Skip to content

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.

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.

A key moves through four stages, from minting to a runtime decision:

  1. Creation. Keys are minted through better-auth’s API key plugin. Each key is scoped to a user and stored in the apikey table with the key value hashed, an optional expiration, and rate-limiting configuration.

  2. Permission assignment. The key receives its own tuples in access_tuples with subjectType: "apikey". It can be granted any relation on any entity, exactly as a user would be.

  3. JWT exchange. The key is exchanged at POST /api/auth/api-key/exchange for 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 the apikey table.

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

API key → JWT exchange and runtime check
Rendering diagram…

The exchange endpoint takes a raw key and returns a bearer token. Send the key in a JSON body:

POST /api/auth/api-key/exchange
POST /api/auth/api-key/exchange
Content-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.

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-permission
POST /api/auth/check-permission
x-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"
}

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.