API key authentication

User login solves the problem of a human proving who they are at a keyboard. A backend service calling another backend service has no human and no keyboard — API keys solve a genuinely different problem, and treating one like a static password is how they end up leaked in a public repo.

Intermediate

3 min read

Why user auth doesn't work for machines

Sessions and OAuth login flows assume a human: someone who can see a login form, type a password, approve a consent screen, or tap a push notification. A cron job calling a billing API, or one microservice calling another, has none of that. API keys exist for this case — service-to-service or machine-to-machine authentication, where the "identity" being proven is a piece of software, not a person.

Issuing keys: what a good key actually looks like

A good API key is a long, high-entropy random string, generated server-side and never derived from anything guessable (not a hash of the account email, not a UUID that's just barely too predictable). A useful convention, popularized by Stripe, is prefixing keys so they're identifiable and greppable without decoding anything:

sk_live_51H8x...   # secret key, live mode
sk_test_51H8x...   # secret key, test mode
pk_live_51H8x...   # publishable key, safe to expose client-side

That prefix matters operationally: it lets you write a pre-commit hook or a GitHub secret scanner that flags sk_live_ appearing anywhere in a diff, and it lets a human glance at a key and know its blast radius before touching it.

Scoping: a key is not "logged in as the account"

The single most common mistake with API keys is treating them as equivalent to a full user session. A key generated for "read order status" should not also be able to issue refunds. Scope keys to the minimum set of operations they need:

{
  "key_id": "key_9f3a2b",
  "owner": "acct_4471",
  "scopes": ["orders:read", "orders:write"],
  "created_at": "2026-01-15T00:00:00Z",
  "last_used_at": "2026-08-20T14:02:11Z"
}

Every request should be checked against the key's stored scopes, not just its validity — a valid-but-unscoped key attempting refunds:write should get a 403, the same way an authenticated-but-unauthorized user would.

Storing and transmitting keys safely

Only the hash of an API key should ever be stored server-side, exactly like a password — if your database leaks, raw keys shouldn't be recoverable from it. Show the full key to the user exactly once, at creation time, and store only a hash plus a short displayable prefix (sk_live_51H8...) for the user to recognize it in a list later. Keys should travel over HTTPS only, in a header (Authorization: Bearer <key> or a custom X-API-Key header) — never as a URL query parameter, since query strings routinely end up in server access logs, browser history, and referrer headers.

Rotating and revoking without downtime

A key that can never be rotated becomes a permanent liability the moment it leaks. Support multiple active keys per account so a client can generate a new key, switch traffic over, and only then revoke the old one — a single active key forces an outage window during rotation. Revocation should be immediate and checked on every request (a lookup against the hashed key, not a cached "is this key valid" flag that might lag), and expose last_used_at so an account owner can spot a key that's stale enough to safely kill, or one that's being used from somewhere it shouldn't be.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why can't typical user login flows (sessions, OAuth) be reused for service-to-service authentication?

2. What problem does scoping an API key to specific permissions solve?

3. How should API keys be stored in the server's database?

4. Why should an account be able to have more than one active API key at a time?