Overview
Unkey stores hashed API keys for you and answers one question fast: "is this key valid, and what is it allowed to do?" You create keys inside an API (called a keyspace in the v2 docs), attach limits and permissions, and call verifyKey from your server on each request.
This skill targets the v2 API and @unkey/api 2.x (2.5.x at the time of writing). The old v1 SDK (verifyKey imported as a function, result.valid, ownerId, remaining, ratelimit: { type: "fast" }, keys.delete({keyId}) on a flat client) is gone: v2 responses are wrapped as { meta, data }, the owner is externalId, and rate limits are an array of named limits.
Two kinds of secret exist: a root key (from the dashboard, used by your backend to call Unkey; never ship it to a client) and the API keys you hand to your customers.
Instructions
- Install and configure the client:
npm install @unkey/api
export UNKEY_ROOT_KEY=... # create it in the dashboard with only the permissions you need
export UNKEY_API_ID=... # the api_... id of the API that will own the keys
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });
-
Create a key (
unkey.keys.createKey). Fields:apiId,prefix,name,externalId(your customer id),meta,roles,permissions,expires(Unix ms),credits({ remaining, refill? }),ratelimits(array of{ name, limit, duration (ms), autoApply }),enabled,recoverable,byteLength(default 16). The response is{ data: { key, keyId } };keyis shown once. -
Verify on every request (
unkey.keys.verifyKey). Sendkey, and optionallypermissions(a query such as"documents.read AND documents.write"),credits: { cost },ratelimits: [{ name, cost }],tags. Readdata.validanddata.code(VALID,NOT_FOUND,FORBIDDEN,INSUFFICIENT_PERMISSIONS,USAGE_EXCEEDED,RATE_LIMITED,DISABLED,EXPIRED). A rejected key is a normal 200 response withvalid: false, not an exception; network or auth failures throwUnkeyError. -
Manage the lifecycle:
keys.updateKey(passnullto clear a field),keys.updateCredits,keys.deleteKey(permanent: trueto hard delete),keys.rerollKey(new key plus a grace period for the old one),apis.listKeys(filter byexternalId; the result is an async iterable:for await (const page of result)),keys.addRoles/setRoles/addPermissions. -
Usage analytics:
analytics.getVerifications({ query })takes a read-only SQLSELECTover thekey_verifications_*_v1views; check the dashboard or docs for column names before writing queries.
Examples
"Give customer 42 a production key with 100 requests a minute and 10,000 total calls"
const created = await unkey.keys.createKey({
apiId: process.env.UNKEY_API_ID!,
prefix: "sk_live",
name: "Northwind Traders production key",
externalId: "customer-42",
meta: { plan: "pro", team: "engineering" },
permissions: ["api.read", "api.write"],
ratelimits: [{ name: "requests", limit: 100, duration: 60_000, autoApply: true }],
credits: { remaining: 10_000 },
expires: Date.now() + 30 * 24 * 60 * 60 * 1000, // optional: 30-day trial
});
console.log(created.data.key); // sk_live_... show it to the customer once
console.log(created.data.keyId); // key_... store this to manage the key later
Result: the customer gets one secret; you keep only keyId. After 10,000 verifications verifyKey returns USAGE_EXCEEDED.
"Protect my API route and return 401/403/429 correctly"
async function requireApiKey(req: Request): Promise<Response | { customerId?: string }> {
const key = req.headers.get("Authorization")?.replace(/^Bearer /, "");
if (!key) return new Response("Missing API key", { status: 401 });
const { data } = await unkey.keys.verifyKey({
key,
permissions: req.method === "GET" ? "api.read" : "api.write",
});
if (!data.valid) {
const status = data.code === "RATE_LIMITED" ? 429
: data.code === "NOT_FOUND" ? 401 : 403;
return Response.json({ error: data.code }, { status });
}
return { customerId: data.identity?.externalId };
}
Result: unknown keys get 401, over-limit keys 429, expired/disabled/under-privileged keys 403. Wrap the call in try/catch and decide whether to fail closed (recommended) when Unkey is unreachable.
"Rotate a leaked key without downtime"
const rerolled = await unkey.keys.rerollKey({
keyId: "key_2cGKbMxRyIzhCxo1Idjz8q",
expiration: 24 * 60 * 60 * 1000, // old key keeps working for 24 hours
});
console.log(rerolled.data.key); // new secret, returned once
Result: the new key carries the same settings; the old one stops working after the grace period (use 0 to cut it off at once for a real leak).
Guidelines
- Keep the root key server-side in an environment variable; give it only the permissions it needs, and use a separate root key per environment.
- Rate limits attached to a key apply on verify only when
autoApply: true; otherwise name them inverifyKey({ ratelimits: [{ name }] }). metais returned on every verification: no secrets, keep it small (docs advise under 10 KB).- Prefixes appear in logs and errors; use
sk_live/sk_test, nothing sensitive. - The plaintext key is returned once and only a hash is stored (unless
recoverable: true, which stores it encrypted: use sparingly). - Do not cache a
valid: trueresult for long; revocation and credits are checked by Unkey on each verify. - Self-hosting is possible from the open-source repository, but the hosted service is the default path; check the current self-hosting docs before committing to it.