Tutorial

FastAPI Auth: API Key Authentication for Machine Clients

API keys are the most misused auth mechanism in production. Here is how to generate, scope, rotate, and rate-limit API keys in FastAPI — with the patterns that separate toy projects from production systems.

Tin Dang avatar
Tin Dang
A labeled key ring with multiple keys of different sizes and colors representing scoped API keys

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:

src/core/api_keys.py
import hashlib
import 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, hashed

Why 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:

  1. Identification: In logs, config files, or CI secrets, you can immediately tell what a string is
  2. Rotation detection: When scanning for leaked keys (GitHub secret scanning), the prefix is the search pattern
  3. 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

src/models/api_key.py
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import ForeignKey, String, DateTime, JSON, func
from 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

src/api/v1/api_keys.py
from fastapi import APIRouter, Depends, HTTPException, status
from src.core.api_keys import generate_api_key
from 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

src/api/deps.py
import hashlib
from fastapi import Depends, HTTPException, Security, status
from 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_record

Scope 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.

src/middleware/api_key_rate_limit.py
import redis.asyncio as redis
from fastapi import HTTPException, status
# Default limits — override per key in the database
DEFAULT_RATE_LIMIT = 100 # requests
DEFAULT_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

DimensionAPI KeysJWTOAuth Client Credentials
Client typeTrusted machinesAny (human or machine)Third-party machines
LifetimeLong (weeks-months)Short (minutes-hours)Medium (hours)
RevocationInstant (DB lookup)Delayed (until expiry)Instant (revoke grant)
ScopingPer-key scopesPer-token claimsOAuth scopes
RotationManual (with grace period)Automatic (refresh flow)Automatic (grant flow)
ComplexityLowMediumHigh
Best forInternal integrations, partnersSPAs, mobile, microservicesThird-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.

0

Next in this series

FastAPI Auth: OAuth 2.0 — The Authorization Framework

Continue reading