API Keys & Machine Access
How does a nightly cron job or a backend service authenticate when there’s no human at the keyboard to log in? It carries an API key — but that key still needs to do things, and only the things it’s allowed to. Auther’s answer is to make API keys first-class subjects in the authorization system.
The one-line mental model: a key is treated like a user. It gets its own relationships and permissions through exactly the same machinery, so everything you already know about tuples, models, and transitivity applies to machines as well as people.
API keys are subjects, not a side channel
Section titled “API keys are subjects, not a side channel”The tempting shortcut with machine credentials is a parallel permission system — a
separate table of “what this key can do.” Auther deliberately avoids that. An API key
gets its own rows in access_tuples with subjectType: "apikey", and from that point
on the permission engine cannot tell the difference between a key and a user. A key can
hold any relation on any entity, and — because it resolves through the same machinery —
it can even inherit permissions through group membership.
This is what keeps the model coherent: there is exactly one authorization story, and both humans and machines are told it.
The key lifecycle
Section titled “The key lifecycle”A key moves through four stages, from minting to a runtime decision:
-
Creation. Keys are minted through better-auth’s API key plugin. Each key is scoped to a user and stored in the
apikeytable with the key value hashed, an optional expiration, and rate-limiting configuration. -
Permission assignment. The key receives its own tuples in
access_tupleswithsubjectType: "apikey". It can be granted any relation on any entity, exactly as a user would be. -
JWT exchange. The key is exchanged at
POST /api/auth/api-key/exchangefor a short-lived JWT — 15-minute expiration — that carries the key’s resolved permissions inline. This is the step worth dwelling on: rather than verifying a raw key on every request, a caller trades it once for a token and then presents that token, so the hot path never touches theapikeytable. -
Runtime permission check. For permissions that require ABAC evaluation, the caller hits
POST /api/auth/check-permission, which evaluates the attribute-based policy and returns an allow/deny decision.
Tracing those stages end to end — the one-time exchange, then a token-only hot path:
sequenceDiagram
participant C as Client (machine)
participant K as POST /api/auth/api-key/exchange
participant R as ApiKeyPermissionResolver
participant P as POST /api/auth/check-permission
C->>K: { apiKey: "ak_live_..." }
K->>R: resolve permissions (tuples + model + groups)
R-->>K: resolved permissions + abac_required
K-->>C: 15-min JWT { sub, scope, permissions, abac_required }
Note over C: present JWT on subsequent calls
C->>P: x-api-key + entity/permission/resource
Note over P: ABAC evaluation for abac_required permissions
P-->>C: { allowed: true/false }
Exchanging a key for a JWT
Section titled “Exchanging a key for a JWT”The exchange endpoint takes a raw key and returns a bearer token. Send the key in a JSON body:
POST /api/auth/api-key/exchangeContent-Type: application/json
{ "apiKey": "ak_live_xxxxxxxxxxxx" }The response is a standard bearer-token envelope. expiresIn is in seconds — 900,
the 15-minute window — and expiresAt gives the absolute expiry so clients don’t have
to do their own clock math:
{ "token": "eyJhbGciOiJSUzI1NiIs...", "tokenType": "Bearer", "expiresIn": 900, "expiresAt": "2025-01-15T11:00:00.000Z"}The JWT itself embeds the resolved authorization state. Its claims are shaped roughly
{ sub, iss, aud, scope, permissions, abac_required, apiKeyId }:
{ "sub": "user_id", "iss": "https://auth.example.com", "aud": "payload-admin", "scope": "api_key_exchange", "permissions": { "client_abc:invoice": ["read", "write", "refund"] }, "abac_required": { "client_abc:invoice": ["refund"] }, "apiKeyId": "key_id"}Two claims do the real work. permissions maps each entity to the relations the key
holds — a verifier can answer most authorization questions from the token alone,
without calling Auther back. The abac_required claim flags the subset of those
permissions whose final decision still depends on resource attributes; in the example
above, the key may refund invoices in principle, but whether a specific invoice is
refundable is an ABAC question that must be evaluated at request time.
The runtime permission check
Section titled “The runtime permission check”When a permission appears in abac_required, holding it in the token is necessary but
not sufficient — the resource’s own attributes decide the outcome. The
check-permission endpoint runs that evaluation. Authenticate with the key itself via
the x-api-key header and describe the entity, the permission, and the resource
attributes to evaluate against:
POST /api/auth/check-permissionx-api-key: ak_live_xxxxxxxxxxxx
{ "entityType": "client_abc:invoice", "entityId": "inv_123", "permission": "refund", "resource": { "attributes": { "amount": 500, "status": "paid" } }}The response is the decision plus the subject it was evaluated for — note
subjectType: "apikey", confirming the key was treated as the principal in its own
right:
{ "allowed": true, "entityType": "client_abc:invoice", "entityId": "inv_123", "permission": "refund", "subjectType": "apikey", "subjectId": "key_id"}How resolution stays unified
Section titled “How resolution stays unified”All of this is held together by the ApiKeyPermissionResolver
(src/lib/services/api-key-permission-resolver.ts). It resolves a key’s permissions
using the same tuple, model, and transitivity logic that the
permission engine applies to users — including group
membership, since API keys can themselves be members of groups. There is no second
code path to keep in sync: machines and users walk the same resolver, which is exactly
why a key’s permissions are as expressive, and as auditable, as any human’s.