6. API key authentication

API keys are the simplest way to authenticate API clients. Generate them securely, store them hashed, verify them in a dependency, and you've got endpoint-level auth.

Why APIs need authentication

A public endpoint without authentication can't tell one caller from another, which means it can't track per-user usage, enforce rate limits, or revoke access when someone abuses it. Authentication is the seam that turns "a request" into "a request from someone", and that one piece of identity unlocks all three.

API keys answer three questions per request: identification (who is calling?), access control (are they allowed to call this endpoint?), and usage tracking (how many requests have they made in the current window?). Keys are not the only way to answer them; OAuth and signed JWTs answer the same questions with more ceremony. But for an API where the caller is another script or service, keys are the simplest credential that does the job.

You've been on the other side of this flow since Chapter 3: register with NewsAPI, paste the key into a header, every request carries it. The system that issued that key is what we're building now.

Generating and storing API keys

API keys are credentials like passwords. Store them hashed, never in plain text. When users generate keys, you show the key once, they copy it, and you store only the hash. If your database leaks, attackers get hashes, not working keys.

auth.py
import secrets
import hashlib
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from database import APIKey as DBAPIKey


def generate_api_key() -> str:
    """Generate a random API key."""
    return secrets.token_urlsafe(32)  # 32 bytes = 256 bits of randomness


def hash_api_key(api_key: str) -> str:
    """Hash an API key using SHA-256."""
    return hashlib.sha256(api_key.encode()).hexdigest()


def create_api_key(db: Session, name: str, tier: str = "basic") -> dict:
    """
    Create a new API key.
    Returns dict with key and id. Store the hash, return the key once.
    """
    # Generate random key
    raw_key = generate_api_key()
    key_hash = hash_api_key(raw_key)
    
    # Store hash in database
    db_key = DBAPIKey(
        key_hash=key_hash,
        name=name,
        rate_limit_tier=tier,
        is_active=True
    )
    db.add(db_key)
    db.commit()
    db.refresh(db_key)
    
    # Return the raw key ONCE - user must save it
    return {
        "api_key": raw_key,  # Show this once, never again
        "key_id": db_key.id,
        "name": name,
        "tier": tier,
        "created_at": db_key.created_at
    }


def validate_api_key(db: Session, api_key: str) -> DBAPIKey | None:
    """
    Validate an API key and return the associated key object.
    Returns None if invalid or inactive.
    """
    key_hash = hash_api_key(api_key)
    
    # Find key by hash
    db_key = db.query(DBAPIKey).filter(
        DBAPIKey.key_hash == key_hash,
        DBAPIKey.is_active == True
    ).first()
    
    if db_key:
        # Update last used timestamp
        db_key.last_used_at = datetime.now(timezone.utc)
        db.commit()
    
    return db_key

secrets.token_urlsafe(32) is what makes the key actually secret: 256 bits of entropy from a CSPRNG, encoded in URL-safe characters so it can travel in headers without further escaping. SHA-256 is one-way; the server can verify a submitted key by re-hashing it and comparing, but a database dump leaves an attacker with hashes they can't reverse back into working credentials.

The lifecycle follows from those two pieces. create_api_key generates the raw key, hashes it on the next line, persists only the hash, and returns the raw key in the response. That single HTTP response is the user's only opportunity to see the credential. validate_api_key runs on every authenticated request: hash the submitted key, look it up, return the row if it exists and is active, and bump last_used_at so the analytics side has something to chart.

The require-key dependency

FastAPI dependencies run before the route handler. A dependency that pulls the Authorization header, validates the key against the database, and raises 401 on any failure becomes a one-liner to add to any protected route. The route body never runs without a verified key in hand.

auth.py (extended)
# Add to auth.py's existing imports:
from fastapi import Depends, Header, HTTPException
from database import get_db


def require_api_key(
    authorization: str | None = Header(None),
    db: Session = Depends(get_db)
) -> DBAPIKey:
    """
    Dependency that validates API key from Authorization header.
    Raises 401 if key is missing or invalid.
    """
    if not authorization:
        raise HTTPException(
            status_code=401,
            detail="Missing Authorization header",
            headers={"WWW-Authenticate": "Bearer"}
        )

    # Extract Bearer token
    if not authorization.startswith("Bearer "):
        raise HTTPException(
            status_code=401,
            detail="Invalid Authorization format. Use: Bearer YOUR_API_KEY"
        )

    api_key = authorization.removeprefix("Bearer ")

    # Validate key
    db_key = validate_api_key(db, api_key)
    if not db_key:
        raise HTTPException(
            status_code=401,
            detail="Invalid or inactive API key"
        )

    return db_key

require_api_key belongs beside the key-validation logic in auth.py. It pulls the Authorization header (a missing one is the first 401), enforces the Bearer YOUR_API_KEY shape that GitHub, Stripe, and most modern APIs use (malformed value is the second 401), and hashes-and-looks-up the key via validate_api_key (unknown or revoked key is the third). Each failure carries its own detail string.

Now import that dependency into main.py and apply it to the protected route:

main.py (extended)
# Add to your existing imports:
from fastapi import Query
from database import APIKey as DBAPIKey
from auth import require_api_key


# Protected endpoints (require authentication)
@app.get("/articles", response_model=ArticleListResponse)
def list_articles(
    category: str | None = None,
    source: str | None = None,
    limit: int = Query(20, ge=1, le=100),
    api_key: DBAPIKey = Depends(require_api_key),
    db: Session = Depends(get_db)
):
    # Authentication succeeded - api_key contains validated key object
    query = db.query(DBArticle)

    if category:
        query = query.filter(DBArticle.category == category)
    if source:
        query = query.filter(DBArticle.source == source)

    articles = query.limit(limit).all()
    return {"articles": articles, "count": len(articles)}

Adding the dependency to a route is one parameter: api_key: DBAPIKey = Depends(require_api_key). FastAPI resolves it before calling the route body, so by the time list_articles runs, authentication has already succeeded and the validated key object is in scope for per-user filtering, audit logging, or the rate-limit lookup the next section adds.

Test authentication:

Terminal
# Request without authentication
curl http://localhost:8000/articles
# Response: {"detail":"Missing Authorization header"}

# Request with invalid key
curl -H "Authorization: Bearer invalid_key" http://localhost:8000/articles
# Response: {"detail":"Invalid or inactive API key"}

# First, generate a valid key (requires admin endpoint)
curl -X POST http://localhost:8000/admin/api-keys \
  -H "X-Admin-Key: dev-admin-key-change-me" \
  -H "Content-Type: application/json" \
  -d '{"name": "Test Key", "tier": "basic"}'
# Response: {"api_key":"xQz4K8m_NpD1...","key_id":1}

# Request with valid key
curl -H "Authorization: Bearer xQz4K8m_NpD1..." http://localhost:8000/articles
# Response: {"articles": [...], "count": 10}

The three failure modes (no header, malformed header, unknown key) each surface as a 401 with a distinct detail string, so a caller debugging an integration knows exactly which one to fix.

Admin endpoints for key management

Create admin endpoints for generating and revoking API keys. They use a separate admin credential from ADMIN_API_KEY, sent in an X-Admin-Key header, so a public API key cannot mint more keys.

main.py (extended)
# Add to your existing imports:
import os
from fastapi import Header
from auth import create_api_key


def require_admin_key(x_admin_key: str | None = Header(None)) -> str:
    """Require the server-side admin key for key-management endpoints."""
    expected_key = os.getenv("ADMIN_API_KEY")
    if not expected_key:
        raise HTTPException(status_code=500, detail="ADMIN_API_KEY is not configured")
    if x_admin_key != expected_key:
        raise HTTPException(status_code=403, detail="Invalid admin key")
    return x_admin_key


class APIKeyCreate(BaseModel):
    """Request body for creating API keys."""
    name: str
    tier: str = "basic"


class APIKeyResponse(BaseModel):
    """Response with API key (shown once)."""
    api_key: str
    key_id: int
    name: str
    tier: str
    created_at: datetime


@app.post("/admin/api-keys", response_model=APIKeyResponse, status_code=201)
def create_api_key_endpoint(
    key_data: APIKeyCreate,
    admin_key: str = Depends(require_admin_key),
    db: Session = Depends(get_db)
):
    """Generate a new API key. Returns key once - user must save it.

    The route is named create_api_key_endpoint (not generate_api_key) to avoid
    shadowing the generate_api_key() helper from auth.py at import time.
    """
    result = create_api_key(
        db=db,
        name=key_data.name,
        tier=key_data.tier
    )
    return result


@app.delete("/admin/api-keys/{key_id}", status_code=204)
def revoke_api_key(
    key_id: int,
    admin_key: str = Depends(require_admin_key),
    db: Session = Depends(get_db)
):
    """Revoke an API key (mark as inactive)."""
    db_key = db.query(DBAPIKey).filter(DBAPIKey.id == key_id).first()

    if not db_key:
        raise HTTPException(status_code=404, detail="API key not found")

    db_key.is_active = False
    db.commit()

    return None  # 204 responses have no body

The generate endpoint returns the freshly-minted key inside APIKeyResponse. That JSON payload is the single moment the raw key exists outside secrets.token_urlsafe; if the user closes the tab before copying it, the key is gone, and the only path forward is to revoke it and create a fresh one. The endpoint's documentation should say so loudly.

The revoke endpoint flips is_active to False rather than calling db.delete. That leaves the row in place so usage history, last-seen timestamps, and audit trails survive the revocation. A hard delete would lose all of that and make a leaked-key incident harder to investigate.

The admin endpoints use a deliberately small guard: the caller must provide the server-side ADMIN_API_KEY in X-Admin-Key. For a larger product you might replace this with an IP allowlist or OAuth role claim, but key generation should never sit on the open internet.

Next, in section 7, we use the same dependency-injection seam to enforce per-key rate limits.