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:

  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.

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 (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.