Every SaaS eventually grows a customer who wants to "just call the API from a cron job." Harmless, until sk_live_... lands in Slack, GitHub, and a support screenshot before lunch.
A customer-facing API key is not an env var you stuffed into Vault. It is a product credential: issued in your UI, shown once, hashed at rest, scoped, rotated, and revoked without taking the tenant offline.
This is a different job from Secrets Management in Modern Infrastructure Using Vault or SSM, which protects secrets your services consume. Humans still go through Implementing Role-Based Access Control (RBAC) in Next.js App Router. Keys inherit tenant rules from Designing Multi-Tenant SaaS Isolation: Data, Controls, and Cost Guardrails, write evidence via Designing Audit Logs for SaaS: Evidence, Security, and Product Trust, and become the actor for Rate Limiting and Throttling Strategies for Production APIs: Token Buckets, Sliding Windows, and Distributed Guardrails.
TL;DR
- Store a hash plus a public prefix. Never persist the full plaintext key.
- Show the secret once. After that, the dashboard can rename, scope, or revoke.
- Authenticate with prefix lookup and a constant-time hash compare. Do not scan the table.
- Keep scopes as a small machine allowlist. Human RBAC stays the source of truth for people.
- Rotate with a dual-accept window so integrations can swap keys without a midnight outage.
- Log create, rotate, revoke, and leak response. Do not log the secret.
What is a customer API key vs an infra secret?
Short answer: an infra secret unlocks your systems. A customer API key is a machine identity you issue, scope, and revoke inside the product.
Vault and SSM still hold database passwords, signing peppers, and vendor tokens your app needs to boot. That model lives in Secrets Management in Modern Infrastructure Using Vault or SSM. A customer key is closer to a user than to a .env line: a human creates it, a script sends it on every request, and one string must identify tenant, actor, and permissions.
If you store issued keys the way you store STRIPE_SECRET_KEY, the model is upside down. Stripe hashes your key. You should hash theirs.
Customer dashboard
|
| Create key (show plaintext once)
v
api_keys row: prefix + hash + scopes + tenant_id
|
| Authorization: Bearer sk_live_...
v
Prefix lookup -> hash compare -> attach keyId, tenantId, scopes
|
+--> Rate limit by keyId
+--> Enforce scopes, then tenant RLS
+--> Audit: key.created / key.revokedHow should you store API keys so a database leak is not a full breach?
Short answer: persist a unique public prefix and an HMAC hash, keep a server-side pepper in your secret store, and return plaintext only in the create response.
Do not bcrypt API keys. Bcrypt is for passwords guessed slowly at login. Keys arrive on every request, so a 100ms hash is a self-imposed outage. HMAC-SHA256 with a pepper is the boring choice: fast on the hot path, useless if someone only stole Postgres.
The pepper belongs in Vault or SSM, not next to the hashes. That split is why Secrets Management in Modern Infrastructure Using Vault or SSM exists: a database dump should not be a skeleton key.
CREATE TABLE api_keys (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
name text NOT NULL,
prefix text NOT NULL UNIQUE,
key_hash text NOT NULL,
scopes text[] NOT NULL,
created_by uuid NOT NULL,
expires_at timestamptz,
revoked_at timestamptz,
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX api_keys_tenant_active_idx
ON api_keys (tenant_id)
WHERE revoked_at IS NULL;tenant_id is not decoration. It is the same isolation rule as Designing Multi-Tenant SaaS Isolation: Data, Controls, and Cost Guardrails and the same predicate you want under PostgreSQL Row Level Security for Multi-Tenant SaaS: A Practical Production Guide. A key from tenant A must never resolve to tenant B, even if a query forgets a WHERE.
import { createHmac, randomBytes, randomUUID } from "node:crypto";
const KEY_PREFIX = "sk_live_";
const LOOKUP_CHARS = 12;
export const hashApiKey = (plaintext: string): string => {
const pepper = process.env.API_KEY_PEPPER;
if (!pepper) throw new Error("API_KEY_PEPPER is not configured");
return createHmac("sha256", pepper).update(plaintext).digest("hex");
};
export const issueApiKey = () => {
const secret = randomBytes(32).toString("base64url");
const plaintext = `${KEY_PREFIX}${secret}`;
return {
id: randomUUID(),
prefix: plaintext.slice(0, KEY_PREFIX.length + LOOKUP_CHARS),
plaintext,
};
};Persist id, prefix, hashApiKey(plaintext), scopes, and tenant. Return plaintext once. If the UI refreshes, that string is gone. That is a feature. "We will hash them later" is how plaintext keys survive into the SOC2 screenshot era.
The prefix format is a public contract. Renaming sk_live_ next quarter breaks every stored credential. Treat it like Best Practices for API Versioning and Backward Compatibility.
Create-key also gets double-submitted. Make that POST idempotent with Architecting Bulletproof Idempotent APIs in Node.js so one mashed button does not mint two live secrets.
How do you authenticate a Bearer key without scanning the whole table?
Short answer: parse the Bearer token, look up the unique prefix, reject revoked or expired rows, then compare hashes with timingSafeEqual.
No prefix means "load every hash and compare." Cute for twelve rows. Fatal at twelve thousand tenants.
import { timingSafeEqual } from "node:crypto";
type ApiKeyRow = {
id: string;
tenant_id: string;
key_hash: string;
scopes: string[];
expires_at: Date | null;
revoked_at: Date | null;
};
export const authenticateApiKey = async (input: {
authorizationHeader: string | undefined;
lookupByPrefix: (prefix: string) => Promise<ApiKeyRow | undefined>;
}) => {
const header = input.authorizationHeader;
if (!header?.startsWith("Bearer ")) return null;
const plaintext = header.slice(7).trim();
if (!plaintext.startsWith(KEY_PREFIX) || plaintext.length < KEY_PREFIX.length + LOOKUP_CHARS) {
return null;
}
const row = await input.lookupByPrefix(plaintext.slice(0, KEY_PREFIX.length + LOOKUP_CHARS));
if (!row || row.revoked_at || (row.expires_at && row.expires_at.getTime() < Date.now())) {
return null;
}
const expected = Buffer.from(row.key_hash, "hex");
const actual = Buffer.from(hashApiKey(plaintext), "hex");
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return null;
}
return { keyId: row.id, tenantId: row.tenant_id, scopes: row.scopes };
};Three details people skip:
- Tenant comes from the key row, never from a
tenantIdquery param. Same fail-closed habit as PostgreSQL Row Level Security for Multi-Tenant SaaS: A Practical Production Guide. - Put
keyIdon the limiter. Hierarchical quotas in Rate Limiting and Throttling Strategies for Production APIs: Token Buckets, Sliding Windows, and Distributed Guardrails already assume an actor. One noisy job should not freeze the workspace. - Update
last_used_atasynchronously. Doing it inside auth turns every GET into a write.
Return 401 for a bad key and 403 for a valid key with the wrong scope. Clients debug those differently.
When should you use API keys instead of OAuth or session cookies?
Short answer: API keys for server-to-server scripts, OAuth for delegated third-party apps, session cookies for humans in a browser.
| Credential | Best for | Weak at | Revocation |
|---|---|---|---|
| API key | Cron jobs, backend integrations, CLIs | Browser apps, user consent | Instant row update |
| OAuth access token | Third-party apps acting for a user | Headless scripts with no redirect | Short TTL plus refresh revoke |
| Session cookie | Dashboard users | Headless jobs | Logout and session table |
Keys win because a customer can drop one string into GitHub Actions. They lose the moment a React bundle can read the secret. That is a published password, not a public API.
Pick one credential per client type. Keep the prefix stable so Best Practices for API Versioning and Backward Compatibility still applies to the auth header.
How do key scopes relate to RBAC without duplicating your permission system?
Short answer: scopes are a coarse machine allowlist. Human RBAC still decides who may mint or revoke keys.
Keep the list short: invoices:read, invoices:write, webhooks:manage. If you copy every role from Implementing Role-Based Access Control (RBAC) in Next.js App Router onto the key, you now have two permission systems that drift the first time someone adds billing.export.
A practical split:
- A signed-in admin with
api_keys:managecreates the key. - The key carries only the selected scopes.
- Request auth checks those scopes.
- Row access still follows Designing Multi-Tenant SaaS Isolation: Data, Controls, and Cost Guardrails.
Default new keys to read-only. Write scopes should take an extra click. Annoying is cheaper than "full access" at 5:55pm.
How do you rotate and revoke keys without breaking integrations?
Short answer: insert a new key, leave the old one valid for a documented window, then revoke the old row. Never overwrite the hash in place.
In-place overwrite is a distributed outage with extra steps. Half the workers still have the old secret. Half have the new one. The status page starts doing cardio.
Insert a new row with the same scopes, show plaintext once, set expires_at on the old key, serve both until that time, then set revoked_at. Late customers get a clean 401.
Clients will POST rotation twice when a gateway times out. Pair the dual-accept window with Architecting Bulletproof Idempotent APIs in Node.js so the second POST returns the same new key.
Revoke is immediate. Rotation is polite. A key in a public gist is a revoke click, not a courtesy window.
What should you log when a key is used, created, or leaked?
Short answer: audit the lifecycle, sample the usage, and never write the plaintext or the hash into logs.
Designing Audit Logs for SaaS: Evidence, Security, and Product Trust already treats "a service account created an API key" as a first-class event. Keep that bar:
api_key.created: actor, tenant, key id, prefix, scopes. No secret.api_key.rotated/api_key.revoked: who, why, old and new key ids.api_key.leak_reported: source and time to revoke.
High-volume key.used events belong in metrics. Record last_used_at, increment a counter, and keep the guest book for human-accountable actions.
If support asks "who created this key?", the answer should come from that audit timeline, not Slack archaeology. Same bar as Designing Audit Logs for SaaS: Evidence, Security, and Product Trust.
Common API key mistakes that turn into incidents
Short answer: most incidents are plaintext storage, shared keys, and slow revocation, not missing cryptography papers.
- Storing plaintext "for support": prefix is enough to identify a key.
- One workspace key for every integration: when it leaks, everything dies.
- Hashing without a prefix: you table-scan or cache secrets in memory.
- Bcrypt or Argon2 on the hot path: you rate-limited yourself.
- Keys in frontend bundles: that is a published password.
- Logging Authorization headers: your log pipeline became a key store.
The crypto is the easy part. Show-once, rotate, and revoke keep you off the incident channel.
FAQ: practical questions teams ask while shipping keys
Should we bcrypt customer API keys like passwords?
Short answer: no. Use HMAC-SHA256 with a pepper, and keep the pepper in your secret manager.
Passwords are guessed often. API keys are sent constantly and should be long random strings. Slow hashes punish legitimate traffic.
Can one tenant have many keys with different scopes?
Short answer: yes, and they should.
A billing export and a webhook debugger should not share a credential. Multiple keys also make Rate Limiting and Throttling Strategies for Production APIs: Token Buckets, Sliding Windows, and Distributed Guardrails fairer: throttle one integration without freezing the tenant.
What do we do when a key shows up in a public repo?
Short answer: revoke first, notify the tenant, then rotate. Do not wait for a committee.
revoked_at is the source of truth. Record the leak response in Designing Audit Logs for SaaS: Evidence, Security, and Product Trust so you can prove how fast you moved.
Actionable next steps
- Add an
api_keystable with uniqueprefix, hashed secret, scopes, andtenant_id. - Put
API_KEY_PEPPERin Vault or SSM and refuse to boot if it is missing. - Ship create as show-once, with an idempotency key on the POST.
- Authenticate with prefix lookup plus
timingSafeEqual, then rate limit bykeyId. - Add rotate-with-grace and instant revoke, plus audit events for both.
A good API key system feels slightly strict in the UI and extremely boring in production. Customers see a secret once, integrations survive rotation, and a leaked credential is a revoke click. Fancy is optional. Killing a key at 2am is not.
