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