User API Keys
Let your users call your API programmatically. Generate and hash API keys, issue them from Supabase, authenticate requests in Next.js middleware, and scope and rate-limit each key.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.
Give your users a key, let them call your API from their own code. That is the difference between a SaaS people log into and a platform people build on. This guide wires the whole thing end to end: generate a key, hash it before it ever hits the database, authenticate every incoming request in Next.js 16 middleware, scope and rate-limit each key, and ship a settings page where users create and revoke their own. Claude Code writes the code. You own the design decisions, which is where the security actually lives.
What You're Building
Five moving parts, each small on its own.
A api_keys table in Postgres that stores a hash of every key, never the key itself. A generator that mints a high-entropy token, shows it once, and throws away the plaintext. A proxy.ts middleware that intercepts /api/v1 requests, verifies the presented key, and rate-limits it. A per-key scope check inside your route handlers. And a settings page where a signed-in user manages their own keys.
The security rule that drives every decision: the raw key exists in memory for exactly one response, then only its hash survives. Everything else follows from that.
The Data Model
Start with the table. Ask Claude Code to plan the migration before writing SQL, then let it produce the schema. Each key belongs to a user, carries a set of scopes, and has its own rate limit. Store the key_prefix and last_four separately so the UI can show a recognizable label like btn_live_...a1b2 without ever holding the secret.
-- supabase/migrations/0002_api_keys.sql
create table api_keys (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null,
key_hash text not null unique,
key_prefix text not null,
last_four text not null,
scopes text[] not null default '{}',
rate_limit_per_minute int not null default 60,
last_used_at timestamptz,
expires_at timestamptz,
revoked_at timestamptz,
created_at timestamptz not null default now()
);
create index api_keys_key_hash_idx on api_keys (key_hash);
create index api_keys_user_id_idx on api_keys (user_id);Row-level security is what keeps one user from reading or revoking another user's keys. The policies scope every operation to auth.uid(), so even a bug in your application code cannot cross that line.
alter table api_keys enable row level security;
create policy "owners read their keys"
on api_keys for select
using (user_id = auth.uid());
create policy "owners create their keys"
on api_keys for insert
with check (user_id = auth.uid());
create policy "owners revoke their keys"
on api_keys for update
using (user_id = auth.uid())
with check (user_id = auth.uid());Notice there is no delete policy and no select on key_hash needed by the client. Revoking is an update that stamps revoked_at, which keeps an audit trail instead of erasing history.
Generating and Hashing Keys
A key is random bytes with a readable prefix. The prefix (btn_live_) tells anyone who finds a leaked key exactly what it is and where it came from, which speeds up incident response. The 24 random bytes give 192 bits of entropy, far past anything brute-forceable.
The critical line is the hash. You compute a SHA-256 digest of the full token, store that, and return the plaintext to the caller once. After this function returns, the raw key is gone from your system.
// lib/api-keys.ts
import { randomBytes, createHash } from "crypto";
export const KEY_PREFIX = "btn_live_";
export function generateApiKey() {
const secret = randomBytes(24).toString("base64url");
const token = `${KEY_PREFIX}${secret}`;
const keyHash = createHash("sha256").update(token).digest("hex");
return {
token, // shown to the user once, never stored
keyHash, // stored in the database
lastFour: token.slice(-4), // stored for display
};
}Why SHA-256 and not bcrypt here. bcrypt is slow on purpose because human passwords are guessable, and you want each guess to cost the attacker time. This token is not guessable, so a slow hash buys you nothing and costs you latency on every request, since you hash the presented key on every call. Fast is correct for high-entropy secrets.
Rate Limiting in Postgres
Verification and rate limiting happen together, in one atomic database round trip. A single SECURITY DEFINER function looks up the key by its hash, checks that it is neither revoked nor expired, increments a per-minute counter, and reports back whether the request is allowed. Doing it in one function means two concurrent requests can never both slip past the limit.
First, a tiny table to hold the counters. Each row is one key inside one fixed 60-second window.
create table api_key_usage (
key_id uuid not null references api_keys(id) on delete cascade,
window_start timestamptz not null,
request_count int not null default 0,
primary key (key_id, window_start)
);Now the function. It buckets the current time into a 60-second window, does an atomic upsert to bump the counter, updates last_used_at, and returns everything the caller needs in a single row.
create or replace function authenticate_api_key(p_hash text)
returns table (
key_id uuid,
user_id uuid,
scopes text[],
allowed boolean,
remaining int,
reason text
)
language plpgsql
security definer
set search_path = public
as $$
declare
k record;
v_window_start timestamptz;
v_count int;
begin
select * into k from api_keys where key_hash = p_hash;
if k.id is null then
return query select null::uuid, null::uuid, null::text[], false, 0, 'invalid_key';
return;
end if;
if k.revoked_at is not null then
return query select k.id, k.user_id, k.scopes, false, 0, 'revoked';
return;
end if;
if k.expires_at is not null and k.expires_at < now() then
return query select k.id, k.user_id, k.scopes, false, 0, 'expired';
return;
end if;
v_window_start := to_timestamp(floor(extract(epoch from now()) / 60) * 60);
insert into api_key_usage (key_id, window_start, request_count)
values (k.id, v_window_start, 1)
on conflict (key_id, window_start)
do update set request_count = api_key_usage.request_count + 1
returning request_count into v_count;
update api_keys set last_used_at = now() where id = k.id;
return query select
k.id,
k.user_id,
k.scopes,
v_count <= k.rate_limit_per_minute,
greatest(k.rate_limit_per_minute - v_count, 0),
case when v_count <= k.rate_limit_per_minute then 'ok' else 'rate_limited' end;
end;
$$;
revoke execute on function authenticate_api_key(text) from anon, authenticated;The final revoke matters. The function bypasses row-level security by design, so you only ever call it from a trusted server context with the service role key, never from the browser. Old rows in api_key_usage are harmless but pile up, so prune windows older than an hour with an Inngest cron or pg_cron on a schedule.
Authenticating Requests in proxy.ts
Next.js 16 renamed middleware.ts to proxy.ts and the exported function from middleware to proxy. This is where your API keys get checked, before any route code runs. Keep the heavy logic in a reusable helper so the middleware stays thin and the verification is testable on its own.
The helper hashes the presented token with Web Crypto (so it runs identically on the Edge runtime), calls the Postgres function through a service-role client, and returns a small tagged result.
// lib/api-auth.ts
import { createClient } from "@supabase/supabase-js";
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
);
async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export type AuthResult =
| { ok: true; keyId: string; userId: string; scopes: string[]; remaining: number }
| { ok: false; status: 401 | 429; reason: string };
export async function authenticateApiKey(token: string): Promise<AuthResult> {
if (!token.startsWith("btn_live_")) {
return { ok: false, status: 401, reason: "invalid_key" };
}
const hash = await sha256Hex(token);
const { data, error } = await admin
.rpc("authenticate_api_key", { p_hash: hash })
.single();
if (error || !data || data.reason === "invalid_key") {
return { ok: false, status: 401, reason: "invalid_key" };
}
if (data.reason === "revoked" || data.reason === "expired") {
return { ok: false, status: 401, reason: data.reason };
}
if (!data.allowed) {
return { ok: false, status: 429, reason: "rate_limited" };
}
return {
ok: true,
keyId: data.key_id,
userId: data.user_id,
scopes: data.scopes ?? [],
remaining: data.remaining,
};
}The middleware itself is short. It pulls the bearer token off the Authorization header, calls the helper, and either rejects the request or forwards it with the authenticated identity attached as request headers. Downstream route handlers read those headers instead of re-verifying the key.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { authenticateApiKey } from "@/lib/api-auth";
export const config = {
matcher: "/api/v1/:path*",
};
export async function proxy(request: NextRequest) {
const header = request.headers.get("authorization");
const token = header?.startsWith("Bearer ") ? header.slice(7).trim() : null;
if (!token) {
return NextResponse.json({ error: "missing_api_key" }, { status: 401 });
}
const result = await authenticateApiKey(token);
if (!result.ok) {
return NextResponse.json(
{ error: result.reason },
{
status: result.status,
headers: result.status === 429 ? { "retry-after": "60" } : undefined,
},
);
}
const headers = new Headers(request.headers);
headers.set("x-api-user-id", result.userId);
headers.set("x-api-key-id", result.keyId);
headers.set("x-api-scopes", result.scopes.join(" "));
const response = NextResponse.next({ request: { headers } });
response.headers.set("x-ratelimit-remaining", String(result.remaining));
return response;
}The pattern that makes this clean is NextResponse.next({ request: { headers } }). It forwards a mutated set of request headers to the route handler, so the handler receives a verified x-api-user-id it can trust without touching the key again. One database round trip per request covers auth, expiry, and rate limiting.
Scoping Access in Your API Routes
Middleware answers "is this a valid key". The route answers "is this key allowed to do this". Keep those separate. A key scoped to projects:read should never be able to write, and that check belongs next to the code doing the writing.
Read the forwarded scopes header and enforce the one this endpoint requires. Because middleware already verified the user, you scope the query by the trusted x-api-user-id header. Note that the service-role client bypasses row-level security, so you must filter by user_id yourself here.
// app/api/v1/projects/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@supabase/supabase-js";
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
);
function hasScope(request: NextRequest, scope: string) {
const scopes = (request.headers.get("x-api-scopes") ?? "").split(" ").filter(Boolean);
return scopes.includes(scope);
}
export async function GET(request: NextRequest) {
if (!hasScope(request, "projects:read")) {
return NextResponse.json(
{ error: "insufficient_scope", required: "projects:read" },
{ status: 403 },
);
}
const userId = request.headers.get("x-api-user-id")!;
const { data, error } = await admin
.from("projects")
.select("id, name, created_at")
.eq("user_id", userId);
if (error) {
return NextResponse.json({ error: "server_error" }, { status: 500 });
}
return NextResponse.json({ data });
}A user can now call this from anywhere with a plain HTTP request, and the response only ever contains their own rows.
curl https://yourapp.com/api/v1/projects \
-H "Authorization: Bearer btn_live_your_key_here"The Key-Management Settings Page
Users need to create and revoke keys themselves. The page is a Server Component that lists the signed-in user's keys, and row-level security guarantees the list only ever contains their own. This reuses the Supabase server client from the Supabase Auth guide at utils/supabase/server.ts.
Server Actions do the writing. Creating a key generates it, inserts the hash, and returns the plaintext exactly once. Revoking stamps revoked_at, and RLS makes sure a user can only revoke their own key.
// app/settings/api-keys/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/utils/supabase/server";
import { generateApiKey, KEY_PREFIX } from "@/lib/api-keys";
export async function createApiKeyAction(formData: FormData) {
const name = String(formData.get("name") ?? "").trim() || "Untitled key";
const scopes = formData.getAll("scopes").map(String);
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Not authenticated");
const { token, keyHash, lastFour } = generateApiKey();
const { error } = await supabase.from("api_keys").insert({
user_id: user.id,
name,
key_hash: keyHash,
key_prefix: KEY_PREFIX,
last_four: lastFour,
scopes: scopes.length ? scopes : ["projects:read"],
});
if (error) throw new Error(error.message);
revalidatePath("/settings/api-keys");
return { token }; // surfaced to the client once, then gone
}
export async function revokeApiKeyAction(keyId: string) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("Not authenticated");
const { error } = await supabase
.from("api_keys")
.update({ revoked_at: new Date().toISOString() })
.eq("id", keyId);
if (error) throw new Error(error.message);
revalidatePath("/settings/api-keys");
}The page reads the keys and renders them. It never selects key_hash, since the client has no reason to see it.
// app/settings/api-keys/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { CreateKeyForm } from "./create-key-form";
import { RevokeButton } from "./revoke-button";
export default async function ApiKeysPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: keys } = await supabase
.from("api_keys")
.select("id, name, key_prefix, last_four, scopes, last_used_at, revoked_at")
.order("created_at", { ascending: false });
return (
<main className="mx-auto max-w-2xl py-12">
<h1 className="text-2xl font-bold">API keys</h1>
<p className="mt-1 text-sm text-muted-foreground">
Keys let your users call the API programmatically. Each key is shown once.
</p>
<CreateKeyForm />
<ul className="mt-8 divide-y rounded-lg border">
{(keys ?? []).map((key) => (
<li key={key.id} className="flex items-center justify-between p-4">
<div>
<p className="font-medium">{key.name}</p>
<p className="font-mono text-sm text-muted-foreground">
{key.key_prefix}...{key.last_four}
</p>
<p className="text-xs text-muted-foreground">
{key.revoked_at ? "Revoked" : `Scopes: ${key.scopes.join(", ") || "none"}`}
</p>
</div>
{!key.revoked_at && <RevokeButton keyId={key.id} />}
</li>
))}
</ul>
</main>
);
}The create form is a Client Component. It submits the action, then reveals the returned token in a one-time panel with a copy button. Once the user navigates away, that value is unrecoverable, which is the whole point.
// app/settings/api-keys/create-key-form.tsx
"use client";
import { useState } from "react";
import { createApiKeyAction } from "./actions";
export function CreateKeyForm() {
const [token, setToken] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function action(formData: FormData) {
setPending(true);
try {
const result = await createApiKeyAction(formData);
setToken(result.token);
} finally {
setPending(false);
}
}
return (
<div className="mt-6">
<form action={action} className="flex flex-wrap items-end gap-3">
<label className="flex flex-col text-sm">
Name
<input
name="name"
placeholder="Production server"
className="mt-1 rounded-md border px-3 py-2"
/>
</label>
<label className="flex flex-col text-sm">
Scopes
<select name="scopes" multiple className="mt-1 rounded-md border px-3 py-2">
<option value="projects:read">projects:read</option>
<option value="projects:write">projects:write</option>
</select>
</label>
<button
type="submit"
disabled={pending}
className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
>
{pending ? "Creating..." : "Create key"}
</button>
</form>
{token && (
<div className="mt-4 rounded-md border border-amber-300 bg-amber-50 p-4">
<p className="text-sm font-medium">Copy this key now. You won't see it again.</p>
<code className="mt-2 block break-all rounded bg-white p-2 font-mono text-sm">
{token}
</code>
<button
onClick={() => navigator.clipboard.writeText(token)}
className="mt-2 text-sm underline"
>
Copy to clipboard
</button>
</div>
)}
</div>
);
}The revoke button is a small Client Component that calls the action inside a transition so the row updates without a full reload.
// app/settings/api-keys/revoke-button.tsx
"use client";
import { useTransition } from "react";
import { revokeApiKeyAction } from "./actions";
export function RevokeButton({ keyId }: { keyId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => revokeApiKeyAction(keyId))}
disabled={pending}
className="rounded-md border px-3 py-1.5 text-sm text-red-600 disabled:opacity-50"
>
{pending ? "Revoking..." : "Revoke"}
</button>
);
}Quality Gates Before You Ship
Two checks catch the mistakes that matter here. Run the type checker to confirm the tagged AuthResult union is handled on every branch, and confirm a revoked key actually gets a 401.
npx tsc --noEmitThen a live test against the running app: create a key in the settings page, call the endpoint, revoke it, and call again. The first call returns your data, the second returns 401 revoked. Hammer it past the per-minute limit and you get 429 with a retry-after header. If all three behave, the auth path is sound.
Where the Code Kit Fits
You can build every piece above with Claude Code in a working session. What the $29 Code Kit adds ($29 one-time, no subscription) is the harness around it: a pipeline that plans the feature, builds it, evaluates the output against your rules, and runs the tests before anything is called done. For API keys that means the RLS policies get checked, the revoked-key and rate-limit cases get exercised, and every file exits the same quality gates (zero type errors, zero lint errors, a clean build). Claude Code itself runs on a paid Anthropic plan. The Code Kit is the plan-build-evaluate-test loop layered on top so security-sensitive work like this ships checked instead of hoped-for.
Posted by @speedy_devv
Stop configuring. Start building.
SaaS builder templates with AI orchestration.