API Keys
Programmatic access: generated once, hashed at rest, gated on the plan, revocable.
Users create keys in Settings → API keys. Everything lives in src/lib/api-keys.ts.
# Shape
sk_live_8f3Kd2QmXv1pR7wLnZ4tB0cY6hJ... production sk_test_... everywhere else
The prefix follows NODE_ENV, so a key from a staging box is visibly not a production key.
# Storage
The plaintext key exists exactly once: in the return value of the call that created it. The row holds a SHA-256 of it plus a short, non-secret prefix for display.
const created = await createApiKey({ userId, name, plan });
created.raw // the only moment this exists — show it, then forget it
created.prefix // "sk_live_8f3Kd2" — safe to store and display
A dump of the api_keys table therefore grants nobody anything. A lost key is replaced, never recovered — that is correct behaviour, not a missing feature.
Why SHA-256 and not bcrypt or argon2
Because the secret is 32 bytes of CSPRNG output, not a human password. There is no dictionary to run against it and no work factor worth paying on every single request. Do not carry this reasoning over to passwords, where it would be wrong.
# Plan gating
planAllowsApiKeys(plan) reads the “API access” feature straight off the PLANS object — the same object the pricing page renders.
export function planAllowsApiKeys(plan: Plan): boolean {
const key = plan.toLowerCase() as keyof typeof PLANS;
return PLANS[key]?.features.some((f) => f.toLowerCase().includes("api access")) ?? false;
}
Remove the line from a plan's feature list and its keys stop working. The table and the gate cannot drift apart, because there is only one of them.
# The reference endpoint
curl https://your-app.com/api/v1/me \
-H "Authorization: Bearer sk_live_..."
{
"id": "user_2abc...",
"email": "ada@acme.com",
"plan": "PRO",
"key": { "id": "clx...", "name": "Production server" }
}
src/app/api/v1/me/route.ts exists so the feature is a closed loop: create a key in the UI, call this, get your account back.
# Protect your own route
import { readApiKeyFromHeaders, verifyApiKey } from "@/lib/api-keys";
export async function GET(request: Request) {
const auth = await verifyApiKey(readApiKeyFromHeaders(request.headers));
if (!auth) {
return NextResponse.json({ error: "invalid_api_key" }, {
status: 401,
headers: { "WWW-Authenticate": 'Bearer realm="api"' },
});
}
// auth.user is the owner, auth.apiKey is the credential
}
Both Authorization: Bearer and x-api-key are accepted.
The middleware does not protect /api/v1
src/middleware.ts guards /dashboard, /admin and /api/stripe. Machine endpoints authenticate themselves with the key, which is the point — a Clerk session would defeat it.
# Rate limiting
Quota is keyed on the API key, not the caller's IP: two customers behind one office NAT must not spend each other's budget.
const quota = rateLimit(`api:${auth.apiKey.id}`, 60, 60_000);
if (!quota.success) {
return NextResponse.json({ error: "rate_limited" }, {
status: 429, headers: { "Retry-After": "60" },
});
}
In-memory by default
src/lib/rate-limit.ts is a Map in the process. That is honest for one instance and useless across several — swap it for Redis or Upstash before you scale horizontally.
# Pitfalls
- --
verifyApiKeyreturnsnullfor every failure — unknown, revoked, expired, plan downgraded. Do not “improve” it into specific errors: the difference is exactly what an attacker wants. - -- Revocation is a soft delete.
revokedAtis set and the row stays, so an audit still shows the key existed. The scoping is in the same statement as the update, so a guessed id revokes nothing. - --
lastUsedAtis written fire-and-forget. A failed write must not turn a valid call into a 401. - -- Ten active keys per user by default. Raise it if you must, but a customer with fifty keys has an automation problem.