Every public API eventually needs the same plumbing: a way to issue keys to customers, a way to verify those keys on each request, per-key rate limits to stop abuse, usage quotas tied to billing plans, and fine-grained permissions. Building all of that yourself means a keys table, hashing logic, a Redis cluster for counters, an analytics pipeline, and a dashboard. It is weeks of undifferentiated work.
Unkey is an open-source API key management and rate-limiting platform that collapses all of that into a few SDK calls. You create a key, you verify a key, and Unkey handles hashing, global low-latency verification, rate limiting, usage-based credits, roles, permissions, and analytics. It is fully self-hostable, which matters for teams in regulated MENA markets operating under INPDP and PDPL data-residency rules.
In this tutorial you will build a document-processing API for a fictional SaaS where each customer authenticates with an API key. You will issue keys programmatically, protect routes with the Next.js middleware helper, enforce per-key rate limits and credit quotas, and gate premium endpoints behind permissions.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed
- A free Unkey account — sign up at app.unkey.com
- Basic knowledge of React and TypeScript
- Familiarity with the Next.js App Router (Route Handlers, middleware)
- A code editor (VS Code recommended)
What You'll Build
A SaaS document API with:
- Programmatic key issuance — generate a scoped API key when a customer signs up
- Request verification — validate every incoming key with one call
- Per-key rate limiting — throttle each customer independently
- Usage credits — meter API calls against a plan quota with automatic refills
- Role-based permissions — restrict premium endpoints to keys that carry the right permission
- Standalone rate limiting — protect unauthenticated routes by IP
How Unkey Works
Three concepts carry the whole system:
- Root key — a privileged key that your backend uses to call the Unkey management API (create, verify, revoke). It never leaves your server and lives in an environment variable.
- API — a namespace in Unkey that groups all the keys you issue. Each API has an
apiId. - Customer keys — the keys you hand to your users. Unkey stores only a hash; the plaintext is shown exactly once at creation.
The golden rule: verification always returns HTTP 200. A rejected key is not a transport error — you must inspect the valid field (and the code) in the response body to decide whether to allow the request.
Step 1: Create the Next.js Project
Initialize a new Next.js project with TypeScript and the App Router:
npx create-next-app@latest unkey-docs-api --typescript --app --src-dir --eslint
cd unkey-docs-apiInstall the Unkey SDKs:
npm install @unkey/api @unkey/nextjs @unkey/ratelimit@unkey/api— the management SDK for creating and verifying keys from your backend@unkey/nextjs— a thin wrapper that verifies keys inside Route Handlers@unkey/ratelimit— standalone rate limiting for routes that have no API key (login, signup, public endpoints)
Step 2: Set Up Your Unkey Workspace
In the Unkey dashboard:
- Create a new API (call it
documents-api). Copy its API ID — it looks likeapi_3xZ.... - Go to Settings → Root Keys and create a root key with at least these permissions:
api.*.create_key,api.*.verify_key, andratelimit.*.limit. Copy it — it is shown only once.
Create a .env.local file:
UNKEY_ROOT_KEY=unkey_3y...
UNKEY_API_ID=api_3xZ...Never commit this file. The root key is as sensitive as a database password — anyone holding it can mint keys against your API.
Step 3: Issue an API Key When a Customer Signs Up
When a new customer joins, your backend asks Unkey to mint a key scoped to their account. Create a server-only helper at src/lib/unkey.ts:
import { Unkey } from "@unkey/api";
// One shared instance, reused across the app.
export const unkey = new Unkey({
rootKey: process.env.UNKEY_ROOT_KEY!,
});
interface IssueKeyInput {
userId: string;
plan: "free" | "pro" | "enterprise";
}
// Map a billing plan to a monthly request quota.
const PLAN_CREDITS = {
free: 1_000,
pro: 50_000,
enterprise: 1_000_000,
} as const;
export async function issueApiKey({ userId, plan }: IssueKeyInput) {
const result = await unkey.keys.createKey({
apiId: process.env.UNKEY_API_ID!,
prefix: "docs", // keys look like docs_xxxxxxxx — easy to recognize
name: `${plan} key for ${userId}`,
externalId: userId, // ties the key back to your own user record
meta: { plan },
// Per-plan rate limit, auto-applied on every verify call.
ratelimits: [
{
name: "requests",
limit: plan === "enterprise" ? 100 : plan === "pro" ? 50 : 10,
duration: 10_000, // per 10 seconds
autoApply: true,
},
],
// Monthly usage quota that refills on the 1st of each month.
credits: {
remaining: PLAN_CREDITS[plan],
refill: {
interval: "monthly",
amount: PLAN_CREDITS[plan],
refillDay: 1,
},
},
// Premium customers also get the "documents.export" permission.
permissions:
plan === "enterprise"
? ["documents.read", "documents.write", "documents.export"]
: ["documents.read", "documents.write"],
});
// The plaintext key is returned ONCE. Return it to the caller now —
// you will never be able to read it again.
return result;
}A few details worth understanding:
externalIdlinks the Unkey key to your ownuserId. Later you can list or revoke every key belonging to a user without storing the key yourself.autoApply: trueon a rate limit means Unkey enforces it on everyverifyKeycall automatically — you do not have to pass the limit again at verification time.creditsturn each verification into a metered unit. Whenremaininghits zero the key stops validating until the next refill.
Now expose this through a Route Handler. Create src/app/api/keys/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { issueApiKey } from "@/lib/unkey";
export async function POST(req: NextRequest) {
// In a real app, authenticate the dashboard session here first.
const { userId, plan } = await req.json();
if (!userId || !plan) {
return NextResponse.json(
{ error: "userId and plan are required" },
{ status: 400 },
);
}
const { result, error } = await issueApiKey({ userId, plan });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// result.key is the plaintext — surface it to the customer exactly once.
return NextResponse.json({ key: result.key, keyId: result.keyId });
}The SDK returns a { result, error } shape, so you never have to wrap calls in try/catch for expected API errors — you branch on error instead.
Step 4: Protect a Route with withUnkey
The simplest way to guard a Route Handler is the withUnkey wrapper from @unkey/nextjs. It reads the key from the Authorization: Bearer header, verifies it, and attaches the result to req.unkey.
Create src/app/api/documents/route.ts:
import { withUnkey } from "@unkey/nextjs";
import { NextResponse } from "next/server";
export const POST = withUnkey(
async (req) => {
// withUnkey only runs your handler when the key is structurally present.
// You still must check validity yourself:
if (!req.unkey?.valid) {
return NextResponse.json(
{ error: "Unauthorized", code: req.unkey?.code },
{ status: 401 },
);
}
// req.unkey carries everything you configured: ownerId, meta, permissions.
const plan = (req.unkey.meta?.plan as string) ?? "free";
return NextResponse.json({
message: "Document accepted for processing",
plan,
remaining: req.unkey.remaining, // credits left after this call
});
},
{
rootKey: process.env.UNKEY_ROOT_KEY!,
},
);Test it. First mint a key:
curl -X POST http://localhost:3000/api/keys \
-H "Content-Type: application/json" \
-d '{"userId":"user_42","plan":"pro"}'
# { "key": "docs_3a8...", "keyId": "key_..." }Then call the protected route with that key:
curl -X POST http://localhost:3000/api/documents \
-H "Authorization: Bearer docs_3a8..." \
-H "Content-Type: application/json" \
-d '{"title":"Q3 report"}'
# { "message": "Document accepted for processing", "plan": "pro", "remaining": 49999 }Call it without a key — or with garbage — and you get a 401. Notice remaining decremented by one: the credit quota is enforced automatically.
Step 5: Verify Keys Manually for Full Control
withUnkey is convenient, but real APIs often need to verify inside their own logic — for example to check a specific permission, charge a variable credit cost, or apply a named rate limit. Use unkey.keys.verifyKey directly.
Create a reusable guard at src/lib/auth.ts:
import { unkey } from "./unkey";
interface VerifyOptions {
permission?: string; // e.g. "documents.export"
cost?: number; // how many credits this request consumes
}
export async function verifyRequest(
authHeader: string | null,
opts: VerifyOptions = {},
) {
const key = authHeader?.replace(/^Bearer\s+/i, "");
if (!key) {
return { ok: false as const, status: 401, reason: "missing_key" };
}
const { result, error } = await unkey.keys.verifyKey({
key,
// A permission query: the key must carry this permission to pass.
permissions: opts.permission,
// Charge more than one credit for heavy operations.
credits: opts.cost ? { cost: opts.cost } : undefined,
});
if (error) {
// A transport/management error (not a rejected key) — fail closed.
return { ok: false as const, status: 500, reason: error.message };
}
if (!result.valid) {
// result.code tells you WHY: NOT_FOUND, RATE_LIMITED,
// USAGE_EXCEEDED, INSUFFICIENT_PERMISSIONS, EXPIRED, DISABLED...
const status = result.code === "RATE_LIMITED" ? 429 : 403;
return { ok: false as const, status, reason: result.code };
}
return { ok: true as const, key: result };
}Now build a premium endpoint that requires the documents.export permission and costs 5 credits per call. Create src/app/api/documents/export/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { verifyRequest } from "@/lib/auth";
export async function POST(req: NextRequest) {
const auth = await verifyRequest(req.headers.get("authorization"), {
permission: "documents.export",
cost: 5, // exporting is expensive — meter it heavier
});
if (!auth.ok) {
return NextResponse.json(
{ error: auth.reason },
{ status: auth.status },
);
}
// Only enterprise keys carry documents.export, so free/pro keys get a 403.
return NextResponse.json({
message: "Export started",
remaining: auth.key.remaining,
});
}A pro key calling this endpoint receives 403 INSUFFICIENT_PERMISSIONS, while an enterprise key succeeds and burns 5 credits. The same verification call enforced authentication, authorization, and quota in one round trip.
Step 6: Rate-Limit Unauthenticated Routes by IP
Some routes have no API key yet — a public signup form, a contact endpoint, a docs search box. Use the standalone @unkey/ratelimit SDK keyed on the client IP.
Create src/lib/ratelimit.ts:
import { Ratelimit } from "@unkey/ratelimit";
export const publicLimiter = new Ratelimit({
rootKey: process.env.UNKEY_ROOT_KEY!,
namespace: "public-signup", // isolate this limit from other namespaces
limit: 5, // 5 attempts...
duration: "60s", // ...per minute per identifier
async: true, // fire-and-forget for lowest latency
});Apply it in a public Route Handler at src/app/api/signup/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { publicLimiter } from "@/lib/ratelimit";
export async function POST(req: NextRequest) {
// Behind a proxy, prefer the leftmost x-forwarded-for entry.
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const { success, remaining, reset } = await publicLimiter.limit(ip);
if (!success) {
return NextResponse.json(
{ error: "Too many signup attempts. Try again shortly." },
{
status: 429,
headers: {
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
},
);
}
// ...proceed with account creation
return NextResponse.json({ ok: true });
}The async: true flag makes the limiter return optimistically while syncing counts in the background. It trims latency at the cost of letting a tiny burst slip through at the edges — a good trade for signup forms, the wrong one for a payment endpoint where you should set async: false.
Step 7: Revoke and List Keys
When a customer downgrades, churns, or leaks a key, you revoke it. Because you set externalId to your userId, you can also enumerate every key a user owns.
import { unkey } from "@/lib/unkey";
// Immediately invalidate a single key by its keyId.
export async function revokeKey(keyId: string) {
return unkey.keys.deleteKey({ keyId });
}
// Temporarily disable without deleting (e.g. on payment failure).
export async function suspendKey(keyId: string) {
return unkey.keys.updateKey({ keyId, enabled: false });
}A revoked or disabled key fails its very next verification with code NOT_FOUND or DISABLED — there is no propagation delay to manage on your side.
Testing Your Implementation
Walk through the full lifecycle from a terminal:
# 1. Issue a free-plan key
curl -s -X POST http://localhost:3000/api/keys \
-H "Content-Type: application/json" \
-d '{"userId":"user_99","plan":"free"}'
# 2. Hammer the documents endpoint past the 10-per-10s limit
for i in $(seq 1 15); do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/documents \
-H "Authorization: Bearer docs_YOURKEY" \
-H "Content-Type: application/json" -d '{}'
done
# You should see 200s flip to 429s once the window fills.
# 3. Try the export endpoint with a free key -> 403
curl -s -X POST http://localhost:3000/api/documents/export \
-H "Authorization: Bearer docs_YOURKEY"For automated coverage, verify the three decisive branches: a valid key returns 200, a rate-limited key returns 429 with code: "RATE_LIMITED", and a free key hitting /export returns 403 with code: "INSUFFICIENT_PERMISSIONS".
Troubleshooting
Every verification returns valid: false with NOT_FOUND. Your root key probably lacks the verify_key permission, or you are verifying against the wrong API. Confirm the root key's scopes in the dashboard and that the key was created under the same apiId.
Rate limits never trigger. Check that the rate limit on the key has autoApply: true, or that you pass a ratelimits array at verify time. A limit defined on the key but not auto-applied is only enforced when you reference it by name during verification.
Credits never decrement. verifyKey only consumes credits when the key was created with a credits object. Keys minted without one are unlimited by design.
Latency spikes on protected routes. Verification is a network call. Keep the Unkey client as a module-level singleton (as in Step 3) so connections are reused, and prefer deploying near Unkey's edge. For non-critical limits, async: true removes the round trip from the hot path.
Production Checklist
- Never expose the root key to the browser. All Unkey management calls belong in Route Handlers or server actions, never in client components.
- Fail closed. If
verifyKeyreturns a transporterror, reject the request rather than letting it through. - Show the plaintext key once. Render it in the dashboard at creation, then store only the
keyIdyour side. - Pick
asyncdeliberately. Useasync: falsewhere correctness beats latency (billing, abuse-sensitive writes). - Self-host for data residency. Teams under INPDP or PDPL can run Unkey on their own infrastructure so key material and usage analytics never leave the region.
Next Steps
- Add roles alongside permissions to manage access in bundles instead of one permission at a time.
- Surface Unkey's analytics in your customer dashboard to show per-key usage trends.
- Combine this with a self-host deployment using the Coolify v4 self-hosted deployment workflow.
- Pair API keys with edge caching — see the Upstash Redis rate limiting and caching tutorial for a complementary approach.
Conclusion
You built a complete API authentication layer without writing a single line of key-hashing or counter-management code. Unkey gave you programmatic key issuance, one-call verification, per-key rate limits, usage credits tied to billing plans, and permission gating — all from three SDKs. The architecture scales from a free side project to an enterprise SaaS, and because Unkey is open source and self-hostable, it fits cleanly into the data-residency requirements that MENA teams operate under. The next time you spin up a public API, reach for a managed key layer instead of rebuilding one from scratch.