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.
| Flag | Side | Effect |
|---|---|---|
API_KEYS_ENABLED | server | Enables key verification in authenticateRequest() and the /api/api-keys management routes (they 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. |
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 throwsForbiddenError(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, notsession.user.id.authenticateRequestreturns a normalized shape whose id field isuserId, 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_…orx-api-key), the key path is authoritative: an invalid, disabled, or expired key returnsnull(→ 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 withsk_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-keysCRUD 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.
Related
- API keys — the feature, the table, the settings UI, and key generation.
- Add an API route — the base session-only handler you're extending.
- Database migrations — applying migration 025 to enable keys on D1.
- Authentication — the session model the key model sits alongside.
Add a chat tool
Give the AI agent a new tool it can call mid-conversation — the AgentTool shape, the ToolContext it receives, and how to register it, following the built-in create_item / generate_image pattern.
Conventions & Gotchas
The accumulated conventions, testing practices, and hard-won lessons for building on the Valcraven template.