Skip to content
Talk to our solutions team

API keys and service tokens

iam.svc issues one non-interactive credential — an API key — and exchanges a subset of those keys for a short-lived service token. They are not interchangeable: the key authenticates a caller to iam.svc itself; the service token is the PASETO the rest of the fleet accepts.

API keyService token
Shape<key-id>.<secret>, opaquePASETO v4.public
HeaderAuthorization: ApiKey <key>Authorization: Bearer <token>
Obtained fromPOST /auth/apikeyPOST /auth/token
Default lifetime365 days15 minutes
Accepted byiam.svc auth routes onlyevery service that trusts the issuer’s JWKS
Revocableyes, DELETE /auth/apikey/:idno — wait out the TTL
Carriesroles, optional CPET scope, is_superadmintype: "service" + CPET scope

A key whose capabilities carry no scope is an ordinary user key. A key that carries one is a service key and is the only kind POST /auth/token will exchange.

Endpoint-level request and response schemas live in the IAM API reference.

POST /auth/apikey

Requires an authenticated principal — a Bearer token or another API key — and the four CPET headers. Rate-limited under the auth_apikey profile.

FieldTypeRequiredDefaultMeaning
servicestringnokisai-v2Free-form tag describing what the key is for. Stored on the row for audit.
capabilitiesobjectno{}What the bearer gets: roles, scope, is_superadmin.
expires_in_daysintno365Lifetime in days. Absent, 0 or negative uses the default. There is no upper bound.
Terminal window
curl -s localhost:5030/auth/apikey \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access token>' \
-H 'X-Customer: acme' -H 'X-Product: iam' -H 'X-Env: prod' -H 'X-Tenant: main' \
-d '{"service":"ci-pipeline","capabilities":{"roles":["reader"]},"expires_in_days":90}'
{
"id": "01K3Z…",
"key": "01K3Z….Xr9d-7fQ…",
"expires_at": "2026-10-25T09:12:44Z",
"capabilities": { "roles": ["reader"] }
}
<key-id>.<secret>

key-id is a ULID and the row id — it is what DELETE /auth/apikey/:id takes. secret is 32 bytes from the system CSPRNG, base64url without padding. Authentication compares in constant time against the stored hash, so timing does not separate “wrong secret” from “no such key”.

capabilities has exactly three channels that confer anything. Each is bounded by what the issuer already holds, checked before the row is written. Exceeding any of them is 403 scope_exceeds_authority, and the message names which one.

ChannelWhat it confersIssuance rule
rolesRole slugs projected into the principal that key produces, and bridged to the entity access-rule evaluator.The issuer must already hold every role it confers. A superadmin issuer may confer any.
scopeList of customer:product:env:tenant grants (* is a wildcard at any segment). Makes the key a service key.Each grant must be covered by one of the issuer’s own CPET permissions — a wildcard segment covers anything, a concrete segment covers only itself and never *.
is_superadminA boolean on the resolved principal.Only a superadmin issuer may set it.

The issuer’s CPET permissions are derived from its roles claim through the platform role ladder, evaluated at the request’s CPET position:

RolePermission granted
platform_admin*:*:*:*
customer_admin<customer>:*:*:*
product_admin<customer>:<product>:*:*
env_admin<customer>:<product>:<env>:*
tenant_admin · tenant_user · tenant_viewerthe request’s own CPET

Any other role contributes no CPET permission. Deny-by-default follows: a principal whose roles all fall outside that ladder holds no permissions, so the grant check fails for every scope entry and it cannot issue a scoped service key at all. A request that asks for none of the three channels passes with nothing to check.

Authorization: ApiKey <key-id>.<secret>

The credential middleware wraps every route on the service. It resolves the row in the request tenant’s datastore, so a key issued in one tenant is simply absent in another and fails there. On success it attaches the same principal shape a Bearer token produces: subject is the key id, tenant is the request CPET, roles come from capabilities.roles. No actor is ever attached — an API key is a standing credential, never an impersonation.

Two schemes are recognised, checked in order: Bearer <token> first, then ApiKey <key>. A request with no Authorization header passes through with no principal; the handler then returns 401 auth_required if it needs one.

These are the routes on which a key stands in for a bearer token:

RouteNote
POST /auth/apikeyIssue another key, bounded by this key’s own capabilities.
DELETE /auth/apikey/:idRevoke a key.
POST /auth/mfa/totp/setup · /enable · /disableTOTP enrolment for the principal the key names.
POST /superadmin/impersonateNeeds more than a principal: the principal’s tenant must equal the plane CPET <customer>:<product>:<env>:superadmin, and it must hold the exact impersonate-readonly or impersonate role.

POST /auth/webauthn/register/begin and /finish also check for a principal, but the service answers 503 webauthn_disabled before that check — the deployable binary never enables WebAuthn. See Magic link, WebAuthn and passkeys.

POST /auth/token requires the ApiKey scheme specifically: a Bearer token there is 401 apikey_required.

Expiry is read fail-closed: a row whose expiresat is missing, null or not a timestamp counts as expired, never as unexpired.

There is no rotation primitive. Rotating a key means issuing a new one, moving callers over, then revoking the old id:

Terminal window
curl -s -X DELETE localhost:5030/auth/apikey/01K3Z… \
-H 'Authorization: Bearer <access token>' \
-H 'X-Customer: acme' -H 'X-Product: iam' -H 'X-Env: prod' -H 'X-Tenant: main'
{ "ok": true }

Revocation takes effect at the next request: the row is deleted, and the next presentation of that key fails resolution in the middleware. The integration suite asserts exactly that — a key that authenticated before the call returns 401 after it.

POST /auth/token
Authorization: ApiKey <key-id>.<secret>

An external caller holds a long-lived API key; internal services must never see it. The exchange trades the key at the edge for a short PASETO, so every internal receiver validates exactly one thing — a bearer token — and the key stays at the boundary.

No body. One optional query parameter, ?audience=<service>, which sets aud and narrows the replay surface. Rate-limited under the auth_token profile. Only a key whose capabilities carry a non-empty scope may be exchanged; a user key without one is 403 not_a_service_key.

Terminal window
curl -s -X POST 'localhost:5030/auth/token?audience=workflow.svc' \
-H 'Authorization: ApiKey 01K3Z….Xr9d-7fQ…' \
-H 'X-Customer: acme' -H 'X-Product: iam' -H 'X-Env: prod' -H 'X-Tenant: main'
{ "token": "v4.public.…", "token_type": "Bearer", "expires_in": 900 }

The minted claim set is deliberately not the user claim set — no realm, no roles, no act:

ClaimValue
issiam
subapikey:<key-id>
typeservice
tenantthe request CPET
scopethe key’s list of customer:product:env:tenant grants
audpresent only when ?audience= was supplied
iat · nbf · expissued-at, not-before, expiry

At the receiver, scope becomes authority only when type is service. A user token carrying a scope claim gains nothing from it, and scope is a reserved claim name that a product’s token.claims cannot set — the escalation path is closed at both ends. See Tokens for the signing and verification path.

Lifetime is fixed at 15 minutes. token.expiry does not reach this path: the exchange passes no TTL, so the mint uses its own default and a tenant cannot lengthen it. The token is self-contained and there is no revocation for it — a compromised service token is valid until exp. Revoking the underlying key stops the next exchange, not the tokens already issued.

Every exchange emits an event carrying the key id, the audience and the exact granted scope. A failed attempt is recorded too, while the response to the caller stays opaque.

Issue — POST /auth/apikey:

StatusCodeCause
401auth_requiredNo principal on the request.
400bad_requestBody is not valid JSON.
400no_tenantCPET headers missing.
403scope_exceeds_authorityA requested grant, role or is_superadmin exceeds the issuer’s own authority.
403hook_deniedA before_apikey_issue hook aborted the request; the message is the hook’s error.
500engine_unavailableThe tenant’s datastore could not be resolved.
500generate_failedKey generation failed.
500persist_failedThe api_key row could not be written.

Revoke — DELETE /auth/apikey/:id:

StatusCodeCause
401auth_requiredNo principal on the request.
400missing_idNo id in the path.
400no_tenantCPET headers missing.
403hook_deniedA before_apikey_revoke hook aborted the request.
500engine_unavailableThe tenant’s datastore could not be resolved.

Presenting a key on any route — emitted by the credential middleware:

StatusCodeCause
401invalid_api_keyBad shape, unknown row, secret mismatch, or expired — one envelope for all four.
401no_tenantCPET headers missing, so the key cannot be resolved against a tenant.
500engine_unavailableThe tenant’s datastore could not be resolved.

Exchange — POST /auth/token:

StatusCodeCause
401apikey_requiredNo Authorization: ApiKey credential. A Bearer token lands here too.
400no_tenantCPET headers missing.
403not_a_service_keyThe key carries no scope.
500engine_unavailableThe tenant’s datastore could not be resolved.
500mint_failedSigning failed — most often a bad external signing key or an unreachable key store.
401apikey_invalidUnknown key, bad secret, or expired — never distinguished from one another.

Rate-limit rejections use a different envelope from every other error on this surface:

{ "error": "rate_limited", "profile": "auth_apikey", "retry_after_seconds": 4 }

with Retry-After, X-RateLimit-Limit and X-RateLimit-Remaining headers. Buckets are per-(tenant, caller IP), in memory and per instance — auth_apikey allows a burst of 15 and refills at one every four seconds; auth_token allows a burst of 60 and refills at one per second. Requests with no resolved tenant are not bucketed at all.

The full external code table is on the error page.