# API keys and service tokens

> 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.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authn/api-keys/ -->

`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 CEPT `scope`, `is_superadmin` | `type: "service"` + CEPT `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](/blocks/baas/iam/api/).

## Issuing a key

```http
POST /auth/apikey
```

Requires an authenticated principal — a Bearer token or another API key — and the four CEPT
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. |

```bash
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}'
```

```json
{
  "id": "01K3Z…",
  "key": "01K3Z….Xr9d-7fQ…",
  "expires_at": "2026-10-25T09:12:44Z",
  "capabilities": { "roles": ["reader"] }
}
```

:::caution
`key` is the only time the plaintext exists. The server stores `sha256(secret)` and cannot
recover or re-display it. Losing it means issuing a new key: there is no "show key" endpoint,
no "list my keys" endpoint, and the `api_key` entity's access rules restrict read, create,
update and delete to the internal service identity, so it is not reachable through the generic
entity surface either.
:::

### Wire format

```text
<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

`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: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_superadmin` | A 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:

| 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 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.

:::caution
`is_superadmin` is read from an `is_superadmin` claim that no token mint emits. A
Bearer-authenticated issuer therefore always fails that check, and `is_superadmin: true`
cannot be obtained through this endpoint from a token-authenticated caller. Operator status on
the wire is the `roles` claim derived from the reserved realm — see
[Superadmin](/blocks/baas/iam/authz/superadmin/).
:::

## Presenting a key

```http
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.

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 CEPT `<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](/blocks/baas/iam/authn/passwordless/).

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

:::caution
An API key does **not** authenticate the generic entity surface. `/rest`, `/anon/rest`,
`/admin` and `/schema` are gated by a second, independent validator that extracts a JWT or
PASETO from the `Authorization` header. A request carrying only `Authorization: ApiKey …` is
rejected there with a plain-text `401 Unauthorized` body and no error code, before any handler
runs — so the roles bound into the key never reach the access-rule evaluator on that surface.
Exchange the key for a service token first.
:::

:::note
`GET /rest/:entity` and `PATCH /rest` additionally accept a chassis-level `X-Api-Key` header
matched against the single shared `api.key` config value. That is a different mechanism with a
different lifecycle: one static secret per deployment, not an issued, scoped, revocable IAM
credential. An IAM-issued key presented there does not match and falls through to the token
paths.
:::

## 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:

```bash
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'
```

```json
{ "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.

:::caution
Revoke performs no ownership check. It requires only *some* authenticated principal in the
tenant, and it ignores the delete result — an unknown id, a key belonging to another user, and
a failed delete all return `200 {"ok": true}`. Nothing in the issue path binds a key to the
principal that created it, so there is no owner to check against. A `before_apikey_revoke`
hook can abort the call, but auth-flow hooks are loaded only from the service binary's own
embedded pointcut files; a product cannot supply one. Treat revocation authority as
tenant-wide.
:::

## The service-token exchange

```http
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`.

```bash
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'
```

```json
{ "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 CEPT |
| `scope` | the key's list of `customer:env:product: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](/blocks/baas/iam/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

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` | CEPT 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` | CEPT 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` | CEPT 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` | CEPT 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. |

:::note
The handler's `apikey_invalid` is rarely what a caller sees. The credential middleware runs
first on every route and applies the same lookup, hash comparison and fail-closed expiry
check, so a bad key is answered `401 invalid_api_key` before the exchange handler is reached.
Key on `(status, code)` pairs and treat the two as equivalent.
:::

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

```json
{ "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](/blocks/baas/iam/error/).