FastAPI Production Checklist: CORS, Rate Limiting, and Diagnostic Monitoring

Vergate Team4 min read

FastAPI's default setup gets you from zero to a running API in minutes. But "running" and "production-ready" are different things. CORS defaults to same-origin (no header = no cross-origin), exception handlers print full tracebacks, and sync functions disguised as async endpoints silently block the event loop.

Here's the production checklist — the middleware, patterns, and diagnostics that prevent the three most common FastAPI production failures.

#CORS: why allow_origins=["*"] is a catastrophe

FastAPI's CORSMiddleware is explicit — you configure it or there's no CORS header. That's safer than Express (which defaults to open). But the typical StackOverflow answer gets it wrong:

python
# DON'T — catastrophic security risk
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

When allow_credentials=True, the browser sends cookies (including session cookies) with cross-origin requests. If allow_origins=["*"], any website on the internet can read authenticated responses from your API. That's not a theoretical risk — it's a full account takeover for every user who visits a malicious page while logged in.

The fix:

python
ALLOWED_ORIGINS = [
    "https://your-domain.com",
    "https://app.your-domain.com",
    "http://localhost:3000",  # dev only
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["Authorization", "Content-Type"],
)

The rule: explicit origin list, never wildcard with credentials. If you need public API access without credentials, use allow_origins=["*"] but set allow_credentials=False and document the tradeoff.

#Exception leakage: the traceback problem

FastAPI returns raw tracebacks when DEBUG=true (or when no debug flag is set and an unhandled exception occurs). This leaks:

  • File paths on your server (/app/backend/routes/users.py:42)
  • Database connection strings (if they appear in the traceback)
  • Library versions (useful for targeted exploits)
  • Internal API structure

The fix — custom exception handler:

python
from fastapi import Request
from fastapi.responses import JSONResponse
import logging

logger = logging.getLogger("vergate.api")

@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    logger.error(
        "Unhandled exception",
        exc_info=exc,
        extra={"path": request.url.path, "method": request.method},
    )
    return JSONResponse(
        status_code=500,
        content={
            "error": "Internal server error",
            "detail": "An unexpected error occurred. Please try again.",
        },
    )

This logs the full traceback server-side (where you can see it in your logs) while returning a clean, generic error to the client.

Also add request logging middleware:

python
import time
import logging

logger = logging.getLogger("vergate.request")

@app.middleware("http")
async def log_requests(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    duration_ms = (time.perf_counter() - start) * 1000

    if response.status_code >= 500:
        logger.error(
            "%s %s %d %.1fms",
            request.method, request.url.path,
            response.status_code, duration_ms,
        )
    elif duration_ms > 1000:
        logger.warning(
            "%s %s %d %.1fms (slow)",
            request.method, request.url.path,
            response.status_code, duration_ms,
        )
    return response

The > 1000ms warning catches the async pitfall in the next section.

#The async trap: sync code inside async def

FastAPI runs all async def endpoints on a single event loop. If one endpoint blocks (synchronous HTTP call, file I/O, CPU-bound work), every other async request waits.

python
# DON'T — blocks the event loop
@app.get("/search")
async def search(q: str):
    import requests
    results = requests.get(f"https://api.example.com/search?q={q}")  # BLOCKS
    return {"results": results.json()}

This looks async. It's not. requests.get() is synchronous — it blocks the entire event loop for the duration of the HTTP call. If that call takes 2 seconds, every other request waits 2 seconds.

The fix — use async HTTP clients:

python
import httpx

@app.get("/search")
async def search(q: str):
    async with httpx.AsyncClient() as client:
        results = await client.get(f"https://api.example.com/search?q={q}")
    return {"results": results.json()}

Or use sync endpoints for blocking code:

python
# FastAPI runs sync endpoints in a threadpool — no event loop blocking
@app.get("/report")
def generate_report():
    # Sync is fine here — FastAPI handles it correctly
    data = expensive_computation()
    return {"report": data}

The rule: async def = non-blocking only. def = blocking code that FastAPI runs in a threadpool. Mixing them correctly is the difference between an API that handles 1000 concurrent requests and one that falls over at 10.

#Rate limiting: the missing middleware

FastAPI has no built-in rate limiting. Without it, one abusive client can exhaust your database connections, memory, or API quotas.

Minimal rate limiter:

python
from collections import defaultdict
from time import time

request_counts: dict[str, list[float]] = defaultdict(list)
RATE_LIMIT = 100  # requests per window
WINDOW_SECONDS = 60

@app.middleware("http")
async def rate_limit(request: Request, call_next):
    client_ip = request.client.host if request.client else "unknown"
    now = time()

    # Clean old entries
    request_counts[client_ip] = [
        t for t in request_counts[client_ip] if now - t < WINDOW_SECONDS
    ]

    if len(request_counts[client_ip]) >= RATE_LIMIT:
        return JSONResponse(
            status_code=429,
            content={"error": "Rate limit exceeded. Try again later."},
        )

    request_counts[client_ip].append(now)
    return await call_next(request)

For production, use a Redis-backed limiter (like slowapi) that works across multiple worker processes.

#Structured logging: the diagnostic foundation

Production debugging without structured logs is guessing. Every log line should include request ID, path, method, status, duration, and user context.

python
import structlog

logger = structlog.get_logger("vergate")

@app.middleware("http")
async def add_request_context(request: Request, call_next):
    request_id = request.headers.get("X-Request-ID", str(uuid4()))
    bind_contextvars(request_id=request_id, path=request.url.path)

    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id
    return response

#The Vergate scan: catch what you miss

Manual header inspection catches the obvious stuff. But Vergate's passive scanner checks 20+ rules across CORS policies, security headers, exposed files, and server information leakage — including route-specific header variations that manual curl misses. Run it on your FastAPI backend at vergate.dev/free-scan before every production deploy. It takes 10 seconds and catches the middleware gaps that become incidents.

Frequently asked questions

Why is allow_origins=['*'] with credentials dangerous in FastAPI?

When Allow-Credentials is true, the browser sends cookies with cross-origin requests. If Allow-Origin is *, any website on the internet can read authenticated responses from your API — including session tokens, user data, and anything behind auth middleware. It's the single most common CORS misconfiguration we find.

How do I prevent FastAPI from leaking tracebacks in production?

Set DEBUG=false (or don't set it), register a custom exception handler that returns a generic error response, and never use ServerErrorMiddleware in production. FastAPI's default ExceptionHandler returns the traceback as a string when DEBUG mode is on — which it is in most development setups that get deployed accidentally.

What's the fastest way to detect blocking sync code in async endpoints?

Monitor endpoint p95 latency. If an async def endpoint shows latency spikes that correlate with CPU usage (not I/O), you likely have blocking sync code (file reads, requests.get(), synchronous DB calls) inside an async function. FastAPI runs async endpoints on a single event loop — one blocking call stalls every concurrent request.

Can Vergate detect FastAPI security issues?

Yes. Vergate's passive scanner checks CORS headers, security headers, server information leakage, exposed files, and technology fingerprinting on any web endpoint — including FastAPI backends.

Keep reading