Developer API Keys
Optional per-user API keys for programmatic access — off by default, SHA-256 at rest, reveal-once, and how to enable and opt a route in.
Developer API Keys
Valcraven ships an optional per-user API key system so your users can call your
REST endpoints from scripts, CI, or integrations without a browser session. It
lives in lib/api-keys/ (keys.ts + auth.ts), with session-only management
routes under app/api/api-keys/ and a card in /settings.
The whole feature is off by default and gated behind two environment flags — with them unset, key headers are ignored entirely and everything stays session-only. Turn it on only if your app needs programmatic access.
Enabling it
Set both flags (they must be enabled together):
| Flag | Side | Effect |
|---|---|---|
API_KEYS_ENABLED | server | Enables key verification in authenticateRequest() and the /api/api-keys management routes (they return 404 when off). Read at call time. |
NEXT_PUBLIC_API_KEYS_ENABLED | client | Shows the API Keys card in /settings. Build-time inlined — rebuild after changing it. |
API_KEYS_ENABLED=true
NEXT_PUBLIC_API_KEYS_ENABLED=trueOn Cloudflare, the api_keys table also needs to exist: run migration
025_create_api_keys.sql against your remote D1 before enabling the feature in
production. See Environment Variables.
How a key looks
A key is generated as sk_ followed by 43 base64url characters derived from 32
CSPRNG bytes (Web Crypto only, so it runs on Workers). It is presented on a
request in one of two headers:
Authorization: Bearer sk_...
x-api-key: sk_...A Bearer token that does not start with sk_ is not treated as an API key —
it falls through to session auth.
Using a key from a client
Once a user creates a key in Settings → API Keys, they can call any opted-in route with it:
curl https://your-app.example.com/api/items \
-H "x-api-key: sk_your_key_here"Only the example items API is opted in out of the box:
GET /api/items— requires thereadpermissionPOST /api/items— requireswritePATCH/DELETE /api/items/[id]— requirewrite
Everything else is session-only until you explicitly opt it in.
Managing keys
Users manage their own keys from the API Keys card in /settings. Under the
hood these are the session-only management routes (an API key can never manage
keys):
| Method | Path | Purpose |
|---|---|---|
GET | /api/api-keys | List the caller's keys (never returns the hash or full key) |
POST | /api/api-keys | Create a key; returns the full key once in the response |
PATCH | /api/api-keys/[id] | Enable/disable — body { "enabled": boolean } |
DELETE | /api/api-keys/[id] | Revoke (hard delete) |
All four return 404 when API_KEYS_ENABLED is off, require a session, and scope
strictly to the caller's own keys. Create, enable/disable, and revoke are written
to the audit log (never the raw key).
POST /api/api-keys accepts:
name— a label, 1–100 characterspermissions—["read"]or["read","write"]expiresInDays— optional integer, 1–365 (omit for a key that never expires)
The response includes the full key string exactly once. After that only the
display prefix (sk_ab12cd34) is ever shown — there is no way to retrieve the
full key again.
Opting a route in
To let an existing route accept API keys, swap the session lookup for
authenticateRequest() and gate each method with requireApiPermission(). This
is exactly how app/api/items/route.ts does it:
import { authenticateRequest, requireApiPermission } from "@/lib/api-keys/auth";
import { errorResponse } from "@/lib/errors";
export async function GET(request: NextRequest) {
const user = await authenticateRequest(request);
if (!user) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
try {
requireApiPermission(user, "read"); // "write" for mutations
} catch (error) {
return errorResponse(error);
}
// user.userId is the owner id — scope every query to it, exactly as with a session.
}authenticateRequest(request) returns one of:
{ userId, via: "apikey", permissions }— a valid key was presented{ userId, via: "session", email }— no key; a Better Auth session was usednull— unauthenticated
requireApiPermission(user, "read" | "write") throws a ForbiddenError (403)
for API-key callers lacking the permission; session callers always pass (they
have full access). For a step-by-step walkthrough, see
Protect a route with API keys.
The key library
lib/api-keys/keys.ts (generation, hashing, verification — Web Crypto only):
createApiKey(userId, { name, permissions, expiresAt? })— generates the key, persists only its SHA-256 hash + prefix, and returns the full key once.verifyApiKey(rawKey)— hashes the presented key, looks it up by hash, and returns{ userId, permissions }ornullwhen it is unknown, disabled, or expired. Bumpslast_used_atbest-effort (a failed bump never fails verification).parsePermissions/serializePermissions/sha256Hex— helpers.
lib/api-keys/auth.ts (request authentication — server-only, imports @/lib/auth):
apiKeysEnabled()— readsAPI_KEYS_ENABLEDat call time.authenticateRequest(request)— key-or-session auth (above).requireApiPermission(authResult, perm)— per-method permission gate.
The api_keys table is defined in lib/schema.sqlite.ts / lib/schema.pg.ts
(re-exported from lib/schema.ts); columns include key_hash (unique),
key_prefix, permissions (JSON), enabled, expires_at, last_used_at, and
a cascading FK to user(id). The dev wiki page at
/admin/docs/api-keys documents every column.
Security properties
- SHA-256 at rest. Only the hex digest of the full key (
key_hash) is stored — the plaintext is never persisted or logged, and lookup is by hash, so a database leak does not leak usable keys. - Reveal-once. The full key exists only in the create response and the reveal modal; afterwards only the prefix is displayed.
- No session fallback on a bad key. When the flag is on and a request presents a key, the key path is authoritative: an invalid, disabled, or expired key yields 401 even if a valid session cookie also rides along. This is deliberate — a silent fallback would mask key compromise or rotation bugs.
- Permission gating. Key callers carry
["read"]or["read","write"]and are enforced per method; session callers keep full access. - Expiry + disable. An expired (
expires_at <= now) or disabled key fails verification. Disabling is instant and reversible; revocation is permanent.
Related
- Protect a route with API keys — the how-to
- Authentication — the Better Auth session system keys sit alongside
- Response Caching — credentialed requests (including
x-api-key) are never cached
Email campaigns
Admin email marketing on top of Cloudflare Email Service — tag-based audiences, a draft/scheduled/sending lifecycle, queued batched sends, and self-hosted open/click tracking.
Response Caching
Two independent caching layers — Cloudflare Workers Cache for public HTTP responses (with a hard credential bypass) and OpenNext's R2/D1 incremental cache for Next.js ISR/data.