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

  • -- verifyApiKey returns null for 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. revokedAt is 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.
  • -- lastUsedAt is 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.