API Key Management Done Right: Rotation, Scoping, and Revocation That Won’t Wake You Up at 3AM

Most developers treat API keys the same way they treat fire extinguishers — slap them in once, forget about them, and pray nothing catches fire. Then a junior dev accidentally commits a .env file, a key leaks, and you’re spending Saturday morning rotating credentials across fourteen services while customers scream.

This article is about not being that guy. We’ll cover how to scope API keys so a leaked credential does minimal damage, how to rotate them without downtime, and how to build a revocation pipeline that actually works under pressure.

The patterns here apply whether you’re managing keys for third-party APIs (Stripe, SendGrid, AWS) or issuing keys from your own service to your own clients. Both sides of the table have the same failure modes.


Why the Default Pattern Is a Disaster

The default pattern looks like this: one long-lived key, full permissions, stored in a .env file, copy-pasted between staging and production, never rotated. This is not a hypothetical — this is what’s running in most startups right now.

The blast radius of a compromised key in this setup is total. An attacker who finds your Stripe secret key can issue refunds, pull customer data, and create phantom subscriptions. An attacker who grabs your AWS root access key can spin up $50k worth of GPU instances before you notice. Real incidents, real numbers.

Three principles fix the majority of these problems:

Scope: A key should only do what it needs to do, in the context it needs to do it.
Rotation: Keys should have a defined lifespan. Treating them as permanent is how you accumulate unauditable surface area.
Revocation: You need to be able to kill a key in under a minute, without touching anything else.

Let’s build each of these properly.


Scoping: Least Privilege Is Not Optional

Scoping is the process of constraining what a key can do. It’s the first line of defense, because a perfectly scoped key does almost nothing useful to an attacker even if it leaks.

Dimensions of Scope

Permission scope: Read vs. write vs. admin. Your analytics dashboard doesn’t need write access. Your backup job doesn’t need the ability to delete records. Segment your permissions granularly.

Resource scope: Restrict which resources a key can touch. An AWS IAM policy that allows s3:GetObject on arn:aws:s3:::my-uploads-bucket/* is fundamentally different from one that allows s3:* on *. The former leaks? Attacker gets your uploaded files. The latter leaks? They own your entire S3 footprint.

IP/network scope: Many API providers let you restrict key usage to specific IP ranges. If your backend always calls from a fixed egress IP, lock the key to it. This is a free revocation mechanism — the key stops working the moment traffic comes from an unexpected source.

Time scope: Set expiration dates on keys. Temporary access should use temporary keys. Most modern providers (AWS STS, GitHub fine-grained tokens, GCP service accounts) support this natively.

Practical Scoping by Use Case

Here’s a real pattern I use when issuing API keys from my own service. Each key gets a JSON metadata envelope stored alongside it:

{
  "key_id": "ak_live_a1b2c3d4e5",
  "owner_id": "user_9871",
  "scopes": ["reports:read", "webhooks:write"],
  "resource_filter": {
    "organization_ids": ["org_42"]
  },
  "expires_at": "2026-08-25T00:00:00Z",
  "ip_allowlist": [],
  "created_at": "2026-05-25T10:00:00Z",
  "last_used_at": null
}

When the key comes in on a request, you check scopes against the route, check resource_filter against the requested resource, and check expires_at against now(). Reject anything that doesn’t match. This is middleware-level logic that runs before any business logic touches the request.

The scope strings (reports:read, webhooks:write) should be defined in a central registry, not scattered across route handlers. This makes auditing straightforward.


Rotation: Keys Should Have a Heartbeat

Rotation is uncomfortable because it feels risky. You’re touching a running system and changing credentials that live in multiple places. Teams avoid it, and so keys accumulate age. A key that’s three years old has been seen by dozens of people, existed through multiple infra changes, and probably lived in a CI environment that no longer has the original access controls.

The goal is to make rotation boring and automatic.

The Overlap Window Pattern

The most common rotation mistake is hard cutover — you generate a new key, immediately invalidate the old one, and discover three services haven’t been updated yet. Hard cutover causes downtime.

The correct pattern is an overlap window:

  1. Generate new key
  2. Both old and new keys are valid (overlap window: 15 minutes to a few days depending on your environment)
  3. Deploy updated secrets to all consumers
  4. Verify traffic is moving through the new key (check last_used_at on the old key)
  5. Revoke old key

This turns a risky swap into two safe steps: issue, then verify, then revoke. The overlap window means a missed deployment doesn’t bring anything down.

Automated Rotation with Vault and Docker

If you’re running HashiCorp Vault (github.com/hashicorp/vault), it handles the overlap window for you with dynamic secrets. For third-party APIs that don’t support dynamic issuance, here’s a rotation script pattern that enforces the window:

#!/usr/bin/env bash
# rotate-api-key.sh
# Rotates a stored API key with overlap window enforcement
set -euo pipefail

KEY_NAME="${1:?Usage: rotate-api-key.sh <key-name>}"
OVERLAP_SECONDS="${2:-3600}"  # Default: 1h overlap window

VAULT_ADDR="${VAULT_ADDR:?VAULT_ADDR not set}"
VAULT_TOKEN="${VAULT_TOKEN:?VAULT_TOKEN not set}"

OLD_KEY=$(vault kv get -field=value "secret/api-keys/${KEY_NAME}")
OLD_KEY_SET_AT=$(vault kv metadata get "secret/api-keys/${KEY_NAME}" \
  | grep "created_time" | awk '{print $2}')

echo "[rotate] Current key set at: ${OLD_KEY_SET_AT}"
echo "[rotate] Generating new key for: ${KEY_NAME}"

# --- Replace this block with your actual key generation logic ---
NEW_KEY=$(curl -sf -X POST "https://api.example.com/v1/keys" \
  -H "Authorization: Bearer ${OLD_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"scope": "read", "ttl": "90d"}' \
  | jq -r '.key')
# ---------------------------------------------------------------

echo "[rotate] Writing new key to Vault"
vault kv put "secret/api-keys/${KEY_NAME}" value="${NEW_KEY}"

echo "[rotate] Overlap window: ${OVERLAP_SECONDS}s — old key still valid"
echo "[rotate] Sleeping through overlap window..."
sleep "${OVERLAP_SECONDS}"

echo "[rotate] Revoking old key"
# --- Replace with your revocation API call ---
curl -sf -X DELETE "https://api.example.com/v1/keys/${OLD_KEY}" \
  -H "Authorization: Bearer ${NEW_KEY}"
# -------------------------------------------

echo "[rotate] Done. New key is live, old key revoked."

Schedule this with cron or a CI pipeline. The important part: the revocation call happens after the overlap window, not immediately after generating the new key.

Docker Compose: Secrets over Environment Variables

For services running in Docker Compose, stop putting keys in environment variables inside docker-compose.yml. Use Docker secrets:

# docker-compose.yml
version: "3.9"

services:
  api:
    image: myapp:latest
    secrets:
      - stripe_secret_key
      - sendgrid_api_key
    environment:
      # Point to the secret mount path, not the value itself
      STRIPE_KEY_FILE: /run/secrets/stripe_secret_key
      SENDGRID_KEY_FILE: /run/secrets/sendgrid_api_key

secrets:
  stripe_secret_key:
    external: true          # Managed outside compose, e.g., via Docker Swarm or Vault agent
  sendgrid_api_key:
    external: true

Your application reads the key from the file at runtime:

# Python example — read secret from file path in env
import os

def load_secret(env_var: str) -> str:
    path = os.environ.get(env_var)
    if path and os.path.isfile(path):
        with open(path) as f:
            return f.read().strip()
    # Fallback to direct env var for local dev
    return os.environ[env_var.removesuffix("_FILE")]

This pattern means rotating a secret is a file swap at the orchestration layer, not a container rebuild. No secret ever hits your compose file, your shell history, or your CI logs.


Revocation: When Everything Goes Wrong

Revocation is the emergency brake. It needs to work fast and reliably, because you’ll be pulling it at the worst possible moment.

Make Revocation a First-Class Feature

If you’re issuing keys from your own service, revocation is not an afterthought. Design it from day one. The data model should have a revoked_at timestamp and an optional revocation_reason string. Revoked keys return 401 immediately — no grace period, no overlap window. That’s the difference between rotation (planned, gradual) and revocation (emergency, immediate).

-- Minimal key table schema
CREATE TABLE api_keys (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  key_hash    BYTEA NOT NULL UNIQUE,   -- Never store plaintext
  owner_id    UUID NOT NULL REFERENCES users(id),
  scopes      TEXT[] NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at  TIMESTAMPTZ,
  revoked_at  TIMESTAMPTZ,
  revocation_reason TEXT,
  last_used_at TIMESTAMPTZ
);

-- Index for fast lookup on inbound requests
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL;

The WHERE revoked_at IS NULL partial index means your hot path (validating incoming keys) never scans revoked keys. Revocation is instantaneous from the database perspective.

The Cache Problem

Here’s where teams get burned. They add a Redis cache in front of key validation to reduce database load:

Request → check Redis cache → miss → check DB → write to cache (TTL 5min) → return

Now you revoke a key. The database is updated. But Redis still has the cached valid result. For the next five minutes, that revoked key still works. During a security incident, five minutes is an eternity.

Fix this with an invalidation event. When a key is revoked, publish to a Redis pub/sub channel:

# On revocation
await redis.publish("key_revocations", key_id)

# In your validation middleware — runs once at startup
async def listen_for_revocations():
    pubsub = redis.pubsub()
    await pubsub.subscribe("key_revocations")
    async for message in pubsub.listen():
        if message["type"] == "message":
            key_id = message["data"].decode()
            await redis.delete(f"apikey:{key_id}")  # Nuke the cache entry

Now revocation propagates in milliseconds, not minutes. This pattern works across horizontally scaled API instances as long as they’re all subscribed to the same channel.

Alternatively: keep the cache TTL at 30 seconds and accept that as your revocation latency. Simpler to implement, and for most threat models, 30 seconds is acceptable.

Panic Revocation: Bulk Operations

When a key store is compromised (database dump, .env file exposed in a public repo, misconfigured S3 bucket), you need to revoke everything for a specific owner or scope, fast.

async def revoke_all_for_owner(
    owner_id: str,
    reason: str,
    db: AsyncSession,
    redis: Redis
) -> int:
    """Bulk revoke all active keys for an owner. Returns revoked count."""
    result = await db.execute(
        update(ApiKey)
        .where(
            ApiKey.owner_id == owner_id,
            ApiKey.revoked_at.is_(None)
        )
        .values(revoked_at=datetime.utcnow(), revocation_reason=reason)
        .returning(ApiKey.id)
    )
    revoked_ids = result.scalars().all()

    # Flush cache for every revoked key
    if revoked_ids:
        await redis.delete(*[f"apikey:{kid}" for kid in revoked_ids])
        # Broadcast revocations to all instances
        for kid in revoked_ids:
            await redis.publish("key_revocations", str(kid))

    return len(revoked_ids)

This should be accessible from an admin panel and from a CLI. When a developer is panicking at 2AM, they need a single command, not a SQL prompt.


Gotchas

Gotcha: Hashing keys wrong. Store a hash of the key, never the plaintext. But don’t use SHA-256 alone — it’s too fast, and a leaked key database becomes trivially crackable offline. Use bcrypt or Argon2 for the stored hash, and keep the plaintext only in the initial issuance response. If the user loses the key, they get a new one.

Wait — bcrypt for API key lookup? That’s too slow for request-path validation. Right. The pattern is: store SHA-256(key) for fast lookup in the index, AND store bcrypt(key) for offline breach resistance. On request validation, you do the fast lookup only. The bcrypt hash protects you if someone dumps your database, because iterating through billions of possible key values against bcrypt hashes is computationally expensive.

Gotcha: Logging the key in plaintext. Access logs, debug logs, and error traces will happily print your full key if it appears in a URL query parameter or an Authorization header that gets dumped on error. Audit your logging middleware. Keys in headers should be truncated in logs (Bearer ak_live_...a1b2). Keys should never appear as URL parameters — that pattern is legacy and broken.

Gotcha: No key metadata. "Which key is this?" should be answerable instantly. Every key needs a human-readable label, a creation timestamp, and a last-used timestamp. Teams that store raw hashes with no metadata can’t answer "which service is using this key" when they need to rotate it. You end up with orphaned keys nobody dares revoke because they don’t know what breaks.

Gotcha: Sharing keys between environments. Staging should have staging keys. Production should have production keys. They should never be the same value. An overly helpful developer who copies prod keys to staging to "just make it work" has now exposed production credentials to staging’s weaker security perimeter. Enforce this at the CI level — if a known prod key appears in a non-prod deploy, fail the pipeline.

Gotcha: Infinite TTL as default. If your issuance endpoint creates keys with no expiration by default, you will accumulate undead keys forever. Force a maximum TTL at issuance time. If a client genuinely needs a long-lived key, make them explicitly request it and flag it for review. The default should be 90 days.


Production Checklist

Before you ship any key management system, verify:

  • Keys are stored as hashes, never plaintext
  • Revocation is reflected in under 60 seconds for all active instances
  • Rotation supports an overlap window; hard cutover is not possible by default
  • Every key has an expires_at with a max enforced TTL
  • Bulk revocation by owner/scope is a one-command operation
  • Key values are scrubbed from all log outputs
  • Staging and production use separate key namespaces
  • A last_used_at field is updated on each use (async write, don’t block the request)
  • Unused keys older than N days trigger a cleanup or alert
  • The issuance endpoint requires authentication and logs every issuance with actor identity

The last point matters more than people think. You need to know who created a key, not just that the key exists. When a breach happens, the audit trail of issuances is often the first place you look.


The Broader Principle

API key management is a microcosm of secrets management in general. The patterns — scope narrowly, rotate often, revoke fast, never store plaintext — apply equally to database passwords, JWT signing keys, TLS private keys, and OAuth client secrets.

The teams that handle incidents well are the ones who built revocation as infrastructure, not an afterthought. When a key leaks, you want your biggest problem to be the post-mortem writeup, not figuring out where the key was used and how to kill it without taking down production.

Build the boring plumbing now. Your future 3AM self will thank you.

👁 Views: 112,742 · Unique visitors: 45,402