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 key | Service token | |
|---|---|---|
| Shape | <key-id>.<secret>, opaque | PASETO v4.public |
| Header | Authorization: ApiKey <key> | Authorization: Bearer <token> |
| Obtained from | POST /auth/apikey | POST /auth/token |
| Default lifetime | 365 days | 15 minutes |
| Accepted by | iam.svc auth routes only | every service that trusts the issuer’s JWKS |
| Revocable | yes, DELETE /auth/apikey/:id | no — wait out the TTL |
| Carries | roles, optional CPET scope, is_superadmin | type: "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.
Issuing a key
Section titled “Issuing a key”POST /auth/apikeyRequires an authenticated principal — a Bearer token or another API key — and the four CPET
headers. Rate-limited under the auth_apikey profile.
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
service | string | no | kisai-v2 | Free-form tag describing what the key is for. Stored on the row for audit. |
capabilities | object | no | {} | What the bearer gets: roles, scope, is_superadmin. |
expires_in_days | int | no | 365 | Lifetime in days. Absent, 0 or negative uses the default. There is no upper bound. |
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"] }}Wire format
Section titled “Wire format”<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 and issuance authority
Section titled “Capabilities and issuance authority”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.
| Channel | What it confers | Issuance rule |
|---|---|---|
roles | Role 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. |
scope | List 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_superadmin | A 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:
| Role | Permission granted |
|---|---|
platform_admin | *:*:*:* |
customer_admin | <customer>:*:*:* |
product_admin | <customer>:<product>:*:* |
env_admin | <customer>:<product>:<env>:* |
tenant_admin · tenant_user · tenant_viewer | the 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.
Presenting a key
Section titled “Presenting a key”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:
| Route | Note |
|---|---|
POST /auth/apikey | Issue another key, bounded by this key’s own capabilities. |
DELETE /auth/apikey/:id | Revoke a key. |
POST /auth/mfa/totp/setup · /enable · /disable | TOTP enrolment for the principal the key names. |
POST /superadmin/impersonate | Needs 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, rotation and revocation
Section titled “Expiry, rotation and revocation”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:
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.
The service-token exchange
Section titled “The service-token exchange”POST /auth/tokenAuthorization: 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.
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:
| Claim | Value |
|---|---|
iss | iam |
sub | apikey:<key-id> |
type | service |
tenant | the request CPET |
scope | the key’s list of customer:product:env:tenant grants |
aud | present only when ?audience= was supplied |
iat · nbf · exp | issued-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.
Errors
Section titled “Errors”Issue — POST /auth/apikey:
| Status | Code | Cause |
|---|---|---|
| 401 | auth_required | No principal on the request. |
| 400 | bad_request | Body is not valid JSON. |
| 400 | no_tenant | CPET headers missing. |
| 403 | scope_exceeds_authority | A requested grant, role or is_superadmin exceeds the issuer’s own authority. |
| 403 | hook_denied | A before_apikey_issue hook aborted the request; the message is the hook’s error. |
| 500 | engine_unavailable | The tenant’s datastore could not be resolved. |
| 500 | generate_failed | Key generation failed. |
| 500 | persist_failed | The api_key row could not be written. |
Revoke — DELETE /auth/apikey/:id:
| Status | Code | Cause |
|---|---|---|
| 401 | auth_required | No principal on the request. |
| 400 | missing_id | No id in the path. |
| 400 | no_tenant | CPET headers missing. |
| 403 | hook_denied | A before_apikey_revoke hook aborted the request. |
| 500 | engine_unavailable | The tenant’s datastore could not be resolved. |
Presenting a key on any route — emitted by the credential middleware:
| Status | Code | Cause |
|---|---|---|
| 401 | invalid_api_key | Bad shape, unknown row, secret mismatch, or expired — one envelope for all four. |
| 401 | no_tenant | CPET headers missing, so the key cannot be resolved against a tenant. |
| 500 | engine_unavailable | The tenant’s datastore could not be resolved. |
Exchange — POST /auth/token:
| Status | Code | Cause |
|---|---|---|
| 401 | apikey_required | No Authorization: ApiKey credential. A Bearer token lands here too. |
| 400 | no_tenant | CPET headers missing. |
| 403 | not_a_service_key | The key carries no scope. |
| 500 | engine_unavailable | The tenant’s datastore could not be resolved. |
| 500 | mint_failed | Signing failed — most often a bad external signing key or an unreachable key store. |
| 401 | apikey_invalid | Unknown 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.