Your API is live. A partner company wants to integrate. They do not have users logging in through a browser — they have a cron job that calls your endpoints at 3 AM to sync inventory data. OAuth is overkill. Session cookies make no sense. They need a key they can put in an environment variable and use forever.
API keys are the simplest authentication method for machine-to-machine communication. They are also the most misused. Most implementations are a random string stored in plaintext with no scoping, no rotation, and no rate limiting. When that key leaks — and keys always leak — the attacker has full access to everything, forever.
This post builds API key authentication properly: cryptographic generation, hashed storage, granular scoping, zero-downtime rotation, and per-key rate limiting.
When to Use API Keys
API keys are the right choice when:
- The client is a machine, not a human (no interactive login possible)
- You need long-lived credentials (weeks to months, not minutes)
- The integration is first-party or trusted third-party (you know who the client is)
- You want per-client rate limiting and usage tracking
API keys are the wrong choice when:
- The client is a human user (use passwords, JWT, or SSO)
- You need delegated access (“User X authorized App Y to read their data”) — use OAuth 2.0
- The key would be exposed in a browser (frontend code, mobile app bundles) — use OAuth with PKCE
Key Generation
An API key must be unpredictable — a random UUID or an incrementing integer is not sufficient. Use cryptographically secure random bytes:
import hashlibimport secrets
def generate_api_key() -> tuple[str, str]: """Generate an API key and its hash.
Returns: (plaintext_key, hashed_key) — show plaintext to the user ONCE, store only the hash. """ # 32 bytes = 256 bits of entropy, URL-safe encoding raw_key = secrets.token_urlsafe(32)
# Prefix for easy identification in logs and key rotation plaintext = f"cdk_{raw_key}" # "cdk" = CloudDocs Key
# Hash for storage — SHA-256 is fine here (not a password) hashed = hashlib.sha256(plaintext.encode()).hexdigest()
return plaintext, hashedWhy SHA-256 and Not bcrypt?
For passwords, we use slow hashes (bcrypt, Argon2) because passwords have low entropy — humans pick predictable strings. API keys have 256 bits of entropy — brute-forcing SHA-256 on a 256-bit input is computationally infeasible regardless of hash speed. The fast hash also means key validation adds negligible latency to every request.
The Prefix Convention
The cdk_ prefix serves three purposes:
- Identification: In logs, config files, or CI secrets, you can immediately tell what a string is
- Rotation detection: When scanning for leaked keys (GitHub secret scanning), the prefix is the search pattern
- Versioning: If you change the key format, use a new prefix (
cdk2_) without breaking existing keys
Stripe uses sk_live_, GitHub uses ghp_, Anthropic uses sk-ant-. Adopt this pattern.
Data Model
from __future__ import annotationsfrom datetime import datetimefrom uuid import UUID, uuid4
from sqlalchemy import ForeignKey, String, DateTime, JSON, funcfrom sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class APIKey(Base): __tablename__ = "api_keys"
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4) name: Mapped[str] = mapped_column(String(100)) # Human-readable label hashed_key: Mapped[str] = mapped_column(String(64), unique=True, index=True) key_prefix: Mapped[str] = mapped_column(String(12)) # First 8 chars for display: "cdk_a1b2..." scopes: Mapped[dict] = mapped_column(JSON, default=list) # ["documents:read", "documents:write"] owner_id: Mapped[UUID] = mapped_column(ForeignKey("users.id")) is_active: Mapped[bool] = mapped_column(default=True) expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner: Mapped["User"] = relationship(back_populates="api_keys")Key design decisions:
hashed_key: Never store the plaintext. The full key is shown exactly once — at creation.key_prefix: Store the first 8 characters so users can identify which key is which in the dashboard (“ends with …a1b2c3”).scopes: JSON array of permission strings. Granular access control per key.expires_at: Optional expiry. Forces rotation by making keys time-limited.last_used_at: Track stale keys. If a key has not been used in 90 days, it is a revocation candidate.
Key Creation Endpoint
from fastapi import APIRouter, Depends, HTTPException, statusfrom src.core.api_keys import generate_api_keyfrom src.api.deps import get_current_user
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
VALID_SCOPES = { "documents:read", "documents:write", "documents:delete", "users:read", "users:write", "analytics:read",}
@router.post("/", status_code=status.HTTP_201_CREATED)async def create_api_key( body: CreateAPIKeyRequest, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db),): # Validate scopes invalid = set(body.scopes) - VALID_SCOPES if invalid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid scopes: {invalid}", )
plaintext, hashed = generate_api_key()
api_key = APIKey( name=body.name, hashed_key=hashed, key_prefix=plaintext[:12], scopes=body.scopes, owner_id=user.id, expires_at=body.expires_at, ) db.add(api_key) await db.commit()
# ⚠️ This is the ONLY time the full key is returned return { "id": str(api_key.id), "name": api_key.name, "key": plaintext, # Show once, never again "scopes": api_key.scopes, "expires_at": api_key.expires_at, "warning": "Store this key securely. It will not be shown again.", }Key Validation Middleware
import hashlibfrom fastapi import Depends, HTTPException, Security, statusfrom fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_api_key_user( api_key: str | None = Security(api_key_header), db: AsyncSession = Depends(get_db),) -> tuple[User, APIKey]: if not api_key: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key", )
# Hash the provided key and look up hashed = hashlib.sha256(api_key.encode()).hexdigest()
result = await db.execute( select(APIKey) .where(APIKey.hashed_key == hashed, APIKey.is_active == True) .options(joinedload(APIKey.owner)) ) key_record = result.scalar_one_or_none()
if not key_record: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
# Check expiry if key_record.expires_at and key_record.expires_at < datetime.now(timezone.utc): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="API key expired")
# Update last_used_at (fire and forget — do not block the request) key_record.last_used_at = datetime.now(timezone.utc) await db.commit()
return key_record.owner, key_recordScope Checking
def require_scope(required: str): """Dependency factory that checks if the API key has a specific scope."""
async def check_scope( auth: tuple[User, APIKey] = Depends(get_api_key_user), ) -> User: user, key = auth if required not in key.scopes: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"API key missing required scope: {required}", ) return user
return check_scope
# Usage in routes@router.get("/documents")async def list_documents(user: User = Depends(require_scope("documents:read"))): ...
@router.post("/documents")async def create_document(user: User = Depends(require_scope("documents:write"))): ...Zero-Downtime Key Rotation
Key rotation is inevitable — compliance requirements, employee departures, suspected leaks. The naive approach is: revoke the old key, issue a new one. The problem: there is a window where the client has the old (revoked) key and has not yet deployed the new one. Requests fail.
The solution: overlapping validity.
@router.post("/{key_id}/rotate")async def rotate_api_key( key_id: UUID, body: RotateKeyRequest, # grace_period_hours: int = 24 user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db),): old_key = await db.get(APIKey, key_id) if not old_key or old_key.owner_id != user.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# Generate replacement key with same scopes plaintext, hashed = generate_api_key() new_key = APIKey( name=f"{old_key.name} (rotated)", hashed_key=hashed, key_prefix=plaintext[:12], scopes=old_key.scopes, owner_id=user.id, expires_at=old_key.expires_at, ) db.add(new_key)
# Set grace period on old key — it will be deactivated after old_key.expires_at = datetime.now(timezone.utc) + timedelta(hours=body.grace_period_hours)
await db.commit()
return { "new_key": plaintext, "new_key_id": str(new_key.id), "old_key_expires_at": old_key.expires_at.isoformat(), "warning": "Deploy the new key before the grace period ends.", }Per-Key Rate Limiting
Different clients have different usage patterns. A monitoring integration making 10 requests/minute should not share a rate limit with a data pipeline making 1,000 requests/minute.
import redis.asyncio as redisfrom fastapi import HTTPException, status
# Default limits — override per key in the databaseDEFAULT_RATE_LIMIT = 100 # requestsDEFAULT_RATE_WINDOW = 60 # per 60 seconds
async def check_api_key_rate_limit( key_id: str, redis_client: redis.Redis, limit: int = DEFAULT_RATE_LIMIT, window: int = DEFAULT_RATE_WINDOW,): redis_key = f"api_rate:{key_id}" current = await redis_client.incr(redis_key)
if current == 1: await redis_client.expire(redis_key, window)
if current > limit: ttl = await redis_client.ttl(redis_key) raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Rate limit exceeded", headers={ "Retry-After": str(ttl), "X-RateLimit-Limit": str(limit), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(ttl), }, )
return { "limit": limit, "remaining": max(0, limit - current), "reset": await redis_client.ttl(redis_key), }API Keys vs. Other Auth Methods
| Dimension | API Keys | JWT | OAuth Client Credentials |
|---|---|---|---|
| Client type | Trusted machines | Any (human or machine) | Third-party machines |
| Lifetime | Long (weeks-months) | Short (minutes-hours) | Medium (hours) |
| Revocation | Instant (DB lookup) | Delayed (until expiry) | Instant (revoke grant) |
| Scoping | Per-key scopes | Per-token claims | OAuth scopes |
| Rotation | Manual (with grace period) | Automatic (refresh flow) | Automatic (grant flow) |
| Complexity | Low | Medium | High |
| Best for | Internal integrations, partners | SPAs, mobile, microservices | Third-party API access |
What’s Next
In Part 5, we tackle the most complex — and most misunderstood — auth standard: OAuth 2.0. You will learn the four grant types, why PKCE replaced the implicit flow, and how to implement the authorization code flow in FastAPI.