API Keys
A long-lived secret that identifies an application rather than a person — cheap to verify, easy to leak, and revocable only if you designed for it.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
When is a long-lived static key the right credential, and what does it take to run one safely?
Partner systems and our own scripts need to call the API without a browser, a login screen or a human present.
Generate a random string, store it in a column, and compare it on each request. Give one to each customer and tell them to keep it safe.
The key is stored in plaintext, so a read-only database leak — a backup, a log of a query, a compromised replica — hands over every customer's credential (Secrets in Logs).
- The key is stored in plaintext, so a read-only database leak — a backup, a log of a query, a compromised replica — hands over every customer's credential (Secrets in Logs).
- A customer pastes the key into a public repository, a support ticket or a browser-side application, and there is no way to detect it and no rotation path that does not cause downtime.
- Every key can do everything, because there was never a scope concept, so a key issued for a read-only integration can also delete.
- A key is compared with
==against the stored value, which is a timing side channel, and against a plaintext column, which means the comparison is the least of the problems. - The key is passed in a query string, so it appears in access logs, browser history, proxy logs and referrer headers.
- One key per customer means rotation requires the customer to change everything at once, so nobody rotates.
What is actually happening
- An API key identifies a workload, not a person. There is no login, no session and no interactive consent — which is exactly why it fits machine callers and exactly why the credential must live somewhere for a long time (Human vs Workload Identity in Cloud & Infrastructure).
- Verification is a lookup plus a comparison. Unlike a signed token, there is nothing self-contained about it, so revocation is a row delete and is immediate — the property sessions have and self-contained tokens do not (Token Authentication and the Revocation Problem).
- Entropy changes what hashing has to do. You generate the key from a cryptographic random source, so it has high entropy and is not guessable. That means a fast cryptographic hash for storage is appropriate and a slow password hash is unnecessary — the slowness in a password hash exists to compensate for human-chosen, low-entropy secrets. This exception applies *only* to keys you generate at full entropy; anything a user chooses is a password and needs Credentials and Password Handling.
- A prefix makes the key findable without making it readable. Storing a short non-secret prefix alongside the hash lets you look up the record in one indexed read, display a recognisable partial to the owner, and give secret scanners a pattern to match.
- Multiple live keys per owner is what makes rotation possible. With one key, rotation is a cutover; with two, it is issue, migrate, revoke.
- Scopes and rate limits attach to the key, not the account. That is what makes a leaked key a bounded incident instead of a total one (Rate Limiting).
What a key record has to contain
Almost every operational property in this lesson comes from the shape of the stored record rather than from the verification code. Prefix, scope, expiry and last-used are each one column, and each one turns an impossible operational question into a query.
Note what is not stored: the key itself. It is shown once at creation and then exists only wherever the customer put it.
1interface ApiKeyRecord {2 id: string3 ownerId: string4 prefix: string // e.g. "ea_live_7f3a" — not secret, indexed5 hash: string // fast cryptographic hash of the full key6 scopes: string[] // narrow by default7 label: string // "billing sync", so humans can reason about it8 createdAt: number9 expiresAt: number | null10 lastUsedAt: number | null11 revokedAt: number | null12}13 14function issue(ownerId: string, scopes: string[]) {15 const secret = randomBytes(32).toString('base64url') // CSPRNG16 const key = `ea_live_${secret}`17 const prefix = key.slice(0, 12)18 store.insert({ ownerId, prefix, hash: sha256(key), scopes, /* ... */ })19 return key // shown exactly once, never retrievable20}21 22async function verify(presented: string) {23 const rec = await store.byPrefix(presented.slice(0, 12)) // one indexed read24 if (!rec || rec.revokedAt || expired(rec)) return null25 if (!timingSafeEqual(sha256(presented), rec.hash)) return null26 store.touchAsync(rec.id) // last-used, off the hot path27 return principalFrom(rec) // who, plus granted scopes28}The prefix is what keeps verification a single indexed read instead of a table scan comparing hashes. timingSafeEqual matters even on hashes; touchAsync keeps a write off the request's critical path.
Rotation is a design decision made before the incident
The moment a customer says "we think our key leaked" is the moment you find out whether rotation is a routine operation or an outage. The difference is entirely whether the system allows two active keys at once.
The overlap window is not a weakness to minimise to zero; it is the mechanism. What matters is that the old key is actually revoked at the end, which is why last-used tracking is on the record.
- 1Issue a second key
A new key with the same scopes and a new label, both now valid.
fails by A schema that allows only one key per owner, making this step impossible.
- 2Customer deploys the new key
Their systems start presenting the new value.
fails by No visibility into whether they have finished, so you revoke too early.
- 3Watch last-used on the old key
Confirms the old key has stopped being presented.
fails by Last-used not tracked, so the decision is a guess.
- 4Revoke the old key
Marks it revoked; the next request with it fails.
fails by A cached lookup keeps accepting it for the cache TTL.
- 5Confirm
Authentication failures for the old prefix appear and then stop.
fails by Nobody watches, and a forgotten system fails silently days later.
Under active compromise, skip to revoke and accept the breakage. The pipeline is for planned rotation, which is what should be happening on a schedule.
Key, token or workload identity?
API keys sit in a specific place: machine callers, long-lived relationships, immediate revocation, no interactive login. Where those conditions do not all hold, one of the neighbours is usually better.
The row that surprises people is the last one. For your own services running on a platform that can attest their identity, a static key is a secret you have to distribute, store and rotate — and the platform will give you a short-lived credential instead if you ask it to.
Who is calling, how long does the relationship last, and how fast must revocation be?
when External partners and customer integrations; long-lived; you want revocation to be a row delete.
cost A long-lived secret in someone else's systems; you own scoping, rotation, leak detection and per-key limits.
when Machine callers where you already run OAuth and want short-lived tokens with scopes (OAuth and OIDC From the Backend Side).
cost More moving parts for the caller; you need an authorization server or a provider.
when Service-to-service where a per-request lookup is unwelcome (Token Authentication and the Revocation Problem).
cost Revocation is bounded by lifetime rather than immediate.
when Internal services in a network where you can run a certificate authority.
cost Issuance, distribution, rotation and revocation infrastructure for certificates.
when Your own services on a platform that can attest the workload and issue short-lived credentials.
cost Platform-specific; does not extend to external partners; the attestation mechanism is a dependency (Roles vs Static Keys).
How to build it
Most important first.
- Generate keys from a cryptographic random source with plenty of entropy, and format them with an identifying prefix — a product marker and an environment marker, so a key in the wrong place is obvious.
- Store a fast cryptographic hash of the key plus the plaintext prefix. Show the full key exactly once at creation and never again.
- Compare in constant time, and look the record up by prefix so verification stays one indexed read.
- Allow several active keys per owner, each with its own label, creation date, last-used timestamp and expiry, so rotation is a routine operation rather than an outage.
- Scope each key to what it is for — read-only, a subset of resources, a set of operations — and default to the narrowest scope (Scopes: Least Privilege as Contract Surface in API Design, Role-Based Access Control).
- Set an expiry by default, even a long one, so abandoned keys eventually die rather than accumulating forever.
- Rate-limit and quota per key, so one leaked or badly written integration cannot exhaust shared capacity (Rate Limiting, Quotas vs Rate Limits in API Design).
- Accept the key in a header, never in a query string, and reject requests that put it in the URL rather than silently accepting them.
- Record last-used time and calling IP per key so you can find dormant keys, and give owners a self-service revoke that takes effect on the next request.
- Publish a key format that secret scanners can recognise, and act on the notifications you get — providers do report leaked keys found in public repositories.
- For your own infrastructure, prefer short-lived workload identity over static keys where the platform offers it (Roles vs Static Keys in Cloud & Infrastructure).
What can go wrong
- Keys stored reversibly so support can read them to a customer — which means an attacker with support-level access gets them too.
- Revocation implemented as a flag that a caching layer holds for minutes, quietly converting immediate revocation into delayed revocation.
- A single key shared across a customer's environments, so a leak from a staging box is production access.
- Scopes defined but not enforced at the object level, so a read-only key can read every tenant's data (Object-Level Authorization, Tenant Isolation).
- No last-used tracking, so nobody can safely delete anything and keys accumulate for years.
- The key checked in middleware but the internal service behind it trusting a plain header, so anything that can reach the internal service bypasses the check (The Trust Boundary).
- Rotation is a race by nature: between issuing a new key and revoking the old one, both are valid. That overlap is the feature — a rotation with no overlap is an outage.
- Revocation racing an in-flight request behaves exactly like session revocation: a request that already read the record completes. Any cache in front of the lookup widens that window to its TTL.
- Concurrent creation of keys against a per-owner limit needs an atomic check, or two parallel requests both pass a limit check that only one should (Atomic Operations).
- A leaked key is full access for as long as it lives. Everything above — scoping, expiry, rotation, per-key limits, last-used visibility — exists to bound that.
- Hash keys at rest. High entropy justifies a fast hash, not no hash: a plaintext key column is a credential store with the access controls of a database table.
- Never in a URL. Query strings are logged by proxies, load balancers and your own access log, and they leak through referrer headers.
- Constant-time comparison of the hashed value; a byte-by-byte early return is measurable at scale.
- A key authenticates; it does not authorize. Every request still needs an object-level check, and a key with a scope is still not permission to touch another tenant's rows (Authentication vs Authorization, Multi-Tenancy).
- Redact keys in logs, error reports and support tooling by default, and test that the redaction works (Secrets in Logs, Security-Safe Logging in Security Engineering).
- For agent and tool callers, the key is what a model's tool call presents — the same rules apply, and the blast radius is whatever the key can reach (Agent Authorization, Tool Permissions and Least Privilege in Agentic).
- "API keys are insecure." They are a bearer credential like any other. What makes them risky is longevity and breadth, both of which you control.
- "Hash them like passwords with bcrypt." Unnecessary for a key you generated at full entropy, and it puts a deliberately slow function on every request. A fast cryptographic hash is the right tool here — and this is the only credential in this module where that sentence is true.
- "A key identifies the user." It identifies an application or an integration. Mapping it to a subject is your decision, and it is usually a service account rather than a person.
- "We can just rotate if there is a leak." Only if the design allows two live keys and the caller can change theirs without downtime. Retrofitting that during an incident is not the moment.
- "Scopes are enough." Scopes bound what kind of operation is allowed. They say nothing about which rows (Object-Level Authorization).
Operating it
- Requests per key, and last-used per key. Together they answer "can we delete this" and "is something using a key we thought was dead".
- Authentication failures per key prefix — a valid prefix with a bad secret is either a botched rotation or someone guessing.
- A sudden change in a key's calling IP or geography, or its first use after months of dormancy, is worth an alert even if it is usually benign.
- Count keys by age and by expiry status, so key sprawl is visible before an audit finds it.
- Rate-limit rejections per key, which is how you find the integration that is about to file a support ticket.
- Verification is one indexed read plus a hash. It scales the way any key-value read scales, and it is a natural candidate for a short-TTL cache — at the cost of turning immediate revocation into TTL-bounded revocation, which should be a decision rather than an optimisation (Where Sessions Live has the same trade-off).
- At 10x partners, key management becomes a product surface: self-service creation, labelling, scoping, rotation and revocation, with an audit trail.
- At 100x, per-key quotas and rate limits are the mechanism that keeps one caller from affecting the rest, and they need to be enforced somewhere shared rather than per instance (Rate Limit Algorithms).
- Static keys are simple for callers and long-lived by nature, which is convenient and is precisely the risk.
- Hashing keys means you can never show the value again, which generates support requests and is worth the friction.
- Fine-grained scopes bound a leak and add a permission model that customers must understand and you must maintain.
- Caching key lookups removes a read from the hot path and delays revocation by the TTL.
- Short-lived workload identity is strictly better where it is available and requires a platform that provides it and callers who can use it.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALPrefix plus hashed secret, multiple live keys, per-key scope and limits — the design is stack- and protocol-independent.
- GENERALThe fast-hash exception is conditional and worth stating precisely: it applies only to secrets your system generates from a cryptographic random source at full entropy. Any secret a human chooses, or any value with structure an attacker can guess, is a password and requires the slow, salted treatment in Credentials and Password Handling.
- CLOUD-SPECIFICWhere the platform offers workload identity — short-lived credentials derived from the runtime's attested identity rather than a static string in configuration — that is preferable for your own services, because there is no long-lived secret to leak. The mechanism, the token lifetime and the way identity is attested differ by provider, so it is not a portable drop-in; see Roles vs Static Keys.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.