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:

  1. Verification. Authorization: Bearer <prefix>… on any request resolves to an apiKey Subject carrying the key's tenantId + scopes — exactly like a session, so handlers, requireScope, and tenant scoping all just work. The strategy runs in the same auth chain as voltro dev and voltro serve.
  2. 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 (rotate revokes 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 stamps lastUsedAt.
  • Keys are tenant-scoped — a key carries its tenantId into every call.