API Security

API Keys

An API key is a long-lived credential for machines. Issue it once, store only a hash, scope it tightly, and give yourself a way to rotate and revoke it before you ever need to.

intermediate14 min readUpdated Sep 16, 2026
keys.ts
ts
// keys.ts
import crypto from "node:crypto";

export function generateApiKey(env: "live" | "test") {
  const secret = crypto.randomBytes(32).toString("base64url");
  const prefix = `sk_${env}_`;
  const key = `${prefix}${secret}`;

  return {
    key,                        // returned to the user once
    prefix: key.slice(0, 12),   // stored and indexed for lookup
    hash: hashKey(key),         // stored instead of the key
  };
}

export function hashKey(key: string): string {
  return crypto.createHash("sha256").update(key).digest("hex");
}

export function timingSafeEqual(a: string, b: string): boolean {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && crypto.timingSafeEqual(left, right);
}
Used for
Server-to-server and public APIs
Format
Prefix plus a random secret
Stored as
SHA-256 hash
Shown
Once, at creation
Transport
Authorization header
Scoping
Permissions and rate limits
Rotation
Create the new key, then revoke the old
Leak risk
Git history, logs, client code

Why it matters

What a good key system gives you

One credential, many services

A key authenticates a machine without a login flow. It is simple to issue, simple to send and simple to rotate, which is why every developer platform uses them.

Hashed at rest

Store only a hash, exactly as you would a password. A stolen database dump then yields nothing an attacker can send to your API.

Scoped and rate limited

Each key carries its own permissions and quota, so a compromised key can only do what it was allowed to do, and only as fast as it was allowed to.

The big picture

Three properties of a safe key

A random secret that cannot be guessed, a hash at rest that cannot be replayed from a dump, and a scope that limits the damage if it leaks.

Secret

Identify

A high-entropy random string is the credential itself. Its only job is to be unguessable and unique to one caller.

Scope

Limit

A key carries a set of permissions. The verifier checks the action against the scope before the handler runs, exactly like a user's roles.

Quota

Protect

Keys make a natural bucket for rate limits, so one noisy integration cannot exhaust capacity for everyone else.

At a glance

The pieces you will build

Format

A readable prefix plus a long random secret, e.g. sk_live_8f2a...

Hashing

Store a SHA-256 hash; compare with a constant-time function.

Scopes

Granular permissions such as projects:read and deploy:write.

Rate limits

Attach a quota to each key and enforce it per key.

Rotation

Issue a replacement, migrate traffic, then revoke the old key.

Monitoring

Track last_used_at and alert on sudden changes in behaviour.

Data model

The api_keys table

Only a hash and a short prefix are stored. The full key exists once, in the response that created it, and can never be recovered.

The api_keys tablePostgreSQL table
  • idbigserialSurrogate primary key
  • nametextHuman label such as CI deploy or mobile app
  • prefixtextLeading characters of the key, indexed for fast lookup
  • key_hashtextSHA-256 of the full key, never the key itself
  • scopestext[]Permissions the key may exercise
  • owner_idbigintThe user or service that created the key
  • last_used_attimestamptzUpdated on use for anomaly detection and cleanup
  • revoked_attimestamptzSet when the key is revoked; null means active

Only a hash and a short prefix are stored. The full key exists once, in the response that created it, and can never be recovered.

Flow

Issuing and verifying a key

The full key exists in exactly one response; everything afterward works from a hash and a prefix.

  1. 1

    Generate a random secret

    Draw at least 128 bits from a CSPRNG and combine it with a readable prefix.

  2. 2

    Show it once

    Return the full key in the creation response and never store or display it again.

  3. 3

    Store a hash and a prefix

    Persist the hash, the short prefix, the scopes and the owner in the api_keys table.

  4. 4

    Send it in a header

    The client presents the key in Authorization or a dedicated header on every request.

  5. 5

    Hash and compare

    Look the key up by prefix, hash the presented value and compare in constant time.

  6. 6

    Attach the scopes

    Load the key's permissions and quota onto the request for the handler to enforce.

  7. 7

    Rotate or revoke

    Issue a replacement, migrate traffic, and mark the old key revoked.

The complete guide

API Keys: Everything you need to know

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 shape of an API key
sk_live_8f2a9c1d4e6b7a0f3c5d8e2b1a4f7c9d6e3b0a8f5c2d1e4b7a9c6f0d3e8b1a4
prefixenvironment and type, safe to log and scan for
secret256 bits of cryptographically secure randomness

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 Authorization header, 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.random or 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.

In practice

Create, verify, scope, revoke

The four operations every API key system needs, and nothing more.

routes/keys.ts
import crypto from "node:crypto";

router.post("/keys", requireAuth(), async (req, res) => {
  const { name, scopes } = req.body;
  const secret = crypto.randomBytes(32).toString("base64url");
  const key = `sk_live_${secret}`;

  const [row] = await db.apiKey.insert({
    name,
    ownerId: req.user.id,
    prefix: key.slice(0, 12),
    keyHash: crypto.createHash("sha256").update(key).digest("hex"),
    scopes,
  });

  // The only time the full key is ever returned.
  res.status(201).json({
    id: row.id,
    name: row.name,
    prefix: row.prefix,
    key,
  });
});

Store a hash, not the key

A hash is enough to verify a presented key and useless to anyone who steals the table. This is the same reasoning that applies to passwords.

Prefer
CREATE TABLE api_keys (
  id         bigserial PRIMARY KEY,
  prefix     text NOT NULL,
  key_hash   text NOT NULL,
  scopes     text[] NOT NULL DEFAULT '{}',
  revoked_at timestamptz
);

CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
Avoid
CREATE TABLE api_keys (
  id  bigserial PRIMARY KEY,
  key text NOT NULL
  -- one database dump or backup leak
  -- hands over every customer's key
);

Send keys in a header

Headers are not logged by default, do not appear in browser history, and do not leak through the Referer header when a page links elsewhere.

Prefer
GET /v1/projects HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_8f2a9c...
Avoid
GET /v1/projects?api_key=sk_live_8f2a9c... HTTP/1.1
Host: api.example.com
# query strings land in access logs, proxies
# and browser history

Trade-offs

Are API keys the right credential?

Keys are simple and universal, which is both their strength and their weakness. Understand what you give up.

Strengths

  • Dead simple for clients

    There is no login flow, no refresh token and no clock skew. A client stores one string and sends it, which is why every CLI and developer platform uses keys.

  • Revocable per integration

    Each key is a named credential. You can revoke the key used by a leaked script without affecting any other customer or service.

  • Easy to scope and meter

    A key is a natural unit for permissions and rate limits, so you can give one integration read-only access and a modest quota without building anything new.

Trade-offs

  • Long-lived by nature

    Keys usually do not expire, so a leaked one is dangerous until someone notices. Short expiries, rotation and monitoring reduce the window.

  • No user context

    A key identifies a service, not a person. Anything requiring consent, delegated access or per-user audit belongs with OAuth instead.

  • Hard to keep secret on the client

    A key embedded in a mobile app or a browser bundle is public. Use a backend proxy or a short-lived token for those clients.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning API Key Management?

Our interactive tutorial walks you through API Key Management step by step — with quizzes and real code you can run in the browser.