Security defaults in a FastAPI app: headers, auth, and input validation
Published: 2026-06-04
When you own the whole stack — app, infra, and deploy — there's no security team to catch what you miss. This is what I added to this FastAPI app to cover the obvious bases.
Security headers middleware
Every response from the app gets a fixed set of HTTP security headers, applied in a single BaseHTTPMiddleware:
pythonclass SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer-when-downgrade"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = _CSP
response.headers["Server"] = ""
...
return response
The Server: "" line strips the server banner. Combined with --no-server-header in the uvicorn command, nothing in the response reveals what's running behind Traefik.
Content Security Policy
The CSP is explicit rather than permissive:
default-src 'self';
script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://mc.yandex.ru ...;
connect-src 'self' https://mc.yandex.ru wss://mc.yandex.ru;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none'
object-src 'none' blocks Flash and plugins. frame-ancestors 'none' is redundant with X-Frame-Options: DENY but belt-and-suspenders is fine here. The unsafe-inline for scripts exists because marked.js is inlined and Yandex Metrica injects inline handlers — a nonce or hash would be cleaner but isn't worth the added complexity for a personal site.
Static asset caching
The middleware also adds Cache-Control: public, max-age=31536000, immutable to anything under /assets/. The deploy script injects a build timestamp into the service worker, which busts the app shell cache on deploy. Static files get permanent cache headers only because their URLs are versioned by content (via the SW cache name).
HTTP Basic Auth
The proxy and VPN pages contain credentials, so they're protected by HTTP Basic Auth:
pythondef require_auth(credentials: HTTPBasicCredentials = Depends(_security)):
if not settings.site_user:
return # auth disabled
ok_user = secrets.compare_digest(
credentials.username.encode(), settings.site_user.encode()
)
ok_pass = secrets.compare_digest(
credentials.password.encode(), settings.site_pass.encode()
)
if not (ok_user and ok_pass):
raise HTTPException(status_code=401, ...)
secrets.compare_digest is used instead of == to prevent timing attacks. If SITE_USER is not set in .env, auth is skipped entirely — useful for local development without needing credentials.
Path traversal prevention
Both the blog and CV routers accept user-supplied path segments. Both explicitly block .. and /:
pythonif not slug or "/" in slug or ".." in slug:
raise HTTPException(status_code=400)
After that check, the blog router validates the slug with slug.replace("-", "").replace("_", "").isalnum() — anything that isn't alphanumeric with hyphens/underscores is rejected before a filesystem lookup happens. The CV router uses a frozenset of allowed slugs and rejects anything outside it.
Neither router constructs a path with os.path.join(base, user_input) without first validating the input. The checks happen at the application layer, not relying on the OS to block ../../etc/passwd.
Contact form
The contact form posts to Telegram via the Bot API. Fields are declared with max_length limits in FastAPI's Form(...) to avoid unbounded input. The message is sent as Markdown, so user-supplied text is embedded in a Telegram message — not rendered as HTML, not stored in a database.
No CSRF token because the endpoint is not session-based. The only action it takes is sending a Telegram message; there's no state to change on behalf of the user.
What's not here
- No rate limiting on the contact form (a
slowapilimiter would be a natural addition). - No input sanitization beyond length limits — Telegram renders the message, so XSS isn't a concern, but a malicious user could inject Telegram Markdown formatting.
- No audit log.
For a personal site this is fine. The threat model is mostly bots scanning for open proxies and script kiddies, not targeted attacks.
What each security header does
| Header | Protection |
|---|---|
X-Content-Type-Options: nosniff |
Prevents browsers from MIME-sniffing a response away from the declared content-type |
X-Frame-Options: DENY |
Prevents clickjacking by blocking the page from being embedded in an iframe |
Referrer-Policy: no-referrer-when-downgrade |
Limits referrer info sent to third parties |
Permissions-Policy |
Disables browser APIs (camera, mic, geolocation) for this origin |
X-XSS-Protection: 1; mode=block |
Legacy XSS filter in older browsers (modern browsers use CSP instead) |
Strict-Transport-Security |
Forces HTTPS for 1 year, including subdomains |
Content-Security-Policy |
Whitelist of allowed resource origins |
HSTS considerations
Strict-Transport-Security: max-age=31536000; includeSubDomains is set for 1 year. This means:
- Once a browser sees this header, it will refuse plain HTTP to the domain for 1 year
includeSubDomainsapplies it to every subdomain — only do this if all subdomains serve HTTPS
To undo HSTS, you'd need to serve max-age=0 and then wait for users' HSTS caches to expire. So set it with confidence.
Testing headers
bash# Check all response headers
curl -I https://antonnovikov.com
# Check specific security headers
curl -sI https://antonnovikov.com | grep -E "X-Frame|Content-Security|Strict-Transport|X-Content"
Or use securityheaders.com for a full graded audit.
What can go wrong
HSTS locks you out of HTTP.
Once a browser receives Strict-Transport-Security, it refuses plain HTTP for max-age seconds. If you remove HTTPS from a subdomain while includeSubDomains is active, browsers can't reach it until the HSTS cache expires. Test with curl -sI before counting on browsers to behave.
CSP breaks third-party scripts.
Adding a new analytics script or CDN resource without updating Content-Security-Policy results in a silent browser block. Check the browser console for CSP violation errors before going live with a new script source.
Timing-safe comparison not used for auth.
Using == instead of secrets.compare_digest for password comparison leaks password length and character position via response timing. Always use secrets.compare_digest for credential checks.
Summary
- All security headers are applied in a single
BaseHTTPMiddleware— one place to audit and update secrets.compare_digestprevents timing attacks in the HTTP Basic Auth check- Path traversal is blocked before filesystem lookups:
..and/rejected, slug validated as alphanumeric - CSP has
unsafe-inlinefor scripts due to marked.js and Yandex Metrica — a nonce would be cleaner but adds complexity - HSTS is set for 1 year with
includeSubDomains— confirm all subdomains serve HTTPS before setting this