What an API key is
An API key is a long-lived secret string that identifies an application rather than a person. A client sends it with every request, the server recognises it, and access is granted according to whatever the key is allowed to do. That is the whole idea.
It is a deliberately simple credential. There is no login, no consent screen and no token exchange. A developer signs up, creates a key, pastes it into a config file, and their code starts working. That low friction is why almost every developer platform — payments, maps, email, infrastructure — hands out keys.
But simple does not mean careless. A key is a bearer credential: whoever holds it can use it, exactly like cash. There is no second factor and no signature. That means the security of the whole system rests on how well you generate keys, how carefully you store them, how narrowly you scope them, and how quickly you can revoke one that leaks. This guide is about doing all four well.
When a key is the right tool
Keys are not the answer to every authentication problem, and using them in the wrong place creates real risk.
Reach for an API key when a server talks to a server, when the caller is an application you can trust with a long-lived secret, or when you are offering a public API to developers. CI pipelines, backend integrations, monitoring agents and third-party services are all natural fits. The key is stored in a secret manager or an environment variable, and it never touches a browser.
Reach for OAuth when you need to act on behalf of a user. If your integration must read someone’s calendar or send email as them, you need a consent flow and a token that represents that delegation. A key cannot express “this is Alice’s data and Alice agreed”.
Reach for a JWT when you need a short-lived, verifiable token that carries claims and can be checked without a shared database. Service-to-service authentication in a mesh, or a signed download URL, are good examples.
The dangerous case is a public client. A key embedded in a mobile app, a desktop binary or a browser bundle is not secret: anyone can extract it. If your product needs those clients to call your API, put a backend proxy in front, or issue short-lived tokens from your own server after authenticating the user. Never ship a long-lived key inside client code.
The shape of a good key
A good key is unguessable and self-describing. It has two parts: a short, readable prefix and a long, random secret.
The prefix does two jobs. It identifies the key’s environment and type at a glance, and it gives the server an indexed handle for lookup without storing the secret. A fixed, recognisable format also makes accidental leaks detectable by secret scanners, which can spot sk_live_ in a commit and block it.
The secret must come from a cryptographically secure random source, never Math.random, a timestamp, or a UUID that leaks structure. 128 bits of entropy is the minimum; 256 bits is a comfortable default and costs nothing. Base64url encoding keeps the key safe to paste into URLs and headers without escaping.
const secret = crypto.randomBytes(32).toString("base64url");
const key = `sk_live_${secret}`;
Resist the urge to encode information into the key, such as the user id or a creation date. Anything readable in the key is information an attacker gains, and anything derived from predictable data weakens the randomness. The prefix is the only readable part you need, and it should reveal nothing sensitive.
Show it once, then forget it
The full key should exist in exactly one place after creation: the response that returned it to the user. From that moment on, the server stores only a hash and a prefix, which means it can verify a presented key but can never reproduce it.
This is the same property as password storage, and it changes the consequence of a breach. If an attacker dumps the api_keys table, they get hashes that cannot be sent to your API. Without hashing, a single database leak or backup exposure hands over every customer’s key at once.
The user experience follows from the technical constraint. The dashboard shows the key once, with a clear warning to copy it now, and afterwards displays only the prefix and metadata such as scopes and last use. If a user loses a key, they rotate it; they cannot recover it. Say this explicitly in your docs so nobody expects to find it later.
res.status(201).json({
id: row.id,
name: row.name,
prefix: row.prefix,
key, // shown once, never retrievable again
warning: "Store this key now. You will not be able to see it again.",
});
Store a hash, never the key
Hashing is the single most important control in an API key system. It is also the one teams most often skip, usually because they want to be able to display the key again later. Do not.
Use a fast cryptographic hash such as SHA-256. Unlike passwords, keys have full entropy, so there is no dictionary to attack and no need for a slow KDF. The fast hash also keeps verification cheap on the hot path.
function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex");
}
Comparison must be constant time. A naive === on strings short-circuits at the first differing byte, which leaks how much of a guessed key was correct. That is usually a theoretical concern over a network, but it is trivial to avoid and good hygiene. Compare equal-length buffers with crypto.timingSafeEqual.
const a = Buffer.from(hashKey(presented));
const b = Buffer.from(record.keyHash);
const ok = a.length === b.length && crypto.timingSafeEqual(a, b);
If you want to hide the hash from the database entirely, a keyed hash with a server-side pepper adds a second secret an attacker must also obtain. That is optional for high-security systems, but hashing at all is not optional.
Looking keys up without scanning
There is a practical problem with hashing: you cannot query the database by hash unless you hash the incoming key first, and you cannot hash the incoming key until you know which row to compare against. Hashing every row on every request is not an option.
The solution is the prefix index. Store the first dozen characters of the key in a plaintext prefix column with a unique index. When a request arrives, read the prefix, find the single matching row, then hash the full presented key and compare it to that row’s key_hash. One indexed lookup, one hash, one constant-time compare.
const prefix = key.slice(0, 12);
const record = await db.apiKey.findByPrefix(prefix);
if (!record || record.revokedAt) return res.status(401).end();
const presented = hashKey(key);
if (!timingSafeEqual(presented, record.keyHash)) {
return res.status(401).end();
}
A few details keep this honest. The prefix must be long enough that collisions are rare but short enough to be a useful label; twelve characters is a common choice. If a collision does occur, the unique index fails at creation and you regenerate. Return the same error for an unknown prefix and a wrong secret, so the response does not reveal whether a prefix exists. And update last_used_at asynchronously or in a batched write, because a synchronous update on every request turns a read into a write and doubles your database load.
Scoping keys to permissions and limits
A key that can do everything is a key whose leak is a catastrophe. Scope every key to the smallest set of permissions that lets it do its job.
Scopes are just permission strings, the same atoms used by RBAC: projects:read, deploy:write, billing:manage. Store them on the key, attach them to the request after verification, and enforce them exactly as you would a user’s permissions.
router.post(
"/deployments",
apiKeyAuth(),
requireScope("deploy:write"),
createDeployment
);
This is where least privilege becomes concrete. A monitoring integration needs only metrics:read. A CI pipeline needs deploy:write but never billing:manage. A read-only partner gets read scopes and nothing else. When a key leaks, the blast radius is whatever it was scoped to, which is why the default should be a short list that a human deliberately extends.
Scopes also make rate limits natural and fair. Because each key is a distinct caller, you can attach a quota per key and enforce it in your rate-limiting layer, so one runaway script cannot consume capacity meant for everyone. Tiered plans often map directly onto quota: free keys get a low ceiling, paid keys a higher one.
Environment prefixes
Prefixes are not decoration. They encode the environment and type of a key, which prevents one of the most common and embarrassing failure modes: a test key pointed at production, or a production key used in a test suite that then mutates real data.
A familiar convention is sk_live_ for secret keys in production and sk_test_ for the sandbox. Publishable or public keys might use pk_live_. The naming is up to you, but keep it consistent and document it, because your users will rely on it to tell at a glance what a key can touch.
sk_live_... secret key, production, full access within its scopes
sk_test_... secret key, sandbox, no real data
pk_live_... publishable key, safe to embed in a browser
The prefix also lets your server route requests to the right environment before any lookup. A sk_test_ key presented to the production API can be rejected immediately with a clear message, rather than failing as an opaque authentication error. And because the prefix is fixed and distinctive, secret scanners and code review tools can be configured to flag it.
Sending a key: header or query
Where the key travels matters as much as how it is stored. Send it in a header.
GET /v1/projects HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_8f2a9c...
Headers are not included in default access logs, do not appear in browser history, and are not forwarded in the Referer header when a page links to another site. They are also easy to redact in logs and proxies. The Authorization header with a Bearer scheme is conventional and supported by most HTTP clients and tooling; a dedicated X-API-Key header is equally fine.
Query strings are the wrong place. They are captured by access logs, cached by intermediaries, stored in browser history and analytics, and leaked through Referer. A key in a URL is a key in a dozen places you do not control.
GET /v1/projects?api_key=sk_live_8f2a9c... HTTP/1.1
If a client genuinely cannot set headers — some legacy webhook or embed scenarios — use a short-lived, narrowly scoped token in the query string instead, and make it expire quickly. Never accept a long-lived key in a URL, and if your logs might contain one, scrub it.
Rotation and revocation
Every key will eventually need to change. An employee leaves, a laptop is lost, a key appears in a public repository, or a routine policy simply requires periodic rotation. Design for this from the start, because retrofitting rotation is painful.
Rotation issues a replacement without breaking the client. The pattern is: create a new key with the same scopes, return it, allow both keys to work for a short overlap window, then revoke the old one. The overlap is what makes rotation zero-downtime, and publishing its duration lets integrators plan.
// 1. Issue the replacement and return it to the owner.
// 2. Keep both keys valid for the overlap window (e.g. 24 hours).
// 3. Revoke the old key, or let it expire automatically.
await db.apiKey.revoke(oldKeyId);
Revocation is immediate and permanent. Set revoked_at on the row, and have the verification middleware reject any key with a non-null value. Do not delete the row: keeping it preserves the audit trail and prevents the same prefix from being reused. Revocation must take effect on the very next request, which is easy when the key is checked against the database and impossible when it is a self-contained token.
Support revoking a single key, all keys for a user, and all keys for a tenant. The last two are the “I think we have been breached” button, and they should be one click. Alert the owner whenever a key is created or revoked, so an attacker who gains dashboard access cannot quietly mint a new credential.
Leaks: git, logs and client code
Most API key compromises are not clever attacks. They are a key sitting somewhere it should not be.
Source control is the classic. A key pasted into a config file, a test fixture or a .env that gets committed is in the git history forever, even if a later commit removes it. Scan commits and pull requests for key patterns, keep secrets in a manager or CI variables, and rotate immediately if one is ever committed. Assume that a key in a public repository is already compromised.
Logs are the quiet one. A request logger that prints full URLs, headers or bodies can capture keys by the million. Configure your logger to redact Authorization and X-API-Key headers, never log request bodies on auth endpoints, and audit log output for key-shaped strings.
Client-side code is the fatal one. A key in a browser bundle, a mobile app or a desktop binary is extractable in minutes. There is no obfuscation that fixes this. Put a server in front, or issue short-lived tokens.
Other places to check: error messages and stack traces, third-party analytics, screenshots in bug reports, and developer laptops that sync to a shared backup. Treat every one as a possible leak and give users the tools to respond when it happens.
Monitoring usage and anomalies
A key that is never observed cannot be defended. Record enough about each use to detect misuse and to answer questions after an incident.
At minimum, store last_used_at and the source of the request. From there you can build alerts that catch the patterns that matter: a key used for the first time in months, a sudden jump in request rate, a source country that does not match the integration, or a burst of 401s that suggests someone is guessing prefixes.
logger.info({
event: "api_key.used",
keyId: record.id,
ownerId: record.ownerId,
route: req.path,
ip: req.ip,
});
Never log the key itself, only its id and prefix. Surface usage in the dashboard so customers can see which keys are active and spot one they do not recognise. Send email on creation, rotation and revocation, and give owners a way to disable a suspicious key instantly.
For a broader treatment of protecting an API from abuse, the rate limiting guide covers quotas, burst handling and how per-key limits fit together.
Expiry and lifecycle policies
A key with no expiry is a key you will forget about until it leaks. Give every key a lifecycle, even if the default is generous.
An optional expiry lets a user create a key that dies on a chosen date, which is ideal for a contractor, a temporary integration or a one-off migration. An absolute maximum age caps how long any key can live, after which rotation is mandatory. Many platforms combine the two: keys default to a year, can be shorter, and can never exceed two.
Track a state rather than just a boolean. A key can be active, expiring soon, expired or revoked, and each state deserves a different response. Keys nearing expiry should trigger an email so the owner rotates before an outage, and the verification middleware should treat expired and revoked identically: reject with 401.
function isUsable(key: ApiKey): boolean {
if (key.revokedAt) return false;
if (key.expiresAt && key.expiresAt < new Date()) return false;
return true;
}
Expiry is a safety net, not a substitute for revocation. A stolen key that expires in a year is still dangerous, so rotation and monitoring remain the primary controls. But an expiry bound means that a key someone forgot about, or one abandoned by a departed employee, eventually stops working on its own.
Testing API key authentication
API key verification is a small amount of code guarding a large amount of access, so test it thoroughly and mostly with negative cases.
import request from "supertest";
import app from "../app.js";
test("rejects a request with no key", async () => {
await request(app).get("/v1/projects").expect(401);
});
test("rejects a malformed key", async () => {
await request(app)
.get("/v1/projects")
.set("Authorization", "Bearer not-a-real-key")
.expect(401);
});
test("rejects a revoked key", async () => {
const { key } = await createKey();
await revokeKey(key);
await request(app)
.get("/v1/projects")
.set("Authorization", `Bearer ${key}`)
.expect(401);
});
test("rejects a key without the required scope", async () => {
const { key } = await createKey({ scopes: ["projects:read"] });
await request(app)
.post("/v1/deployments")
.set("Authorization", `Bearer ${key}`)
.expect(403);
});
Add tests that prove the full key is not stored: create a key, inspect the row, and assert that key_hash is the hash and that the plaintext appears nowhere. Assert that last_used_at advances on use. And test the rotation overlap explicitly: both the old and new keys should work during the window, and only the new one after it closes.
Finally, test that error responses do not distinguish an unknown prefix from a wrong secret. Both must return the same status and body, or you have handed an attacker a way to confirm which prefixes exist.
Building the key management UI
The management screen is where the security properties become visible to users, so it should make the right behaviour the easy behaviour.
The list view shows each key’s name, prefix, scopes, creation date and last use, never the secret. It offers a revoke button with a confirmation, and it makes “create key” the path to rotation. The creation flow lets the user pick scopes and an optional expiry, then shows the full key once with a copy button and an unmistakable warning.
sk_live_8f2a... CI deploy deploy:write created 3 days ago
sk_live_1c4b... Monitoring metrics:read last used 2 minutes ago
sk_test_9a7d... Staging projects:read revoked yesterday
Good details to include: a “last used” timestamp so users can spot keys they do not recognise, a one-click revoke for a single key, a “revoke all” for the account, and email notifications on every create and revoke. If you show a usage chart, make it per key, because that is the unit users reason about.
Do not build a “reveal key” button. It cannot exist if you hash correctly, and its absence is a feature: it means a database leak is survivable. Explain in the UI that keys are shown once and rotation is the recovery path, so the constraint feels deliberate rather than broken.
Choosing an encoding and length
The encoding is a small decision with a few practical consequences. Base64url is the common choice because it is compact, safe in URLs and headers, and case-sensitive, which maximises entropy per character. Hex is longer but easier to read aloud and to match in logs. Base62 sits between the two and avoids + and / entirely.
| Encoding | 128 bits | 256 bits | Notes |
|---|---|---|---|
| base64url | 22 chars | 43 chars | Compact, URL-safe, case-sensitive |
| hex | 32 chars | 64 chars | Longer, easy to copy, case-insensitive |
| base62 | 22 chars | 43 chars | Alphanumeric only, no symbols |
Whatever you choose, keep the secret case-sensitive and never lowercase it before comparison. A common bug is a middleware or proxy that normalises header values, silently breaking keys that contain uppercase letters. Document the exact format, and make the prefix distinctive enough that a key is recognisable in a support ticket without being useful to an attacker.
Do not make the key self-describing beyond the prefix. Embedding the user id, a checksum or an expiry inside the key tempts you to skip the database lookup, but it also means the key carries information and cannot be revoked without a lookup anyway. Keep the secret opaque and let the database hold the meaning.
Storing client secrets safely
Your server-side storage is only half the story; the client also has to keep the key safe. Give integrators clear, opinionated guidance, because the default behaviour of many developers is to paste a key into a file and commit it.
Recommend environment variables for servers, injected by the platform or a secret manager rather than written into a checked-in .env. In CI, use the pipeline’s secret store and mask the value in logs. For local development, load from a .env that is git-ignored, and prefer sk_test_ keys so a mistake touches only sandbox data.
# Load from the environment, never hard-code.
export MYAPP_API_KEY="sk_live_8f2a9c..."
curl -H "Authorization: Bearer $MYAPP_API_KEY" https://api.example.com/v1/projects
Point users at managed secret stores — AWS Secrets Manager, Google Secret Manager, Vault, or the platform’s built-in variables — and explain rotation in terms they can act on. If your SDK reads the key from an environment variable by default, most integrations will do the right thing without being told, which is the best kind of security control.
Secret keys and publishable keys
Many platforms ship two kinds of key, and conflating them causes real problems. A secret key authenticates a trusted server and can never be exposed. A publishable key is meant to be embedded in client code and is safe to be public because it grants no privileges on its own.
sk_live_... secret, server-only, carries scopes and a quota
pk_live_... publishable, client-safe, identifies the account only
A publishable key is useful for attribution and rate limiting in the browser, but every privileged operation must still be authorised by something the client cannot forge: a short-lived token minted by your server, or a user session. Treat the publishable key as an identifier, not a credential, and never let its presence alone grant access.
Naming the two consistently makes the distinction obvious at a glance and lets secret scanners, code review and documentation all reinforce the same rule. If a developer ever pastes an sk_ key into front-end code, the prefix alone should make the mistake visible before it ships.
Best practices
- Generate the secret from a CSPRNG with at least 128 bits of entropy, prefixed for readability and environment.
- Store only a SHA-256 hash and a short prefix; never persist the full key.
- Show the full key exactly once, at creation, and make rotation the recovery path.
- Compare hashes in constant time and return the same error for unknown and invalid keys.
- Index the prefix so verification is one lookup and one hash.
- Scope every key to the minimum permissions and attach a per-key rate limit.
- Encode the environment in the prefix and reject a sandbox key at the production API.
- Accept keys in the
Authorizationheader, never in a query string. - Support zero-downtime rotation with an overlap window, and make revocation immediate.
- Monitor
last_used_at, alert on anomalies, and notify owners on every credential change. - Separate secret keys from publishable keys, and never let a publishable key grant access on its own.
- Document the key format, scopes and rotation policy so integrators can follow it without guessing.
Common mistakes
- Storing keys in plaintext and assuming the database will never leak.
- Hashing every row to find a match instead of indexing a prefix.
- Using
Math.randomor a UUID as the secret and shrinking the search space. - Embedding a long-lived key in a browser bundle, mobile app or desktop binary.
- Putting the key in a query string, where logs and history capture it.
- Granting every key full access because scoping felt like extra work.
- Never rotating or expiring keys, so a leak stays useful indefinitely.
- Deleting revoked rows and losing the audit trail.
- Logging full headers or URLs and leaking keys into observability tooling.
- Returning a different error for an unknown prefix than for a wrong secret.
Where to go next
API keys are the simplest credential in your toolbox, and the same instincts — hash at rest, scope tightly, rotate on demand — apply everywhere. If you need short-lived, verifiable tokens that carry claims, read the JWT guide. When the caller acts on behalf of a user and needs consent, OAuth 2.0 is the right model. The permission strings on a key are the same atoms used by RBAC, so that guide is the natural companion for scoping. And because keys are a natural bucket for quotas, rate limiting shows how to stop one integration from exhausting capacity for everyone.