Valcraven Docs
Building with Valcraven

Protect a route with API keys

Opt an API route into sk_… API-key auth with authenticateRequest() and requireApiPermission() — a flag-gated, session-first model where an invalid key never falls back to the session.

Protect a route with API keys

By default every API route is authenticated by the browser session cookie. To let a user call a route programmatically — from a script, CI, or an integration, with no browser — opt it into API-key auth. A user mints a personal sk_… key in Settings; the route accepts it in addition to a session. This recipe shows how to opt a route in.

For the feature end to end — the api_keys table, key generation and hashing, the settings UI — see API keys and the dev wiki at /admin/docs/api-keys.

It's off by default (two flags)

The whole module is gated behind two environment flags. With the server flag off, key headers are ignored entirely and everything is session-only.

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

Enable both together. Enabling in production also means the api_keys table must exist — apply migration 025_create_api_keys.sql to D1 (see Database migrations).

Two helpers

Both live in @/lib/api-keys/auth (server-only — it imports @/lib/auth, so never import it from a client component):

  • authenticateRequest(request) — resolves the caller from an API key (when the flag is on and a key is presented) or the Better Auth session. Returns one of:
    • { userId, via: "apikey", permissions }
    • { userId, via: "session", email }
    • null (unauthenticated)
  • requireApiPermission(authResult, "read" | "write") — enforces a permission. Session callers always pass (full access). API-key callers must carry the permission or it throws ForbiddenError (403).

A key is presented as either header:

Authorization: Bearer sk_...
x-api-key: sk_...

Opt a route in

Swap the plain session check for authenticateRequest, and add a requireApiPermission call per method. This is exactly what the example items route does (app/api/items/route.ts) — it's the copy-paste reference:

import { NextRequest, NextResponse } from "next/server";
import { errorResponse } from "@/lib/errors";
import { authenticateRequest, requireApiPermission } from "@/lib/api-keys/auth";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

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"); // reads need "read"
  } catch (error) {
    return errorResponse(error); // 403 for an API key lacking the permission
  }

  // Scope the query to the caller — works the same for a key or a session.
  // user.userId is the authenticated user's id in both cases.
  const rows = await getData(user.userId);
  return NextResponse.json({ items: rows });
}

For a mutating method, require "write":

export async function POST(request: NextRequest) {
  const user = await authenticateRequest(request);
  if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });

  try {
    requireApiPermission(user, "write");
  } catch (error) {
    return errorResponse(error);
  }
  // …
}

Choose the permission per method: reads (GET) require read, writes (POST/PATCH/PUT/DELETE) require write. In items, the list GET requires read and every mutation requires write.

user.userId, not session.user.id. authenticateRequest returns a normalized shape whose id field is userId, for both key and session callers. If a handler needs the email, note it's only present on the session branch (user.via === "session") — an API-key caller has no email.

The security model (know these)

Opting a route in inherits a deliberately strict posture:

  • No session fallback on a presented-but-invalid key. When the flag is on and a request presents a key (Bearer sk_… or x-api-key), the key path is authoritative: an invalid, disabled, or expired key returns null (→ your 401) even if a valid session cookie rides along. A silent fallback would mask a compromised or mis-rotated key. Bearer tokens that don't start with sk_ are not treated as presented keys — they fall through to session auth.
  • Hashed at rest, revealed once. Only the SHA-256 hash of a key is stored; the plaintext is shown once at creation and never again. A database leak doesn't leak usable keys.
  • Keys can't manage keys. The /api/api-keys CRUD routes are session-only, so a leaked key can't mint more keys or escalate.
  • Expiry and disable both fail verification instantly.

What stays session-only

Only opt in routes that make sense for programmatic access — typically your resource CRUD. Leave account-management, billing, and admin routes session-only; don't reach for authenticateRequest there. Everything you don't touch keeps the standard session-based route behavior.

On this page