API Key Security
API keys are simpler than JWT for machine-to-machine authentication — no login flow, no token refresh. The client sends a key in a header or query parameter; the server validates it against a database of hashed keys.
Learning Focus
By the end of this lesson you can: implement header-based API key authentication, hash and store keys securely, validate them via a FastAPI dependency, and apply key-based rate limiting.
API Key Schemes
FastAPI provides APIKeyHeader, APIKeyQuery, and APIKeyCookie:
app/dependencies/api_key.py
from fastapi import Security, HTTPException, status
from fastapi.security import APIKeyHeader, APIKeyQuery
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
API_KEY_QUERY = APIKeyQuery(name="api_key", auto_error=False)
Storing Keys Securely
Never store API keys in plaintext. Hash them like passwords:
app/db/models/api_key.py
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func
from app.db.base import Base
class APIKey(Base):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[str] = mapped_column(DateTime, server_default=func.now())
last_used_at: Mapped[str | None] = mapped_column(DateTime, nullable=True)
app/core/api_keys.py
import hashlib
import secrets
def generate_api_key() -> tuple[str, str]:
"""Returns (raw_key, hashed_key). Store only the hash."""
raw = secrets.token_urlsafe(32)
hashed = hashlib.sha256(raw.encode()).hexdigest()
return raw, hashed
def hash_api_key(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
Auth Dependency
app/dependencies/api_key.py
from typing import Annotated
from fastapi import Depends, HTTPException, status, Security
from fastapi.security import APIKeyHeader
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.api_keys import hash_api_key
from app.db.models.api_key import APIKey
from app.db.session import get_db
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=True)
async def get_api_key_user(
api_key: Annotated[str, Security(API_KEY_HEADER)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> APIKey:
key_hash = hash_api_key(api_key)
result = await db.execute(
select(APIKey).where(APIKey.key_hash == key_hash, APIKey.is_active == True)
)
key_record = result.scalar_one_or_none()
if key_record is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired API key",
)
return key_record
APIKeyAuth = Annotated[APIKey, Depends(get_api_key_user)]
Protected Routes
app/routers/data.py
from fastapi import APIRouter
from app.dependencies.api_key import APIKeyAuth
router = APIRouter(prefix="/data", tags=["data"])
@router.get("/export")
async def export_data(key: APIKeyAuth) -> dict:
return {"owner": key.user_id, "data": []}
Key Issuance Endpoint
app/routers/keys.py
from fastapi import APIRouter, status
from pydantic import BaseModel
from app.core.api_keys import generate_api_key, hash_api_key
from app.db.models.api_key import APIKey
router = APIRouter(prefix="/keys", tags=["api-keys"])
class KeyResponse(BaseModel):
key: str # Raw key — shown ONCE at creation
name: str
id: int
@router.post("/", response_model=KeyResponse, status_code=201)
async def create_key(name: str, db: DBSession, user: CurrentUser) -> KeyResponse:
raw, hashed = generate_api_key()
key = APIKey(key_hash=hashed, name=name, user_id=user.id)
db.add(key)
await db.flush()
await db.refresh(key)
return KeyResponse(key=raw, name=name, id=key.id)
warning
Return the raw key only once at creation. After that, it cannot be recovered (only the hash is stored). Tell users to copy it immediately.
Accept Header or Query Parameter
app/dependencies/api_key.py
async def get_api_key_flexible(
header_key: Annotated[str | None, Security(API_KEY_HEADER)] = None,
query_key: Annotated[str | None, Security(API_KEY_QUERY)] = None,
db: Annotated[AsyncSession, Depends(get_db)] = None,
) -> APIKey:
raw_key = header_key or query_key
if raw_key is None:
raise HTTPException(401, "API key required (header X-API-Key or query ?api_key=)")
...
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| Key stored as plaintext | DB breach exposes all keys | Hash with SHA-256 before storing |
| Key in URL (query param) | Logged by reverse proxies and CDNs | Prefer header-based key for sensitive APIs |
| No key rotation mechanism | Compromised keys can't be revoked | Implement per-key is_active flag and revoke endpoint |
| Timing attack on comparison | == comparison leaks timing info | Use hmac.compare_digest() for constant-time comparison |
| No rate limiting per key | One key can exhaust server resources | Associate rate limit counters with key ID in Redis |
Hands-On Practice
test-api-keys.sh
# 1) Create an API key (requires JWT auth)
TOKEN="<your-jwt-token>"
KEY_RESPONSE=$(curl -s -X POST "http://localhost:8000/keys/?name=my-integration" \
-H "Authorization: Bearer $TOKEN")
RAW_KEY=$(echo $KEY_RESPONSE | jq -r '.key')
echo "API Key: $RAW_KEY"
# 2) Use the key in a header
curl http://localhost:8000/data/export \
-H "X-API-Key: $RAW_KEY"
# 3) Try with an invalid key
curl http://localhost:8000/data/export \
-H "X-API-Key: invalid-key-here" \
-w "\nHTTP %{http_code}"
# → 401