Valcraven Docs
Features

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):

FlagSideEffect
API_KEYS_ENABLEDserverEnables key verification in authenticateRequest() and the /api/api-keys management routes (they return 404 when off). Read at call time.
NEXT_PUBLIC_API_KEYS_ENABLEDclientShows the API Keys card in /settings. Build-time inlined — rebuild after changing it.
API_KEYS_ENABLED=true
NEXT_PUBLIC_API_KEYS_ENABLED=true

On 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 the read permission
  • POST /api/items — requires write
  • PATCH / DELETE /api/items/[id] — require write

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):

MethodPathPurpose
GET/api/api-keysList the caller's keys (never returns the hash or full key)
POST/api/api-keysCreate 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 characters
  • permissions["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 used
  • null — 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 } or null when it is unknown, disabled, or expired. Bumps last_used_at best-effort (a failed bump never fails verification).
  • parsePermissions / serializePermissions / sha256Hex — helpers.

lib/api-keys/auth.ts (request authentication — server-only, imports @/lib/auth):

  • apiKeysEnabled() — reads API_KEYS_ENABLED at 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.

On this page