Service API keys

A service API key lets a backend call Gate's own /api/v1 endpoints without a user session. It is the right credential for reading configuration at boot, recording usage, or managing billing from a worker.

It is the wrong credential for representing a user. Endpoints that mint credentials reject it outright.

Key format

Text
gate_sk_<12 alphanumeric>_<43 base64url>
        └── prefix ──┘   └─── secret ───┘

The prefix is stored in the clear and used to look up the row; the whole key is hashed with HMAC-SHA256 and compared in constant time. Gate never stores the key itself, so it cannot be recovered — only replaced.

Create one

Requires a user session with credential:manage. A service account cannot create another service account.

const response = await fetch(
  `https://evonia-gate.annatarhe.com/api/v1/projects/${projectId}/service-accounts`,
  {
    method: "POST",
    headers: { "content-type": "application/json", cookie: request.headers.get("cookie")! },
    body: JSON.stringify({
      name: "ShellTime API",
      description: "Reads configuration at boot and records usage",
      environmentId, // omit to allow every environment
      scopes: ["config:read", "usage:write"],
      expiresAt: "2027-01-01T00:00:00.000Z", // optional
    }),
  },
);

const { data } = await response.json();
// data.apiKey exists only in THIS response.
await secretStore.write("GATE_API_KEY", data.apiKey);

Constraints:

  • name is 2–80 characters; description is optional, up to 500.
  • scopes holds 1–30 entries, each matching ^[a-z*][a-z0-9:*_-]*$.
  • environmentId, when given, must belong to the project.
  • expiresAt is an optional ISO-8601 timestamp.

Use it

Send the key as X-API-Key. Do not put it in a query string.

const response = await fetch(
  `https://evonia-gate.annatarhe.com/api/v1/environments/${environmentId}/configs`,
  {
    headers: {
      "x-api-key": process.env.GATE_API_KEY!,
      "x-request-id": crypto.randomUUID(),
    },
  },
);

Scopes

Scopes are control-plane permission strings, checked directly against what an endpoint requires. A key with config:read may call GET /environments/{id}/configs; the same key gets 403 from PUT /environments/{id}/configs/{key}, which needs config:update.

* is a wildcard matching everything. Use it only for a trusted administrative worker.

Grant the narrowest set that works:

JobScopes
Read configuration at bootconfig:read
Record usage eventsusage:write
Read analyticsusage:read, billing:read
Manage plans and checkoutbilling:manage
Ask authorization questionsproject:read

Environment pinning

A service account with environmentId set is confined to that environment. Any request touching a different one fails with 403 Service account is scoped to another environment — before the permission check runs.

Pin production keys. An unpinned key that leaks from a staging host can read production secrets.

What a service key cannot do

RefusedWhy
Every /organizations/* endpointRequires a user session
Create or disable service accountsRequires a user session
Create, list, or disable OIDC clientsRequires a user session
Write the Stripe connectionRequires a user session
Reach another projectThe key is bound to one project

The rejection is 403 A user session is required.

Rotation

There is no rotate endpoint. Roll forward:

  1. Create a second service account with the same scopes.

  2. Deploy the new key to every consumer.

  3. Confirm the old key is idle — its lastUsedAt stops advancing.

  4. Disable the old service account with POST /projects/{projectId}/service-accounts/{accountId}/disable.

Disabling the service account invalidates its keys immediately. Set expiresAt on creation if you want a deadline that enforces itself.