Build an API Key Management Service
A complete API key service: hashed key storage, prefix lookup, scopes, expiry, rotation with overlap, usage metering, and revocation tests.
Project goal
Issue, scope, rotate, and revoke API keys while storing only hashes, so a database leak never exposes a usable credential.
Final features
- Issue a key
- Verify by prefix
- Scope check
- Rotation with overlap
- Usage metering
Architecture
A key has a public prefix and a secret body. Only the prefix and a slow hash of the full key are stored, letting lookups by prefix find one candidate to verify. Scopes and an expiry bound each key, and rotation keeps the old key valid briefly.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+
- A hashing library with a configurable cost
Dependencies
- pg ^8.12.0 — Key metadata storage
- @node-rs/argon2 ^2.0.2 — Slow hashing of the secret part
- express ^4.21.0 — Management endpoints
Directory structure
api-key-service/
├── src/issue.ts
├── src/verify.ts
├── src/rotate.ts
├── src/usage.ts
├── tests/keys.test.ts
└── package.json
Phases
1. Issue a key
Return the full key once and store only the prefix and hash.
const secret = base64url(crypto.randomBytes(24));
const full = 'sk_' + prefix + '_' + secret;
await db.insert({ prefix, hash: await argon2.hash(full), scopes, expiresAt });
Expected behavior: The plaintext key never persists, so a leak cannot be replayed.
2. Verify by prefix
Look up by prefix and verify the full key against the stored hash.
const row = await db.findByPrefix(prefix);
if (!row || !(await argon2.verify(row.hash, presented))) return null;
Expected behavior: Prefix indexing avoids hashing every key on each request.
3. Scope check
Require the route scope to be a subset of the key scopes.
if (!scopes.every((s) => key.scopes.includes(s))) return res.status(403).end();
Expected behavior: A key issued for reads must not perform writes.
4. Rotation with overlap
Create a new key and keep the old key valid until a grace deadline.
await db.update(oldKeyId, { expiresAt: now + graceMs });
return issueKey(sameScopes);
Expected behavior: Rotation must not cause an outage for the caller.
5. Usage metering
Increment a per-key counter atomically and reject over-quota calls.
await redis.incr('quota:' + keyId);
Expected behavior: Metering enforces plan limits without a separate service.
Testing
Assert that a revoked key is rejected, that ownership scopes are enforced, that rotation keeps the old key valid within the grace window, and that quota exhaustion returns 429.
Troubleshooting
Verification is slow under load
The verifier scanned all rows; it must look up by prefix first to hash only one candidate.
Rotation breaks clients instantly
The grace window was skipped; always overlap old and new keys on rotation.
Deployment
Store key metadata in the primary database and meter with Redis. Back up the database; you cannot recover plaintext keys, only reissue them, and that must be documented to users.
Known limitations
- Keys are opaque strings without embedded metadata.
- Rotation is manual and not scheduled.
- The example does not implement per-endpoint cost weighting for quotas.
Project overview
API keys are the most commonly issued credential a service ever hands out, and the most commonly mishandled: stored in plaintext, compared with string equality, and impossible to rotate without an outage. This build treats the key as a secret the database must never be able to replay.
You will mint keys as a prefix plus a random secret, store only the prefix and a slow argon2 hash, verify by looking the prefix up and hashing one candidate, enforce scopes and expiry, rotate with a grace window, and meter usage per key.
The design assumes the database will leak eventually. If only a prefix and a hash are stored, a dump exposes nothing usable, and the expensive hash is the reason a leaked hash is not one lookup away from the key it protects.
The service is sized for a small platform team that hands keys to a handful of integrators. It keeps the whole credential lifecycle visible in a few files, so an operator can answer what a key can do and when it dies without reading a framework.
Every phase leaves a working service: first a key that can be issued and verified, then scopes, then rotation, then metering, so you always have a credential system that behaves correctly at the stage you stopped.
What you will build
The service provides five capabilities, each with a test that drives it through its public interface:
- Issue a key
- Verify by prefix
- Scope check
- Rotation with overlap
- Usage metering
Issuing returns the plaintext exactly once; verification is a prefix lookup plus one hash; scopes constrain what a key can do; rotation overlaps old and new so clients migrate without failing; and metering enforces a plan without a second service.
The key format is deliberately self-describing: the prefix is short and indexed, the secret is long and random, and neither carries embedded metadata a client could forge. Metadata lives in the database, where it can be changed.
Architecture and data model
A key has a public prefix and a secret body. Only the prefix and a slow hash of the full key are stored, letting lookups by prefix find one candidate to verify. Scopes and an expiry bound each key, and rotation keeps the old key valid briefly.
A key row holds the prefix, the argon2 hash of the full key, scopes, an expiry, and a version. Verification fetches that one row by its indexed prefix and runs argon2 against the presented key, so the cost is one hash per request rather than one per stored key.
Scopes are checked as a subset relationship: a route declares the scopes it needs and the key must contain all of them. That makes authorization a single set comparison and keeps policy out of the request handler.
Rotation is a state change rather than a new credential type: the old key receives a grace expiry and a replacement is issued with the same scopes. Because both rows stay valid during the window, clients migrate on their own schedule instead of at deploy time.
The prefix is long enough to be unique without a lookup table yet short enough to index cheaply, and the secret is generated from a CSPRNG so guessing is infeasible. Splitting public and secret parts is what allows a fast, indexed lookup without ever storing the secret itself.
Prerequisites and dependencies
You need a database and a hashing library before the first key can be minted, and the secret part is never stored.
- Node.js 20 LTS or later
- PostgreSQL 15+
- A hashing library with a configurable cost
The dependencies keep storage, hashing, and the management API in one process:
pg(^8.12.0) — Key metadata storage@node-rs/argon2(^2.0.2) — Slow hashing of the secret partexpress(^4.21.0) — Management endpoints
pnpm init
pnpm add pg @node-rs/argon2 express
pnpm add -D typescript tsx vitest
Pin the hashing parameters in configuration and record them with each hash so a future cost increase can be applied lazily on the next verification. Store the database URL and any pepper in the environment, never a committed default.
Directory structure
Separate issuance, verification, rotation, and metering so each rule has a single home and one test file:
api-key-service/
├── src/issue.ts
├── src/verify.ts
├── src/rotate.ts
├── src/usage.ts
├── tests/keys.test.ts
└── package.json
Phase 1: Issue a key
Return the full key once and store only the prefix and hash.
const secret = base64url(crypto.randomBytes(24));
const full = 'sk_' + prefix + '_' + secret;
await db.insert({ prefix, hash: await argon2.hash(full), scopes, expiresAt });
The plaintext key never persists, so a leak cannot be replayed.
Generate the secret from a CSPRNG, never from a predictable counter, and return the full key once with a clear warning that it will not be shown again. Insert only the prefix and the hash; the plaintext exists for the length of the response and nowhere else.
Phase 2: Verify by prefix
Look up by prefix and verify the full key against the stored hash.
const row = await db.findByPrefix(prefix);
if (!row || !(await argon2.verify(row.hash, presented))) return null;
Prefix indexing avoids hashing every key on each request.
Look up by the indexed prefix to find a single candidate, then run argon2 to compare. Hashing every stored key on each request is what makes naive implementations fall over under load, so prefix indexing is a correctness requirement rather than an optimization.
Phase 3: Scope check
Require the route scope to be a subset of the key scopes.
if (!scopes.every((s) => key.scopes.includes(s))) return res.status(403).end();
A key issued for reads must not perform writes.
Compare required scopes against the key scopes as a subset, and reject with 403 when one is missing. Keep the scope vocabulary small and documented, because a sprawl of fine-grained scopes is harder to review than a coarse model plus route-level policy.
Phase 4: Rotation with overlap
Create a new key and keep the old key valid until a grace deadline.
await db.update(oldKeyId, { expiresAt: now + graceMs });
return issueKey(sameScopes);
Rotation must not cause an outage for the caller.
On rotation, set the old key expiry to now plus a grace period and issue a replacement. Log which key id replaced which so an operator can trace a migration, and make the grace period configurable because it trades a security window against migration comfort.
Phase 5: Usage metering
Increment a per-key counter atomically and reject over-quota calls.
await redis.incr('quota:' + keyId);
Metering enforces plan limits without a separate service.
Increment a per-key counter atomically and reject once the quota is exceeded. Metering in a separate store keeps the hot path fast, and a hard cap protects the upstream even when a single key is legitimate but misbehaving.
Testing
Assert that a revoked key is rejected, that ownership scopes are enforced, that rotation keeps the old key valid within the grace window, and that quota exhaustion returns 429.
Issue a key, verify it, then verify a tampered variant and assert failure. Assert that the plaintext never appears in the database by querying for it and finding nothing.
Cover scopes and expiry: a read-only key must be refused a write route, and an expired key must be refused even with a valid hash. Rotation gets its own test that the old key keeps working inside the grace window and stops after it.
Metering is tested by exhausting a quota and asserting a 429 on the next call, while a second key on the same account remains unaffected, which proves quotas are per key rather than global.
A test that scans the key table for the plaintext string is worth writing early, because it converts a design intention into a failing build if a future refactor ever logs or persists the secret by mistake.
pnpm vitest run
Deployment and operations
Store key metadata in the primary database and meter with Redis. Back up the database; you cannot recover plaintext keys, only reissue them, and that must be documented to users.
Keep metadata in the primary database and meter in Redis, and back the database up while understanding that keys cannot be recovered from a backup — only reissued. Say that plainly to users, because a support process that promises recovery fails the first time it matters.
Raise the argon2 cost over time and rehash on the next successful verification so old hashes upgrade without forcing every client to rotate. Schedule rotation as an operator action with a recorded replacement, rather than as an automatic expiry surprise.
Run the management API behind the same authorization as any privileged surface: issuing a key is an administrative act, so it needs its own authorization, audit row, and rate limit separate from the data path that verifies keys.
Keep the metering store from becoming a source of truth; if it is lost, quotas reset but keys still verify. Decide whether that is acceptable and document it, because a quota system that silently fails open is a business decision, not only a technical one.
Security considerations
Hash with a memory-hard function, never log full keys, and return the plaintext exactly once. Use constant-time comparison where applicable and reject expired keys before scope checks.
Store only hashes and prefixes; treat the plaintext as a secret that passes through memory once. Compare hashes with the library constant-time verifier, and never log the presented key, which would turn a debug log into a credential store.
Scopes must be enforced server-side on every route, not merely attached to the key. Reject keys whose scope set is empty at issuance, because an empty set is almost always a bug that silently grants nothing or, worse, is treated as a wildcard.
Keep database credentials and any pepper in a secret manager, and restrict the table with a role that can select and insert but not drop. A leaked read-only replica is far less damaging when the hashes are slow and the prefixes carry no secret material.
Give keys a maximum lifetime rather than relying on operators to remember rotation, and refuse to issue a key with an unbounded expiry by default. A credential with no deadline is the one that survives a departed employee.
Observability
Log issue, verify failure, rotation, and revocation events with key id but not the key. Alert on repeated verify failures for one prefix, which suggests brute force.
Emit an event per verification with the key id, route, and outcome, but never the key itself. Verification latency and hash duration belong on a histogram, because a rising hash time is the earliest sign that the cost parameter or the hardware has drifted.
Track the 403 rate by key and by route; a spike usually means a client was issued the wrong scopes, which is cheap to fix once you can see which key and which route disagree.
Alert on rotation events and on keys approaching expiry so renewals happen on purpose. An expired production key discovered through a customer outage is exactly the failure this metric exists to prevent.
Chart quota consumption per key over the billing window, because the key that is quietly approaching its cap is the one whose owner will call you next week if the trend is invisible today.
Troubleshooting
Verification is slow under load. The verifier scanned all rows; it must look up by prefix first to hash only one candidate.
Rotation breaks clients instantly. The grace window was skipped; always overlap old and new keys on rotation.
When verification fails, first confirm the prefix lookup returned a row, then confirm the hash matches, then confirm scopes and expiry. Each step has a distinct error code precisely so this triage takes seconds rather than a review of the whole service.
Limitations and next steps
- Keys are opaque strings without embedded metadata.
- Rotation is manual and not scheduled.
- The example does not implement per-endpoint cost weighting for quotas.
Keys are opaque strings with no embedded metadata, so a leaked prefix reveals nothing and also cannot be introspected offline. Rotation is manual rather than scheduled, and there is no automatic revocation on anomaly detection.
Quotas are per key with a flat cost, so a cheap read and an expensive write count the same. Adding a cost field per route is the natural extension when quotas need to reflect real resource use.
The example assumes a single database and a single Redis, so multi-region issuance is out of scope. A key minted in one region is not visible in another until the stores are replicated, a delay the code does not attempt to hide.
There is no self-service portal; keys are issued by an operator through the API. Building a UI on top is straightforward but would add an authentication surface of its own, which is why it is left out of this build.