Skip to main content

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

PitfallCause / SymptomFix
Key stored as plaintextDB breach exposes all keysHash with SHA-256 before storing
Key in URL (query param)Logged by reverse proxies and CDNsPrefer header-based key for sensitive APIs
No key rotation mechanismCompromised keys can't be revokedImplement per-key is_active flag and revoke endpoint
Timing attack on comparison== comparison leaks timing infoUse hmac.compare_digest() for constant-time comparison
No rate limiting per keyOne key can exhaust server resourcesAssociate 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

What's Next