API key provider
WIPLong-lived bearer keys for integrations, CLI tools, and machine-to-machine. Plaintext shown once, hashed at rest, scope-checked at verify.
Setup
import { apiKey } from '@gentleduck/auth/providers/api-key'
export const auth = new AuthEngine({
// ...
apiKeys: {
prefix: 'ak_live_', // user-visible token prefix
randomBytes: 32, // 256-bit secret per key
},
providers: [
apiKey({
apiKeys: auth.apiKeys, // the shared facet
limiterKeyPrefix: 'signin:api-key:',
requireScopes: ['api:read'], // optional default scope check
}),
],
})
Creating keys
const { token, key } = await auth.apiKeys.create({
identityId: ctx.identity.id,
name: 'CI deployment key',
scopes: ['ci:write', 'logs:read'],
expiresAt: new Date('2027-12-31'),
})
// `token` is shown to the user ONCE. After this point, only the hash is stored.
// -> 'ak_live_01HJX7B2T9XKAZQP1G...'
console.log('Share this token securely:', token)
console.log('Key id (safe to log):', key.id)
The plaintext token follows the format:
ak_live_<43-character base64url secret>
The store records:
id- primary key, safe to log.prefix- the visible prefix (ak_live_), for at-a-glance triage.hash- SHA-256 of the secret part, hashed at rest.last4- last 4 characters of the visible token, for UI.scopes- granted scopes.identityId,tenantId,name,expiresAt,revokedAt.
Signing in with a key
// Vanilla client:
await client.signIn({
providerId: 'api-key',
input: { token: 'ak_live_01HJX...' },
})
// -> 200 { session, identity } (session.kind: 'apikey')
// Or with curl:
curl -X POST https://api.example.com/auth/signin \
-H 'Content-Type: application/json' \
-d '{"providerId":"api-key","input":{"token":"ak_live_..."}}'
The verify path:
- Slice off the prefix; SHA-256-hash the secret part.
- Lookup by hash.
- Check
revokedAt == nullandexpiresAt > now. - Check
requireScopes(if configured) is a subset ofscopes. - Mint a session with
kind: 'apikey'so downstream code can branch.
Sessions issued via API keys are typically stateless bearer (no rotation, no MFA, no step-up) - they represent a long-lived machine identity, not a user.
Listing keys
const keys = await auth.apiKeys.list({ identityId })
// -> [{ id, name, prefix, last4, scopes, expiresAt, revokedAt, lastUsedAt }, ...]
The list never contains plaintext - only the metadata. Show last4 in
UIs so users can identify which key is which without seeing the secret.
Revoking and rotating
// Revoke (instant, irreversible):
await auth.apiKeys.revoke({ keyId })
// Rotate (mint a new key, revoke the old one in the same transaction):
const { token, key } = await auth.apiKeys.rotate({ keyId })
// User pastes the new token; the old one is dead immediately.
Scope enforcement
In handlers, check scopes against the resolved session:
import { AuthError } from '@gentleduck/auth/core'
app.post('/api/logs', async (req, res) => {
const ctx = await auth.resolveSession(req)
if (!ctx) throw new AuthError('AUTH/UNAUTHENTICATED')
if (ctx.session.kind === 'apikey') {
await auth.apiKeys.requireScopes(ctx.session.apiKeyId, ['logs:write'])
// -> throws AUTH/APIKEY_SCOPE_INSUFFICIENT if missing
}
// Proceed.
})
For browser-issued sessions (kind: 'user'), requireScopes is a no-op
- scopes only apply to API-key sessions.
Token prefix per environment
A common pattern is two prefixes for dev vs prod:
new AuthEngine({
apiKeys: {
prefix: process.env.NODE_ENV === 'production' ? 'ak_live_' : 'ak_test_',
},
})
Bots and tooling can refuse to accept ak_test_ keys in production
contexts, catching env mix-ups before they reach the database.
Errors
| Code | When |
|---|---|
AUTH/APIKEY_INVALID | Token unknown, malformed, or missing prefix. |
AUTH/APIKEY_REVOKED | Token found but revoked. |
AUTH/APIKEY_SCOPE_INSUFFICIENT | Token valid but missing a required scope. |
AUTH/RATE_LIMITED | Per-key bucket tripped. |