{
  "docId": "baas.iam.authn.api-keys",
  "title": "API keys and service tokens",
  "summary": "How iam.svc issues, scopes, presents and revokes API keys, which routes accept one in place of a bearer token, and how a scoped key is exchanged at POST /auth/token for a short-lived service token.",
  "url": "https://docs.kis.ai/blocks/baas/iam/authn/api-keys/",
  "markdown": "https://docs.kis.ai/blocks/baas/iam/authn/api-keys.md",
  "product": "iam",
  "area": "guide",
  "documentType": "guide",
  "status": "active",
  "authority": "canonical",
  "visibility": "public",
  "documentationVersion": "2026.07",
  "productVersion": "2.0.0",
  "apiContractVersion": null,
  "httpPathVersioning": "none",
  "intent": [],
  "audience": [],
  "lastModified": "2026-07-29",
  "lastReviewed": null,
  "owners": [],
  "appliesTo": [
    ">=2.0.0 <2.1.0"
  ],
  "prerequisites": [],
  "related": [],
  "supersedes": [],
  "supersededBy": null,
  "ai": {
    "discoverable": true,
    "retrievable": true,
    "authoritative": true,
    "chunking": "heading",
    "answerableQuestions": []
  },
  "headings": [
    {
      "depth": 2,
      "text": "Issuing a key",
      "id": "issuing-a-key"
    },
    {
      "depth": 3,
      "text": "Wire format",
      "id": "wire-format"
    },
    {
      "depth": 2,
      "text": "Capabilities and issuance authority",
      "id": "capabilities-and-issuance-authority"
    },
    {
      "depth": 2,
      "text": "Presenting a key",
      "id": "presenting-a-key"
    },
    {
      "depth": 2,
      "text": "Expiry, rotation and revocation",
      "id": "expiry-rotation-and-revocation"
    },
    {
      "depth": 2,
      "text": "The service-token exchange",
      "id": "the-service-token-exchange"
    },
    {
      "depth": 2,
      "text": "Errors",
      "id": "errors"
    }
  ],
  "wordCount": 2230,
  "body": "`iam.svc` issues one non-interactive credential — an **API key** — and exchanges a subset of\nthose keys for a short-lived **service token**. They are not interchangeable: the key\nauthenticates a caller to `iam.svc` itself; the service token is the PASETO the rest of the\nfleet accepts.\n\n| | API key | Service token |\n|---|---|---|\n| Shape | `<key-id>.<secret>`, opaque | PASETO v4.public |\n| Header | `Authorization: ApiKey <key>` | `Authorization: Bearer <token>` |\n| Obtained from | `POST /auth/apikey` | `POST /auth/token` |\n| Default lifetime | 365 days | 15 minutes |\n| Accepted by | `iam.svc` auth routes only | every service that trusts the issuer's JWKS |\n| Revocable | yes, `DELETE /auth/apikey/:id` | no — wait out the TTL |\n| Carries | `roles`, optional CEPT `scope`, `is_superadmin` | `type: \"service\"` + CEPT `scope` |\n\nA key whose capabilities carry no `scope` is an ordinary user key. A key that carries one is a\n**service key** and is the only kind `POST /auth/token` will exchange.\n\nEndpoint-level request and response schemas live in the [IAM API reference](/blocks/baas/iam/api/).\n\n## Issuing a key\n\n```http\nPOST /auth/apikey\n```\n\nRequires an authenticated principal — a Bearer token or another API key — and the four CEPT\nheaders. Rate-limited under the `auth_apikey` profile.\n\n| Field | Type | Required | Default | Meaning |\n|---|---|---|---|---|\n| `service` | string | no | `kisai-v2` | Free-form tag describing what the key is for. Stored on the row for audit. |\n| `capabilities` | object | no | `{}` | What the bearer gets: `roles`, `scope`, `is_superadmin`. |\n| `expires_in_days` | int | no | `365` | Lifetime in days. Absent, `0` or negative uses the default. There is no upper bound. |\n\n```bash\ncurl -s localhost:5030/auth/apikey \\\n  -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer <access token>' \\\n  -H 'X-Customer: acme' -H 'X-Product: iam' -H 'X-Env: prod' -H 'X-Tenant: main' \\\n  -d '{\"service\":\"ci-pipeline\",\"capabilities\":{\"roles\":[\"reader\"]},\"expires_in_days\":90}'\n```\n\n```json\n{\n  \"id\": \"01K3Z…\",\n  \"key\": \"01K3Z….Xr9d-7fQ…\",\n  \"expires_at\": \"2026-10-25T09:12:44Z\",\n  \"capabilities\": { \"roles\": [\"reader\"] }\n}\n```\n\n:::caution\n`key` is the only time the plaintext exists. The server stores `sha256(secret)` and cannot\nrecover or re-display it. Losing it means issuing a new key: there is no \"show key\" endpoint,\nno \"list my keys\" endpoint, and the `api_key` entity's access rules restrict read, create,\nupdate and delete to the internal service identity, so it is not reachable through the generic\nentity surface either.\n:::\n\n### Wire format\n\n```text\n<key-id>.<secret>\n```\n\n`key-id` is a ULID and the row id — it is what `DELETE /auth/apikey/:id` takes. `secret` is 32\nbytes from the system CSPRNG, base64url without padding. Authentication compares in constant\ntime against the stored hash, so timing does not separate \"wrong secret\" from \"no such key\".\n\n## Capabilities and issuance authority\n\n`capabilities` has exactly three channels that confer anything. Each is bounded by what the\n*issuer* already holds, checked before the row is written. Exceeding any of them is\n`403 scope_exceeds_authority`, and the message names which one.\n\n| Channel | What it confers | Issuance rule |\n|---|---|---|\n| `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. |\n| `scope` | List 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 `*`. |\n| `is_superadmin` | A boolean on the resolved principal. | Only a superadmin issuer may set it. |\n\nThe issuer's CEPT permissions are derived from its `roles` claim through the platform role\nladder, evaluated at the request's CEPT position:\n\n| Role | Permission granted |\n|---|---|\n| `platform_admin` | `*:*:*:*` |\n| `customer_admin` | `<customer>:*:*:*` |\n| `product_admin` | `<customer>:<product>:*:*` |\n| `env_admin` | `<customer>:<product>:<env>:*` |\n| `tenant_admin` · `tenant_user` · `tenant_viewer` | the request's own CEPT |\n\nAny other role contributes no CEPT permission. Deny-by-default follows: a principal whose\nroles all fall outside that ladder holds no permissions, so the grant check fails for every\nscope entry and it cannot issue a scoped service key at all. A request that asks for none of\nthe three channels passes with nothing to check.\n\n:::caution\n`is_superadmin` is read from an `is_superadmin` claim that no token mint emits. A\nBearer-authenticated issuer therefore always fails that check, and `is_superadmin: true`\ncannot be obtained through this endpoint from a token-authenticated caller. Operator status on\nthe wire is the `roles` claim derived from the reserved realm — see\n[Superadmin](/blocks/baas/iam/authz/superadmin/).\n:::\n\n## Presenting a key\n\n```http\nAuthorization: ApiKey <key-id>.<secret>\n```\n\nThe credential middleware wraps every route on the service. It resolves the row in the\n**request tenant's** datastore, so a key issued in one tenant is simply absent in another and\nfails there. On success it attaches the same principal shape a Bearer token produces: subject\nis the key id, tenant is the request CEPT, roles come from `capabilities.roles`. No actor is\never attached — an API key is a standing credential, never an impersonation.\n\nTwo schemes are recognised, checked in order: `Bearer <token>` first, then `ApiKey <key>`. A\nrequest with no `Authorization` header passes through with no principal; the handler then\nreturns `401 auth_required` if it needs one.\n\nThese are the routes on which a key stands in for a bearer token:\n\n| Route | Note |\n|---|---|\n| `POST /auth/apikey` | Issue another key, bounded by this key's own capabilities. |\n| `DELETE /auth/apikey/:id` | Revoke a key. |\n| `POST /auth/mfa/totp/setup` · `/enable` · `/disable` | TOTP enrolment for the principal the key names. |\n| `POST /superadmin/impersonate` | Needs 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. |\n\n`POST /auth/webauthn/register/begin` and `/finish` also check for a principal, but the\nservice answers `503 webauthn_disabled` before that check — the deployable binary never\nenables WebAuthn. See [Magic link, WebAuthn and passkeys](/blocks/baas/iam/authn/passwordless/).\n\n`POST /auth/token` requires the `ApiKey` scheme specifically: a Bearer token there is\n`401 apikey_required`.\n\n:::caution\nAn API key does **not** authenticate the generic entity surface. `/rest`, `/anon/rest`,\n`/admin` and `/schema` are gated by a second, independent validator that extracts a JWT or\nPASETO from the `Authorization` header. A request carrying only `Authorization: ApiKey …` is\nrejected there with a plain-text `401 Unauthorized` body and no error code, before any handler\nruns — so the roles bound into the key never reach the access-rule evaluator on that surface.\nExchange the key for a service token first.\n:::\n\n:::note\n`GET /rest/:entity` and `PATCH /rest` additionally accept a chassis-level `X-Api-Key` header\nmatched against the single shared `api.key` config value. That is a different mechanism with a\ndifferent lifecycle: one static secret per deployment, not an issued, scoped, revocable IAM\ncredential. An IAM-issued key presented there does not match and falls through to the token\npaths.\n:::\n\n## Expiry, rotation and revocation\n\nExpiry is read fail-closed: a row whose `expiresat` is missing, null or not a timestamp counts\nas expired, never as unexpired.\n\nThere is no rotation primitive. Rotating a key means issuing a new one, moving callers over,\nthen revoking the old id:\n\n```bash\ncurl -s -X DELETE localhost:5030/auth/apikey/01K3Z… \\\n  -H 'Authorization: Bearer <access token>' \\\n  -H 'X-Customer: acme' -H 'X-Product: iam' -H 'X-Env: prod' -H 'X-Tenant: main'\n```\n\n```json\n{ \"ok\": true }\n```\n\nRevocation takes effect at the next request: the row is deleted, and the next presentation of\nthat key fails resolution in the middleware. The integration suite asserts exactly that — a\nkey that authenticated before the call returns `401` after it.\n\n:::caution\nRevoke performs no ownership check. It requires only *some* authenticated principal in the\ntenant, and it ignores the delete result — an unknown id, a key belonging to another user, and\na failed delete all return `200 {\"ok\": true}`. Nothing in the issue path binds a key to the\nprincipal that created it, so there is no owner to check against. A `before_apikey_revoke`\nhook can abort the call, but auth-flow hooks are loaded only from the service binary's own\nembedded pointcut files; a product cannot supply one. Treat revocation authority as\ntenant-wide.\n:::\n\n## The service-token exchange\n\n```http\nPOST /auth/token\nAuthorization: ApiKey <key-id>.<secret>\n```\n\nAn external caller holds a long-lived API key; internal services must never see it. The\nexchange trades the key at the edge for a short PASETO, so every internal receiver validates\nexactly one thing — a bearer token — and the key stays at the boundary.\n\nNo body. One optional query parameter, `?audience=<service>`, which sets `aud` and narrows the\nreplay surface. Rate-limited under the `auth_token` profile. Only a key whose capabilities\ncarry a non-empty `scope` may be exchanged; a user key without one is `403 not_a_service_key`.\n\n```bash\ncurl -s -X POST 'localhost:5030/auth/token?audience=workflow.svc' \\\n  -H 'Authorization: ApiKey 01K3Z….Xr9d-7fQ…' \\\n  -H 'X-Customer: acme' -H 'X-Product: iam' -H 'X-Env: prod' -H 'X-Tenant: main'\n```\n\n```json\n{ \"token\": \"v4.public.…\", \"token_type\": \"Bearer\", \"expires_in\": 900 }\n```\n\nThe minted claim set is deliberately not the user claim set — no `realm`, no `roles`, no `act`:\n\n| Claim | Value |\n|---|---|\n| `iss` | `iam` |\n| `sub` | `apikey:<key-id>` |\n| `type` | `service` |\n| `tenant` | the request CEPT |\n| `scope` | the key's list of `customer:env:product:tenant` grants |\n| `aud` | present only when `?audience=` was supplied |\n| `iat` · `nbf` · `exp` | issued-at, not-before, expiry |\n\nAt the receiver, `scope` becomes authority **only** when `type` is `service`. A user token\ncarrying a `scope` claim gains nothing from it, and `scope` is a reserved claim name that a\nproduct's `token.claims` cannot set — the escalation path is closed at both ends. See\n[Tokens](/blocks/baas/iam/tokens/) for the signing and verification path.\n\nLifetime is fixed at 15 minutes. `token.expiry` does not reach this path: the exchange passes\nno TTL, so the mint uses its own default and a tenant cannot lengthen it. The token is\nself-contained and there is no revocation for it — a compromised service token is valid until\n`exp`. Revoking the underlying key stops the *next* exchange, not the tokens already issued.\n\nEvery exchange emits an event carrying the key id, the audience and the exact granted scope. A\nfailed attempt is recorded too, while the response to the caller stays opaque.\n\n## Errors\n\nIssue — `POST /auth/apikey`:\n\n| Status | Code | Cause |\n|---|---|---|\n| 401 | `auth_required` | No principal on the request. |\n| 400 | `bad_request` | Body is not valid JSON. |\n| 400 | `no_tenant` | CEPT headers missing. |\n| 403 | `scope_exceeds_authority` | A requested grant, role or `is_superadmin` exceeds the issuer's own authority. |\n| 403 | `hook_denied` | A `before_apikey_issue` hook aborted the request; the message is the hook's error. |\n| 500 | `engine_unavailable` | The tenant's datastore could not be resolved. |\n| 500 | `generate_failed` | Key generation failed. |\n| 500 | `persist_failed` | The `api_key` row could not be written. |\n\nRevoke — `DELETE /auth/apikey/:id`:\n\n| Status | Code | Cause |\n|---|---|---|\n| 401 | `auth_required` | No principal on the request. |\n| 400 | `missing_id` | No id in the path. |\n| 400 | `no_tenant` | CEPT headers missing. |\n| 403 | `hook_denied` | A `before_apikey_revoke` hook aborted the request. |\n| 500 | `engine_unavailable` | The tenant's datastore could not be resolved. |\n\nPresenting a key on any route — emitted by the credential middleware:\n\n| Status | Code | Cause |\n|---|---|---|\n| 401 | `invalid_api_key` | Bad shape, unknown row, secret mismatch, or expired — one envelope for all four. |\n| 401 | `no_tenant` | CEPT headers missing, so the key cannot be resolved against a tenant. |\n| 500 | `engine_unavailable` | The tenant's datastore could not be resolved. |\n\nExchange — `POST /auth/token`:\n\n| Status | Code | Cause |\n|---|---|---|\n| 401 | `apikey_required` | No `Authorization: ApiKey` credential. A Bearer token lands here too. |\n| 400 | `no_tenant` | CEPT headers missing. |\n| 403 | `not_a_service_key` | The key carries no `scope`. |\n| 500 | `engine_unavailable` | The tenant's datastore could not be resolved. |\n| 500 | `mint_failed` | Signing failed — most often a bad external signing key or an unreachable key store. |\n| 401 | `apikey_invalid` | Unknown key, bad secret, or expired — never distinguished from one another. |\n\n:::note\nThe handler's `apikey_invalid` is rarely what a caller sees. The credential middleware runs\nfirst on every route and applies the same lookup, hash comparison and fail-closed expiry\ncheck, so a bad key is answered `401 invalid_api_key` before the exchange handler is reached.\nKey on `(status, code)` pairs and treat the two as equivalent.\n:::\n\nRate-limit rejections use a different envelope from every other error on this surface:\n\n```json\n{ \"error\": \"rate_limited\", \"profile\": \"auth_apikey\", \"retry_after_seconds\": 4 }\n```\n\nwith `Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers. Buckets are\nper-(tenant, caller IP), in memory and per instance — `auth_apikey` allows a burst of 15 and\nrefills at one every four seconds; `auth_token` allows a burst of 60 and refills at one per\nsecond. Requests with no resolved tenant are not bucketed at all.\n\nThe full external code table is on the [error page](/blocks/baas/iam/error/)."
}