API keys
First-class API keys — built into the framework. Bearer-token auth for headless callers + admin-gated issue/list/revoke management, with hash-only storage.
API keys are a first-class framework feature, not a plugin — the _voltro_api_keys table is framework-internal, and a single apiKeys: true in app.config.ts turns on both verification and management. Only the SHA-256 hash of a key is ever stored; the raw token is shown once at issue time and is unrecoverable thereafter.
Enable
// app.config.ts
export default {
type: 'api' as const, name: 'api',
apiKeys: true, // or { prefix?: 'myapp_', managementPath?: '/v1/api-keys', ensureActor? }
}This does two things:
- Verification.
Authorization: Bearer <prefix>…on any request resolves to anapiKeySubject carrying the key'stenantId+scopes— exactly like a session, so handlers,requireScope, and tenant scoping all just work. The strategy runs in the same auth chain asvoltro devandvoltro serve. - Management. Admin-gated REST routes mount under
managementPath(default/v1/api-keys):
| Method + path | Action |
|---|---|
POST /v1/api-keys/issue |
mint a key — returns the raw token once |
GET /v1/api-keys |
list the tenant's keys (no secrets) |
POST /v1/api-keys/revoke |
revoke a key by id |
All three require admin:full (ADMIN_SCOPE), so only an admin Subject can manage keys.
# An admin issues a key for a CI pipeline:
curl -XPOST https://api.example.com/v1/api-keys/issue \
-H 'Authorization: Bearer <admin-session>' \
-d '{"name":"CI deploy","scopes":["deploy:write"],"expiresInDays":90}'
# → { "id": "apikey_…", "token": "voltro_…", "keyPrefix": "voltro_ab12" } ← copy the token now
# The CI pipeline then authenticates with it:
curl https://api.example.com/... -H 'Authorization: Bearer voltro_…'In-handler service
Build your own management UI on the same ApiKeyService (issue / verify / rotate / revoke / list):
import { makeApiKeyService, dataStoreApiKeyStore } from '@voltro/runtime'
const svc = makeApiKeyService(dataStoreApiKeyStore(ctx.store))
const issued = await svc.issue({ tenantId, name: 'mobile app', scopes: ['read'] })
// show issued.token ONCE; later: svc.rotate(id), svc.revoke(id), svc.list(tenantId)The second ownership axis — metadata
tenantId and onBehalfOf are the two relationships the framework models. If
your keys also belong to something else — a team, a project, an environment —
and that binding is what authorizes them, store it in metadata:
const key = await keys.issue({
tenantId: ctx.request.subject.tenantId,
name: 'CI deploy',
createdBy: ctx.request.subject.id, // who minted it
onBehalfOf: null, // an ORG key: acts as no person
metadata: { teamId: 'team_7' }, // your axis
})It comes straight back on resolve, so a guard needs no second query:
const resolved = await keys.verify(token)
resolved?.metadata // { teamId: 'team_7' }It survives rotate — a rotated key is the same credential with a new secret,
so dropping it would silently de-authorize every rotated key. And it reaches the
Subject as metadata, alongside the framework's own claims.
It is app data, never identity. The strategy merges your bag UNDER its own
claims: provider, and the acting userId, are written afterwards from
onBehalfOf and always win — including when the answer is "none". A bag that
could set userId would let whoever minted a key choose who the request is.
Before this slot existed, an app with a team axis could authenticate through the
built-in strategy and still not authorize, so apiKeys: true was unusable for
it. The alternatives people reached for were a second table joined on every auth
check, or team:<id> smuggled into scopes — where hasScope then sees a scope
that is not a scope.
Two strategies, one prefix
If your app already runs its own key strategy on a prefix and you then enable
apiKeys: true, both claim the same shape. The chain is first-match-wins, so the
first one decides the Subject — and if they resolve to different authority,
which strategy answered decides whether authorization works.
voltro dev / voltro serve warn at boot when this happens. Give them distinct
prefixes (apiKeys: { prefix: 'vk_' }) or drop one.
A key next to a JWT strategy
A JWT strategy — jwtBearerStrategy and every catalog strategy built on it
(supabase, auth0, clerk, kinde, oidc, workos) — claims every
Authorization: Bearer. A token it cannot verify comes back as a failure,
not a skip, and a failure ends the chain, deliberately: a credential another
IdP rejected could be an attack wearing our shape.
So an API key presented to an app with a JWT strategy used to be refused by the JWT verifier before the key strategy was ever asked. Nothing logged an error; the key simply never worked.
A strategy that declares claimsBearerPrefix is now asked first for a token
carrying that prefix. apiKeys: { prefix: 'vk_' } declares it for you, so a
vk_… token reaches the key strategy even though the framework appends it last.
A token matching no declared prefix leaves the chain exactly as you wrote it — a real JWT is still claimed, and still rejected, by its own verifier.
Every key is an audit subject
Issuing a key also writes its row into actors, the table every createdBy /
updatedBy / deletedBy points at, with kind: 'apiKey' and the same id the
key presents as subject.id. Without it, the first audit()-stamped insert by a
key-authenticated caller fails on the foreign key — and apiKeys: and audit()
are both on by default.
Revoking a key leaves the row: it is the author of everything that key wrote.
The row is written BEFORE the key row, through ApiKeyStore.ensureActor,
which receives the key's id, its displayName and the issue() input
verbatim — on a rotation, the input the rotation rebuilt from the old row, so
ownership survives rotation too.
If you declare your own actors — it takes precedence over the built-in
table — the built-in write ({ id, kind: 'apiKey', displayName }) does not fit
a table without kind, or with required columns of its own, and issue()
fails at the actor write, before any key exists. Give the wiring your row,
built from the input; it is handed the store the key row goes to:
// app.config.ts
import type { ApiKeyActor } from '@voltro/runtime'
import type { DataStore } from '@voltro/database'
export default {
type: 'api' as const, name: 'api',
apiKeys: {
// The framework's own row is { id, kind: 'apiKey', displayName }; this
// table has a different shape, so the row is built from the issue input.
ensureActor: async ({ id, input }: ApiKeyActor, store: DataStore) => {
await store.insertIgnore('actors', {
id, // the key id, unchanged — what the key presents as subject.id
type: input.metadata?.['keyType'] === 'team' ? 'team_api_key' : 'personal_api_key',
ownerId: input.onBehalfOf ?? null,
}, { conflictColumns: ['id'] })
},
},
}Three things the hook must keep: the id is the key id, unchanged; the write is
idempotent (it runs on every issue, rotation included); revoke never deletes
the row. A hand-written ApiKeyStore implements the same hook with the same
input.
Keys issued before the row existed get theirs at boot. Both boot paths
plant, after the schema is applied, one actors row per _voltro_api_keys.id
that has none — through the same ensureActor, handed the issue input rebuilt
from the row (onBehalfOf, metadata and all). Idempotent; two queries when
nothing is missing; logged as api keys: planted actors rows for keys that had none with planted and keys, including planted: 0. That zero confirms
the check completed; absence of the line does not. A key whose row cannot be written (a declared actors
the built-in row does not fit, and no ensureActor) is named in
api keys: some keys still have no actors row and fails where it failed
before; the boot goes on.
Security model
- Hash-only storage. A DB dump never exposes a usable key — only
sha256(token). Lose a token → rotate it (rotaterevokes the old + issues a fresh one with the same scopes). - Scopes gate what a key can do (
requireScope(ctx.subject, 'deploy:write')); expiry (expiresInDays) and revoke stop it. Verification checks not-revoked + not-expired and stampslastUsedAt. - Keys are tenant-scoped — a key carries its
tenantIdinto every call.