~/portfolio/ blog/ fastapi-backend

Securing a FastAPI backend in under an hour.

Most FastAPI apps ship with zero authentication, no rate limiting, and validation that only catches type errors. Here's the four-layer baseline I apply before anything goes to staging.

The default FastAPI security posture

FastAPI is genuinely excellent at making you fast. That's also the problem. The framework guides you toward working endpoints, not secure ones. A fresh FastAPI app has no auth, no rate limiting, no CORS policy, and Pydantic validation that only checks types — not values.

None of this is the framework's fault. Security is application-specific. But it means every FastAPI backend needs deliberate hardening before exposure, and the four layers below cover the vast majority of the attack surface.

Layer 1: signed sessions with JWT

FastAPI has no built-in session management. The standard approach is python-jose for JWT creation and passlib for password hashing. The key decision is what goes in the token payload.

// jwt token creation
from jose import JWTError, jwt from datetime import datetime, timedelta SECRET_KEY = os.environ["JWT_SECRET"] ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE = timedelta(minutes=30) def create_token(data: dict) -> str: payload = data.copy() payload["exp"] = datetime.utcnow() + ACCESS_TOKEN_EXPIRE return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) # never put passwords, full PII, or role escalation data in payload # payload is base64 — not encrypted, just signed

The most common mistake is treating JWTs as opaque. They're not — base64-decode the payload and you'll see everything in plain text. Keep the payload minimal: a user ID, the expiry, and nothing else. Role checks happen server-side on each request by looking up the user ID.

Layer 2: SlowAPI rate limiting

SlowAPI is a thin FastAPI/Starlette wrapper around the limits library. Adding it takes about 10 lines. The two configurations that matter in practice: a global limit to prevent bulk scraping, and a tighter limit on auth endpoints specifically to stop credential stuffing.

// slowapi setup
from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # global: 200/minute per IP # auth endpoints: 10/minute per IP @app.post("/auth/login") @limiter.limit("10/minute") async def login(request: Request, ...): ...

One gotcha: if your app sits behind a reverse proxy, get_remote_address will rate-limit the proxy's IP, not the client. Fix this by using request.headers.get("X-Forwarded-For") as the key function and trusting only known proxy IPs.

Layer 3: Pydantic strict validation

Pydantic validates types by default. It does not validate values. An email field that passes str validation can still accept "'; DROP TABLE users; --". Add validators for anything that touches a database or gets reflected back to users.

// strict input model
from pydantic import BaseModel, EmailStr, validator import re class UserCreate(BaseModel): email: EmailStr username: str password: str @validator("username") def username_safe(cls, v): if not re.match(r"^[a-zA-Z0-9_-]{3,32}$", v): raise ValueError("alphanumeric, _ and - only, 3–32 chars") return v @validator("password") def password_strength(cls, v): if len(v) < 10: raise ValueError("minimum 10 characters") return v

Layer 4: default-deny auth middleware

Rather than decorating every endpoint with an auth dependency, I add a middleware that denies all requests unless the path is on an explicit allowlist. This catches the "I forgot to add auth to that endpoint" failure mode at the architecture level.

// default-deny middleware
PUBLIC_PATHS = {"/auth/login", "/auth/register", "/health", "/docs"} @app.middleware("http") async def auth_guard(request: Request, call_next): if request.url.path in PUBLIC_PATHS: return await call_next(request) token = request.headers.get("Authorization", "").removeprefix("Bearer ") if not verify_token(token): return JSONResponse({"detail": "unauthorized"}, status_code=401) return await call_next(request)
Why default-deny? Per-endpoint auth decorators scale with endpoint count. A new developer adds 10 endpoints, forgets auth on two, ships. The middleware approach means new endpoints are protected by default — the developer has to explicitly opt out, which is a much smaller failure surface.

The 5-minute pre-staging checklist

// must have

JWT with expiry < 60 min
Rate limiting on auth endpoints
Pydantic validators on all user input
Default-deny middleware
CORS locked to known origins

// common misses

Debug mode left on in prod
SECRET_KEY hardcoded in source
/docs exposed in production
Password in JWT payload
X-Forwarded-For not handled

The common misses column is a checklist, not a judgment. All of them have shipped to production at some point. The most damaging is the secret key in source — once it's in git history, rotating it requires invalidating every existing session, which becomes an incident if you have active users.

← tryhackme series next: office audits →