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 CEPT scope, is_superadmintype: "service" + CEPT scope

A key whose capabilities carry no scope is an ordinary user key. A key that carries one is a service key, the kind of API key POST /auth/token exchanges for a service token.

An app key (kisak_<key-id>.<secret>) is a separate credential. It belongs to an app connection, is created through POST /auth/apps, confers nothing itself, and exchanges at POST /auth/token for an agent token whose roles come from the connection’s grant. The rest of this page covers API keys.

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 CEPT headers. An agent token is refused with 403 standing_principal_required: an agent borrows its authority through a grant, and a key it issued would outlive that grant. 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; any positive value is accepted.
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:env:product: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 CEPT 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 CEPT permissions are derived from its roles claim through the platform role ladder, evaluated at the request’s CEPT 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 CEPT

Any other role contributes no CEPT 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 CEPT, 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. An app key (kisak_…) under either scheme also passes through with no principal: only POST /auth/token accepts it.

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 CEPT <customer>:<product>:<env>:superadmin, and it must hold the exact impersonate-readonly or impersonate role.

POST /auth/webauthn/register/begin and /finish answer 503 webauthn_disabled on the shipped service. See Magic link, WebAuthn and passkeys.

POST /auth/token requires the ApiKey scheme for an API key: a Bearer token there is 401 apikey_required. An app key is accepted under either scheme.

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

Rotate a key by 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
ptypservice
svcthe service the key names, when it was issued for one
tenantthe request CEPT
scopethe key’s list of customer:env:product: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.

A service token lives 15 minutes, independent of the tenant’s token.expiry. It is self-contained and valid until exp. Revoking the underlying key stops the next exchange, so a revocation reaches every caller of that key within 15 minutes.

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_tenantCEPT headers missing.
403scope_exceeds_authorityA requested grant, role or is_superadmin exceeds the issuer’s own authority.
403standing_principal_requiredThe caller is an agent.
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.
400app_keyThe id names an app key. Revoke it with DELETE /auth/apps/:id.
400no_tenantCEPT 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_tenantCEPT 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 and no app key; a Bearer access token lands here too.
400no_tenantCEPT 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.