Rotating API Keys Without Downtime
Sooner or later you have to change a customer's API key. A contractor leaves, a key lands in a public repository, or your own security policy says credentials expire after a year. The naive move — generate a new key, overwrite the old row, email the customer — takes every integration they built down until a human notices and redeploys. This page resolves one decision: whether to swap keys atomically or to run two valid keys side by side, and it gives you the schema, the endpoint and the revocation path to do the second one properly. It is part of the Handling API Authentication in Python guide, which covers issuing and verifying credentials in the first place.
The short version: never swap. Overlap. A key is not a value you replace, it is a row with a lifecycle, and rotation is a state transition that two keys live through together.
Why the atomic swap always costs you an outage
Think about where your key actually lives on the customer's side. It is in a Render environment variable, a GitHub Actions secret, a Lambda config, a .env on someone's laptop, and probably pasted into a Postman collection. There is no single place to update, and no way for you to update any of them. The moment you invalidate the old value, every one of those call sites starts returning 401 — and your support inbox fills with the exact confusion described in debugging 401 Unauthorized API errors.
The cost is not just goodwill. If you bill per successful request, a two-hour outage on a customer doing 40 requests per second is roughly 288,000 unbilled calls, and worse, it teaches their engineering team that your API is fragile. Churn after a self-inflicted auth outage is brutal because the customer has a concrete, shareable story about why you broke them.
The overlap approach removes the cliff entirely. You mint the new key while the old one still works, hand the new value to the customer, and let them migrate call site by call site on their own schedule. The old key retires only when it stops receiving traffic, or when its grace deadline passes — whichever comes first.
Store keys so rotation is even possible
You cannot run an overlap window if your database holds one key column on the customer row. Rotation needs keys to be their own table with their own status, and it needs the stored form to be a hash — because the second reason you rotate is that a raw key leaked, and if you store raw keys a database dump leaks all of them at once.
Hashing creates a lookup problem: you cannot index a value you only see hashed at request time. Solve it by splitting the key into a public lookup id and a secret half. The lookup id is indexed and meaningless on its own; the secret half is what you hash and compare. This gives you an O(1) index hit followed by exactly one constant-time comparison, instead of hashing every key in the table.
Use HMAC-SHA-256 with a server-side pepper, not bcrypt. Bcrypt exists to slow down attacks on low-entropy human passwords; a 256-bit random key has no dictionary to attack, and bcrypt's deliberate 50–100 ms cost lands on every single authenticated request. At 200 requests per second that is enough CPU to force a second instance you did not need, which shows up directly in your cost per API request.
# apikeys/mint.py
import hashlib
import hmac
import os
import secrets
KEY_PREFIX = os.getenv("API_KEY_PREFIX", "pk_live")
KEY_PEPPER = os.getenv("API_KEY_PEPPER", "") # 32+ random bytes, from your secret store
def hash_secret(secret_part: str) -> str:
if not KEY_PEPPER:
raise RuntimeError("API_KEY_PEPPER is not set")
return hmac.new(
KEY_PEPPER.encode(), secret_part.encode(), hashlib.sha256
).hexdigest()
def mint_key() -> tuple[str, str, str]:
"""Return (raw_key, lookup_id, key_hash). Persist only the last two."""
lookup_id = secrets.token_hex(6) # 12 public chars, indexed
secret_part = secrets.token_urlsafe(32) # 256 bits of entropy
raw_key = f"{KEY_PREFIX}_{lookup_id}.{secret_part}"
return raw_key, lookup_id, hash_secret(secret_part)
def split_key(raw_key: str) -> tuple[str, str] | None:
match raw_key.split(".", 1):
case [head, secret_part] if head.startswith(f"{KEY_PREFIX}_"):
return head.removeprefix(f"{KEY_PREFIX}_"), secret_part
case _:
return None
The prefix is not decoration. pk_live_ in front of the lookup id lets GitHub's secret scanning and your own log scrubbers recognise your credentials on sight, and it tells a customer at a glance whether they pasted a test key into production.
The table itself is small and boring, which is the point. Every key is a row; a customer with two live keys has two rows.
# apikeys/schema.py
import asyncio
import os
import asyncpg
DDL = """
CREATE TABLE IF NOT EXISTS api_keys (
lookup_id text PRIMARY KEY,
key_hash text NOT NULL,
customer_id text NOT NULL,
status text NOT NULL DEFAULT 'active',
label text,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
last_used_at timestamptz,
revoked_reason text
);
CREATE INDEX IF NOT EXISTS api_keys_customer_live
ON api_keys (customer_id) WHERE status IN ('active', 'retiring');
"""
async def main() -> None:
conn = await asyncpg.connect(os.environ["DATABASE_URL"])
try:
await conn.execute(DDL)
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())
The overlap window as a state machine
Three statuses carry the whole design. active means the key is the customer's current credential. retiring means it still authenticates but is on a clock — every response it serves carries a deadline header so a machine, not just a human, can notice. revoked means dead now, no grace, no appeal.
Rotation is one transaction: insert a new active row, flip the previous active row to retiring with an expires_at. A leak skips the middle state entirely and goes straight to revoked. That distinction is the entire reason to model status as a column instead of just deleting rows — planned rotation and incident response need different behaviour from the same table.
The verification dependency reads the status and decides. Note that a retiring key still returns 200 — it just tells the truth about its deadline in the response headers, so the customer's own monitoring can catch what their inbox missed.
# apikeys/verify.py
import hmac
import os
from datetime import datetime, timezone
from fastapi import HTTPException, Request, Response, status
from .mint import hash_secret, split_key
EXPIRES_HEADER = os.getenv("API_KEY_EXPIRES_HEADER", "X-Api-Key-Expires-At")
async def current_key(request: Request, response: Response) -> dict:
raw = request.headers.get("authorization", "").removeprefix("Bearer ").strip()
parts = split_key(raw)
if parts is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Malformed API key")
lookup_id, secret_part = parts
row = await request.app.state.pool.fetchrow(
"SELECT lookup_id, key_hash, customer_id, status, expires_at "
"FROM api_keys WHERE lookup_id = $1",
lookup_id,
)
if row is None or not hmac.compare_digest(row["key_hash"], hash_secret(secret_part)):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key")
now = datetime.now(timezone.utc)
match (row["status"], row["expires_at"]):
case ("revoked", _):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Key revoked")
case ("retiring", deadline) if deadline is not None and deadline <= now:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Key expired")
case ("retiring", deadline) if deadline is not None:
response.headers[EXPIRES_HEADER] = deadline.isoformat()
response.headers["Warning"] = '299 - "API key retiring, rotation incomplete"'
case _:
pass
return dict(row)
Let the customer rotate themselves
The rotation endpoint is what turns this from an internal runbook into a product feature. A customer who can rotate at 2am without emailing you is a customer who actually rotates, and self-service rotation is table stakes for anyone doing a SOC 2 review of your service. Ship it alongside the rest of your developer portal.
Two rules matter. Authenticate the rotate call with the key being rotated — no separate credential to lose — and cap live keys per customer at two, so nobody accumulates a drawer of forgotten credentials. Return the raw key exactly once, in the response body, and never store it.
# apikeys/routes.py
import os
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from .mint import mint_key
from .verify import current_key
router = APIRouter(prefix="/v1/keys", tags=["keys"])
GRACE_HOURS = int(os.getenv("API_KEY_GRACE_HOURS", "168"))
MAX_LIVE_KEYS = int(os.getenv("API_KEY_MAX_LIVE", "2"))
@router.post("/rotate", status_code=status.HTTP_201_CREATED)
async def rotate(request: Request, key: dict = Depends(current_key)) -> dict:
raw, lookup_id, key_hash = mint_key()
deadline = datetime.now(timezone.utc) + timedelta(hours=GRACE_HOURS)
async with request.app.state.pool.acquire() as conn:
async with conn.transaction():
live = await conn.fetchval(
"SELECT count(*) FROM api_keys "
"WHERE customer_id = $1 AND status IN ('active', 'retiring')",
key["customer_id"],
)
if live >= MAX_LIVE_KEYS:
raise HTTPException(status.HTTP_409_CONFLICT, "Retire a key first")
await conn.execute(
"UPDATE api_keys SET status = 'retiring', expires_at = $2 "
"WHERE lookup_id = $1 AND status = 'active'",
key["lookup_id"], deadline,
)
await conn.execute(
"INSERT INTO api_keys (lookup_id, key_hash, customer_id, status) "
"VALUES ($1, $2, $3, 'active')",
lookup_id, key_hash, key["customer_id"],
)
return {"api_key": raw, "previous_key_expires_at": deadline.isoformat()}
Watch the old key's traffic decay after each rotation — that curve is your real signal for whether the grace window is long enough. Record it from the same event stream you use for logging API usage events to Postgres; a last_used_at touch on the key row costs almost nothing if you batch it.
Revoking a leaked key immediately
Everything above assumes a planned rotation. A leak inverts the priority: breaking the customer's integration is now the correct outcome, because the alternative is an attacker running up their metered usage on your infrastructure. Revocation must be immediate and global.
The trap here is your own cache. Any sane key check caches the verified row — caching API responses with Redis applies just as much to auth lookups, since it is the difference between one database round trip per request and one per minute. A 60-second cache means a revoked key keeps working for up to 60 seconds on every instance. Delete the cache entry in the same call that flips the row, and publish the revocation so instances holding in-process copies drop them too.
# apikeys/revoke.py
import os
import redis.asyncio as redis
CACHE_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CACHE_PREFIX = os.getenv("API_KEY_CACHE_PREFIX", "apikey:")
REVOKE_CHANNEL = os.getenv("API_KEY_REVOKE_CHANNEL", "key-revocations")
async def revoke_now(pool, lookup_id: str, reason: str) -> None:
"""Kill a key everywhere: database row, shared cache, and in-process copies."""
await pool.execute(
"UPDATE api_keys SET status = 'revoked', expires_at = now(), "
"revoked_reason = $2 WHERE lookup_id = $1",
lookup_id, reason,
)
client = redis.from_url(CACHE_URL)
try:
await client.delete(f"{CACHE_PREFIX}{lookup_id}")
await client.publish(REVOKE_CHANNEL, lookup_id)
finally:
await client.aclose()
Emit a structured event on every revocation with the reason and the actor, using the pattern from structured logging with structlog. When a customer asks six months later why their key died, the answer needs to be a log line, not a guess.
When to overlap, and when not to
Overlap is right for essentially every planned rotation. It is wrong when the key is known-compromised, when a customer's contract ends, or when an employee with access to the credential leaves under a cloud. In those cases the failed requests are the feature.
| Situation | Window | Rationale |
|---|---|---|
| Scheduled annual rotation | 30 days | Fits a customer sprint cycle |
| Self-service rotation | 7 days | Default; covers a weekend plus slack |
| Suspicious activity | 1 hour | Enough to redeploy, not to abuse |
| Confirmed leak | None | Revoke instantly, notify after |
Traffic level shifts the default. A customer at ten requests a day may not exercise every code path in a week, so lengthen the window until you have seen zero old-key traffic for at least three of their natural cycles. A customer at a thousand requests a minute proves migration within hours, and holding the old key open beyond that just widens the exposure. Cost is not the constraint here — an extra row and one index entry per customer is free — so pick the window from risk, not from storage.
Migrating from a single plaintext key column
If you already shipped customers.api_key as a plaintext column, the switch takes four steps and no downtime. First, create the api_keys table and start writing new keys to it while verification checks both places, old column first for a miss. Second, backfill: for every existing customer, insert a row with the lookup id derived from the existing key and the hash computed from its secret half — if your current keys have no dot-separated structure, mint fresh keys instead and run the overlap from that moment. Third, announce and rotate: call the rotation flow for each customer, keeping the legacy column valid as the retiring credential. Fourth, once old-key traffic is zero, drop the column.
The same rollout discipline covered in zero-downtime deploys for Python APIs applies: dual-read before you dual-write, and never remove the old path in the same release that adds the new one. Cover both branches with tests before you touch production, using the approach in testing Python APIs with pytest — a rotation bug is an outage that authenticates as your customer.
Builder verdict
Ship the dual-key overlap with a seven-day default and a self-service rotate endpoint, and never write an atomic key swap. The extra work is roughly one table, one endpoint and a status check in the dependency you already have — call it an afternoon. What you get back is the ability to respond to a leaked credential in seconds without arguing about blast radius, a compliance answer that closes enterprise deals, and zero support tickets from planned rotations. The atomic swap looks cheaper only until the first time you use it; one auth outage on a paying customer costs more in refunds, credibility and engineering time than the overlap pattern costs to build twice over. Build it before your first enterprise customer asks, because they will ask, and "we are working on it" is a worse answer than a link to your docs.
FAQ
How long should the grace window be before it becomes a security risk? Seven days is the sweet spot for self-service rotation and thirty for a scheduled policy rotation. Risk scales with the window only if you assume the old key is already compromised — and if you assume that, you should revoke instead of retire. Keep the window, but watch the old key's traffic and close it early once it hits zero.
Does an extra key row per customer cost anything meaningful at scale? No. At 10,000 customers each holding two keys you are storing 20,000 rows of roughly 200 bytes — four megabytes, entirely cached in memory by Postgres. Verification is one index hit either way. The only real cost is the HMAC, which runs in microseconds; if you use bcrypt instead you will pay in extra instances.
What happens to metered billing during the overlap?
Bill the customer, not the key. Attribute usage to customer_id so a rotation never splits an invoice or resets a quota. If you also want per-key visibility for the customer's own debugging, log lookup_id alongside customer_id on each usage event and aggregate on the customer for billing.
Can I force rotation on a schedule without breaking customers?
Yes, if you announce it in-band. Set expires_at at issue time, return it in the response headers on every call, and send a webhook or email at the 30-, 7- and 1-day marks. Customers who ignore all of that will still break, which is why the header matters: their monitoring can fail the build before your API fails their production.
How do I roll this out without a maintenance window? Deploy verification that accepts both the old and the new storage format first, backfill, then rotate customer by customer, then remove the old path in a later release. That is the same staged pattern used when deprecating an API endpoint without breaking customers — never flip reads and writes in one deploy.
Related
Same track:
- Handling API Authentication in Python — the parent guide covering how keys get issued and verified in the first place.
- JWT vs API keys for Python APIs — why revocable keys beat stateless tokens when rotation matters.
- Debugging 401 Unauthorized API errors — what your customers see when a rotation goes wrong.
Other tracks:
- Creating a developer portal for your API — where the self-service rotate button actually lives.
- Logging API usage events to Postgres — the event stream that tells you when an old key is finally idle.