How to Add API Key Authentication You Can Actually Rotate

    6 min read3 approaches compared

    API keys look like the simplest authentication there is: generate a random string, check it on each request. The simplicity is real, but three requirements show up later that a naive implementation can't satisfy — showing users which key is which, revoking one key without breaking the others, and telling a customer whether a leaked key was ever used.

    All three are cheap if you design for them upfront and genuinely painful to retrofit, because retrofitting means invalidating every key your customers have already deployed.

    Design for the day a key leaks

    Keys end up in git repos, CI logs, and screenshots. Assume it will happen and make the response fast and surgical rather than a full reset for every customer.

    • Store a hash, never the key. You will not be able to show it again, which is correct and what every serious API does.
    • Give keys a visible prefix (`vcj_live_`, `vcj_test_`) so they're greppable in leaked code and identifiable in a support ticket.
    • Store a short identifiable fragment — first 8 characters — so users can tell keys apart in a list without you storing the secret.
    • Support multiple active keys per account from day one. Rotation without downtime is impossible with a single key.
    • Record `last_used_at`. It's the first thing anyone asks after a leak, and it makes revoking dormant keys safe.

    The approaches

    Hashed keys in your own database

    // best for: Most APIs. This is the default and it's not much code.

    • PostgreSQL
    • MySQL
    • MongoDB
    • Supabase
    • Express.js
    • Django
    • FastAPI
    • Laravel

    Generate 32 bytes from a cryptographically secure source, encode it, prefix it, show it exactly once. Store SHA-256 of the key plus the prefix fragment and metadata.

    Use SHA-256 rather than bcrypt or argon2 here, which reverses the usual advice for passwords — and the reason matters. Password hashing is deliberately slow to resist brute force against low-entropy human-chosen secrets. An API key is 256 bits of randomness; it is not brute-forceable regardless of hash speed. Using bcrypt adds ~100ms to every API request to defend against an attack that isn't possible, and because bcrypt salts each hash you can't look the key up by hash at all — you'd have to bcrypt-compare against every row.

    SHA-256 is deterministic, so the lookup is a single indexed query on the hash column.

    typescript
    import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
    
    export function issueKey() {
      const secret = randomBytes(32).toString("base64url");
      const key = `vcj_live_${secret}`;
      return {
        key,                                                   // shown once, never stored
        keyHash: createHash("sha256").update(key).digest("hex"),// indexed lookup column
        keyPrefix: key.slice(0, 16),                           // so users can tell keys apart
      };
    }
    
    // Deterministic hash => one indexed query. bcrypt here would force a
    // compare against every row, and defends against a brute force that
    // 256 bits of entropy already rules out.

    The gotcha

    Comparing the presented key against a stored value with `===` after fetching by user ID leaks timing information. Look the key up *by* its hash so the database index does the work, and if you do compare bytes directly, use `timingSafeEqual`.

    Gateway-managed keys

    // best for: Teams already on a gateway who want rate limiting and quotas bundled in.

    • AWS
    • Cloudflare
    • Google Cloud
    • Azure
    • Kong

    API Gateway, Cloudflare, and similar can validate keys before traffic reaches your service, and they bundle per-key rate limiting and usage plans — which is genuinely the tedious part to build well. Your application never sees an invalid key, and abusive traffic is rejected at the edge before it costs you compute.

    The trade is that key lifecycle now lives in a second system. Creating a key means calling the gateway's API, so your signup flow has a new dependency and a new failure mode. Usage data lives in their analytics rather than your database, which makes "show the customer their usage" a cross-system join.

    This is a good fit when rate limiting is a product feature you'd otherwise build, and a poor one when keys are incidental to your product.

    The gotcha

    AWS API Gateway usage plans bind a key to specific stages and methods. Adding a new endpoint doesn't automatically make it available to existing keys, so customers get 403s on a new feature and the cause is invisible from the application logs.

    Scoped keys with embedded permissions

    // best for: APIs where customers need to hand a key to a third party without granting full access.

    • Stripe
    • PostgreSQL
    • Supabase

    Rather than one key per account, keys carry a scope set — `read:invoices`, `write:webhooks`. The customer generates a restricted key for their contractor's integration and sleeps fine. This is what Stripe's restricted keys do, and it's a real trust feature for anything touching money or customer data.

    Implementation is a `scopes` array on the key row, resolved into the request context at authentication time. It composes cleanly with an existing `can()` function from RBAC: the effective permission set becomes the intersection of the key's scopes and the owning user's permissions, which correctly means a restricted key can never exceed its owner's access even if the owner is later demoted.

    The main cost is UX. Scope pickers are hard to design, and defaulting to "all scopes" defeats the purpose while defaulting to none generates support tickets.

    The gotcha

    Forgetting to intersect key scopes with the owner's current permissions. If a key was minted while its owner was an admin and the owner is later demoted, a key checked only against its own stored scopes keeps admin access indefinitely. Always resolve against both.

    Common pitfalls

    Storing keys in plaintext so they can be shown again

    Almost always driven by a product request to redisplay the key. Don't. A database leak becomes a full compromise of every customer's account. Show it once, make regeneration painless, and explain why — every developer who uses APIs already expects this.

    One key per account

    Makes rotation a downtime event: the customer must update every deployment in the instant between generating the new key and the old one dying. Allow several active keys and rotation becomes add, deploy, verify, revoke.

    Accepting keys in query strings

    Query parameters land in server logs, proxy logs, browser history, and `Referer` headers. Take the key in an `Authorization` header. If you must accept a query parameter for a legacy client, log a deprecation and strip it from your access logs.

    No expiry and no usage tracking

    Keys issued in 2023 for a prototype are still live and nobody knows if anything uses them. Record `last_used_at`, surface it in the UI, and support optional expiry. Dormant keys are the ones that leak without anyone noticing.

    Questions people actually ask

    Should API keys expire?

    Offer it, don't force it. Machine-to-machine integrations break unattended when a key expires, and the failure often surfaces at 3am. Optional expiry with advance-warning emails gives security-conscious customers what they need without breaking everyone else's cron jobs.

    API keys or OAuth?

    API keys when the client is the account owner acting on their own data — scripts, servers, CI. OAuth when a third-party application acts on behalf of a user who must consent. Handing your API key to someone else's product is the thing OAuth exists to prevent.

    How do I handle a leaked key?

    Revoke it immediately, then check `last_used_at` and any request logs for that key ID to determine whether it was used and from where. This is why per-key usage tracking matters — without it, you can only tell the customer that you don't know.

    Need someone who's done this before?

    Browse vetted developers who work with these tools day to day.

    Related guides