Let your customers create API keys.
Your customers want to drive your API from CI and from their end-to-end tests. Tenant API keys let the members of each tenant create keys for exactly that, while Auth stores and verifies them. This chapter adopts them in five steps.
Read Tenant API keys first for the model: the key format, the scope ceiling, expiry, rotate vs revoke, and what happens when a creator leaves. The examples below use a disputes product with scopes like disputes.read; swap in your own.
Before you start
What you need
- An Auth app that uses tenants. Keys belong to a tenant, and only a member signed into that tenant can create one.
- A Platform API key (
pcft_live_…) for your app's backend, from the workspace that owns the app. - Your own map from a tenant role to the permissions it holds in your app. You will intersect every key's scopes with it.
Every call below goes to https://api.auth.productcraft.co. All of the endpoints are in the API reference under Consumer · Tenant API keys.
1
Grant your Platform API key the two actions
Your backend's key needs auth.tenant.api_keys.introspect (verify keys, read a key's status, proxy management) and auth.tenant.api_keys.configure (push the settings), on this app.
{
"effect": "allow",
"actions": ["auth.tenant.api_keys.introspect", "auth.tenant.api_keys.configure"],
"resources": ["pcft:heimdall:app/<app-uuid>"]
}In the console these show as Verify tenant API keys and Configure tenant API keys. See Workspace API keys for CI/CD for creating a policy and binding it to a key.
This step is the one people miss. A policy that lists actions without a wildcard does not cover the new ones, and every introspection then fails with 403. The key must also belong to the workspace that owns the app, or the answer is 403 PAK_WORKSPACE_MISMATCH.
2
Enable keys and push your settings on boot
Your code knows which permissions a key may carry, so your app pushes the settings itself every time it starts.
curl -X PUT https://api.auth.productcraft.co/acme/v1/tenant-api-keys/config \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-H 'idempotency-key: config-2026-10-23' \
-d '{
"enabled": true,
"grantable_scopes": [
{ "name": "disputes.read", "description": "Read disputes" },
{ "name": "reports.create", "description": "Create reports" },
{ "name": "audit.read", "description": "Read the activity log" }
],
"manage_roles": ["owner"],
"management_lane": "app_proxy_only",
"default_expiry_days": 90,
"max_expiry_days": 365
}'The response is the full stored configuration. Fields you leave out keep their stored value; grantable_scopes replaces the whole list.
grantable_scopes: what a key may carry, at most 64, each<resource>.<verb>. Leave out anything a script should never do (managing keys, the team, billing). Enabling with no scopes is refused (TENANT_API_KEYS_NO_SCOPES).manage_roles: the tenant roles allowed to create, rotate and revoke keys. Default["owner"]. A role your app does not have is refused (TENANT_API_KEYS_UNKNOWN_ROLE).management_lane:direct(the default) lets a signed-in user call the management endpoints with their own token.app_proxy_onlyaccepts the user's token only together with your PAK, so every create, rotate and revoke passes through your backend (step 4). Chooseapp_proxy_onlyif you keep your own audit trail.default_expiry_days/max_expiry_days: from 7, 30, 90 and 365.require_recent_mfa_minutes: optional. When set, creating or rotating a key needs a second factor verified within that many minutes, else403 step_up_required. Leave itnullif your app has no step-up screen yet.
Once your app has pushed its settings, the console shows them read-only (your code owns them). The Suspend all tenant API keys switch stays in the console; a push that contains suspended is refused.
3
Verify keys in your API
A request arrives with Authorization: Bearer pctk_live_…. Check the shape, look in your cache, and ask Auth only when you must.
curl https://api.auth.productcraft.co/acme/v1/tenant-api-keys/introspect \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "key": "pctk_live_4fQ2aZ..." }'
# Active
{
"active": true,
"key_id": "8b1c…", "display_id": "tak_3a9f2b1c0d4e",
"tenant_id": "5e2a…", "tenant_display_id": "tnt_3a9f2b1c0d4e",
"name": "CI e2e",
"scopes": ["disputes.read", "audit.read"],
"created_by": { "account_id": "c41d…", "tenant_role": "agent_full" },
"expires_at": "2027-01-21T10:00:00.000Z",
"env": "live"
}
# Anything else: unknown, malformed, revoked, expired, creator gone,
# tenant not active, keys suspended, or a key of another app
{ "active": false }Introspection puts Auth on your request path, so the verifier has to be careful. These are the rules, and the code below follows all of them:
- Typo filter first. Check the pattern and the CRC32 checksum, and refuse a bad key without a network call. A valid checksum proves nothing; anyone can make one.
- Cache by the key's SHA-256, never by the raw key. Positive answers for at most 30 seconds and never past
expires_at; negative answers for up to 60 seconds. Your positive TTL is your revocation latency. - Single-flight. Fifty parallel CI workers with one key make one introspection call, not fifty.
- Cap calls in flight and keep a failed-verify budget. If someone sprays random well-formed keys at you, stop calling Auth for unknown keys once the budget is spent, while cached active keys keep working.
- Fail closed, for uncached keys only. A timeout, a network error, a
5xxor a429is not a verdict. Refuse that request (503withRetry-Aftertells the client to retry) and cache nothing. Never turn it into a cached{"active": false}: that would break a customer's valid key for a minute. - Intersect scopes with the creator's current role.
created_by.tenant_roleis read live; the key can do only what is both inscopesand allowed to that role in your app.
Introspection has its own budget of 1,200 calls a minute for your app, separate from your PAK's other calls. With a 30-second cache, that is far more than real traffic needs; it is there so a flood cannot take your sign-ins down with it.
// tenant-api-keys.ts: verify a tenant API key presented to YOUR API.
import { createHash } from 'node:crypto';
const AUTH_URL = 'https://api.auth.productcraft.co';
const APP_SLUG = process.env.AUTH_APP_SLUG!;
const PAK = process.env.PRODUCTCRAFT_PAK!; // pcft_live_…, holds auth.tenant.api_keys.introspect
const POSITIVE_TTL_MS = 30_000; // never more than 30 s, never past expires_at
const NEGATIVE_TTL_MS = 60_000;
const MAX_IN_FLIGHT = 8; // introspect calls in flight per process
const FAILED_VERIFY_BUDGET = 60; // unknown keys per minute per process
const TIMEOUT_MS = 2_000;
export type Verdict =
| { active: false }
| {
active: true;
key_id: string;
display_id: string;
tenant_id: string; // the same tenant id your end-user tokens carry as org_id
tenant_display_id: string;
name: string;
scopes: string[];
created_by: { account_id: string; tenant_role: string };
expires_at: string;
env: 'live' | 'test';
};
/** Auth could not give a verdict. Fail closed: refuse the request, cache nothing. */
export class VerifyUnavailable extends Error {}
// ---- 1. Typo filter (shape + CRC32 checksum). Not a security control. ----
const KEY_PATTERN = /^pctk_(live|test)_[0-9A-Za-z]{43}_[0-9A-Za-z]{6}$/;
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(s: string): number {
let crc = 0xffffffff;
for (let i = 0; i < s.length; i++) crc = CRC_TABLE[(crc ^ s.charCodeAt(i)) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function base62(n: number, width: number): string {
let out = '';
while (n > 0) {
out = BASE62[n % 62] + out;
n = Math.floor(n / 62);
}
return out.padStart(width, '0');
}
export function isWellFormedTenantKey(key: string): boolean {
if (!KEY_PATTERN.test(key)) return false;
return base62(crc32(key.slice(10, 53)), 6) === key.slice(-6);
}
// ---- 2. Cache, single-flight, in-flight cap, failed-verify budget. ----
const cache = new Map<string, { verdict: Verdict; until: number }>();
const inFlight = new Map<string, Promise<Verdict>>();
let budgetWindow = Date.now();
let failedInWindow = 0;
export async function verifyTenantKey(key: string): Promise<Verdict> {
if (!isWellFormedTenantKey(key)) return { active: false };
const hash = createHash('sha256').update(key).digest('hex'); // never cache by the raw key
const now = Date.now();
const hit = cache.get(hash);
if (hit && hit.until > now) return hit.verdict;
cache.delete(hash);
const pending = inFlight.get(hash);
if (pending) return pending; // concurrent requests with one key share one call
if (now - budgetWindow >= 60_000) {
budgetWindow = now;
failedInWindow = 0;
}
// Budget spent (someone is spraying random keys): refuse unknown keys
// without calling Auth. Keys already cached as active keep working.
if (failedInWindow >= FAILED_VERIFY_BUDGET) return { active: false };
if (inFlight.size >= MAX_IN_FLIGHT) throw new VerifyUnavailable('verify capacity');
const call = introspect(key)
.then((verdict) => {
const at = Date.now();
if (verdict.active) {
const until = Math.min(at + POSITIVE_TTL_MS, Date.parse(verdict.expires_at));
cache.set(hash, { verdict, until });
} else {
failedInWindow += 1;
cache.set(hash, { verdict, until: at + NEGATIVE_TTL_MS });
}
return verdict;
})
.finally(() => inFlight.delete(hash));
inFlight.set(hash, call);
return call;
}
async function introspect(key: string): Promise<Verdict> {
let res: Response;
try {
res = await fetch(AUTH_URL + '/' + APP_SLUG + '/v1/tenant-api-keys/introspect', {
method: 'POST',
headers: { authorization: 'Bearer ' + PAK, 'content-type': 'application/json' },
body: JSON.stringify({ key }), // the key goes in the body, never in a header
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch {
throw new VerifyUnavailable('introspect unreachable');
}
if (res.status === 200) return (await res.json()) as Verdict;
// 429 (your app's introspect bucket), 5xx, or 401/403 (a PAK problem) are
// not verdicts. Never cache them as {active:false}.
throw new VerifyUnavailable('introspect answered ' + res.status);
}The cache and the budget live in each process, so with several replicas each one keeps its own; the numbers above are per replica. Revoking a key takes effect in Auth at once and in your API within your positive TTL.
4
Let your customers manage keys through your backend
With management_lane: app_proxy_only, your API exposes its own api-keys routes and forwards each call to Auth with the signed-in user's token plus your PAK.
Auth still decides who may manage keys: the user must be signed into the tenant, their tenant role must be in manage_roles, and every scope must be within their own role. Your backend adds the PAK in X-Heimdall-App-Key (it needs auth.tenant.api_keys.introspect) and records the change in your own audit trail.
# Create (201; "key" is the secret, returned once)
curl https://api.auth.productcraft.co/acme/v1/tenant-api-keys \
-H 'authorization: Bearer <end-user access token>' \
-H 'x-heimdall-app-key: pcft_live_...' \
-H 'idempotency-key: 6f1c2a8e-…' \
-H 'content-type: application/json' \
-d '{ "name": "CI e2e", "scopes": ["disputes.read", "audit.read"], "expires_in_days": 30 }'
# List (newest first; ?status=active|expired|revoked, ?limit=, ?cursor=)
curl 'https://api.auth.productcraft.co/acme/v1/tenant-api-keys?status=active' \
-H 'authorization: Bearer <end-user access token>' \
-H 'x-heimdall-app-key: pcft_live_...'
# Rotate (201; new secret once; old key revoked now, or after grace_hours ≤ 24)
curl -X POST https://api.auth.productcraft.co/acme/v1/tenant-api-keys/tak_3a9f2b1c0d4e/rotate \
-H 'authorization: Bearer <end-user access token>' \
-H 'x-heimdall-app-key: pcft_live_...' \
-H 'content-type: application/json' \
-d '{ "grace_hours": 1 }'
# Revoke (204, immediate, idempotent)
curl -X DELETE https://api.auth.productcraft.co/acme/v1/tenant-api-keys/tak_3a9f2b1c0d4e \
-H 'authorization: Bearer <end-user access token>' \
-H 'x-heimdall-app-key: pcft_live_...'- The secret is never replayed. Retrying a create or rotate with the same
Idempotency-Keyreturns409 idempotency_secret_not_replayablewithdetails.key_id, not the secret again. If your user lost the response, revoke that key and create a new one. - Pass
Cache-Control: no-storethrough, and never log the request or response body of these calls. - Errors your UI should explain:
403TENANT_API_KEYS_FORBIDDEN(role not inmanage_roles),TENANT_API_KEY_SCOPE_ABOVE_CEILING(details.scopes),TENANT_CONTEXT_REQUIRED(the user has not switched into a tenant),step_up_required;409 TENANT_API_KEY_LIMIT(25 active keys).
Keys can also die without passing through your backend: they expire, their creator leaves the tenant, or you revoke one in the console. When your verifier sees a key go inactive, ask why and record it:
curl https://api.auth.productcraft.co/acme/v1/tenant-api-keys/tak_3a9f2b1c0d4e/status \
-H 'authorization: Bearer pcft_live_...'
{ "status": "revoked", "revoke_reason": "member_removed" }
# status: active | expired | revoked | orphaned | suspended
# revoke_reason: manual | rotated | member_removed | tenant_deleted | null5
Accept keys only where you mean to
Default deny: a tenant API key works on a route only if that route says so.
Mark each route a key may call with the scope it needs, and make your normal authentication refuse any pctk_ bearer everywhere else. A key should never reach the routes that manage keys, members or billing, whatever its scopes say.
// Accept tenant API keys only on routes that opt in (Express shown).
import type { NextFunction, Request, Response } from 'express';
import { verifyTenantKey, VerifyUnavailable } from './tenant-api-keys';
import { permissionsOfTenantRole } from './permissions'; // YOUR role → permission grid
export function acceptsTenantKey(scope: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const header = req.headers.authorization ?? '';
const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
if (!bearer.startsWith('pctk_')) return next(); // not a key: your normal auth runs
let verdict;
try {
verdict = await verifyTenantKey(bearer);
} catch (err) {
if (err instanceof VerifyUnavailable) {
res.setHeader('Retry-After', '5');
return res.status(503).json({ code: 'AUTH_UNAVAILABLE' });
}
throw err;
}
if (!verdict.active) return res.status(401).json({ code: 'INVALID_API_KEY' });
// The ceiling, again: the key's scopes ∩ what its creator's CURRENT role allows.
const allowed = permissionsOfTenantRole(verdict.created_by.tenant_role);
const effective = verdict.scopes.filter((s) => allowed.has(s));
if (!effective.includes(scope)) {
return res.status(403).json({ code: 'SCOPE_MISSING', scope });
}
req.principal = {
type: 'api_key',
keyId: verdict.key_id,
tenantId: verdict.tenant_id, // map to your tenant exactly like org_id
scopes: effective,
};
next();
};
}
// requireUser is your existing JWT middleware. It lets a request through when
// req.principal is already set, and REFUSES any other pctk_ bearer:
// if (bearer.startsWith('pctk_') && !req.principal) return res.status(401)…
app.get('/v1/disputes', acceptsTenantKey('disputes.read'), requireUser, listDisputes);
app.post('/v1/reports', acceptsTenantKey('reports.create'), requireUser, createReport);
// No acceptsTenantKey on /v1/api-keys, /v1/team, billing…: keys cannot reach them.Checklist
Before you ship
- Introspection with a fresh key of your own test tenant returns
active: true; a typo'd key is refused without a call to Auth. - A key revoked in your UI stops working within your positive TTL, and your audit trail shows who revoked it.
- Removing the creator from the tenant kills the key, and the status endpoint says
member_removed. - A key calling a route you did not mark gets
401, and a key without the route's scope gets403. - With Auth unreachable, cached keys keep working until their TTL, uncached keys get
503, and nothing is cached as revoked. - Tell your customers: keys expire, a key dies when its creator leaves the team, and a leaked key should be revoked, not rotated.