HSTS in FastAPI is one of those things that looks trivial until you ship it wrong.
The header itself is simple:
Strict-Transport-Security: max-age=31536000; includeSubDomains
But the decision around where to set it, when to enable it, and whether to preload it can absolutely break local development, staging subdomains, and legacy services you forgot still exist.
If you run FastAPI in production, you want HSTS. The real question is which implementation path fits your stack.
What HSTS actually does
HSTS tells browsers: “for this domain, only use HTTPS for a period of time.”
That gives you a few wins:
- blocks protocol downgrade attacks
- reduces SSL stripping risk
- helps prevent users from accidentally using
http:// - enforces HTTPS after the first secure visit
The catch is the “after the first secure visit” part. HSTS is not magic on the first request unless you use preload. I’ll get to that.
For FastAPI apps, you’ll usually choose one of these approaches:
- set HSTS in FastAPI middleware
- set HSTS at the reverse proxy or load balancer
- use a platform or CDN to inject the header
- enable preload, or don’t
Option 1: Set HSTS in FastAPI middleware
This is the most obvious Python-native solution. You add middleware and attach the header to every HTTPS response.
Example: custom middleware
from fastapi import FastAPI, Request
app = FastAPI()
HSTS_VALUE = "max-age=31536000; includeSubDomains"
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
# Only send HSTS when the original request was HTTPS
forwarded_proto = request.headers.get("x-forwarded-proto")
is_https = request.url.scheme == "https" or forwarded_proto == "https"
if is_https:
response.headers["Strict-Transport-Security"] = HSTS_VALUE
return response
Pros
- easy to understand in app code
- versioned with your FastAPI project
- works well if you own the whole deployment path
- convenient for app-specific logic
Cons
- easy to get wrong behind proxies
- your app may think traffic is HTTP unless proxy headers are trusted correctly
- duplicated effort if multiple services need the same policy
- not ideal if TLS terminates before the app
This approach is fine, but I don’t love it as the primary production strategy unless the app really is the right place to manage headers. In most real deployments, TLS terminates at Nginx, Traefik, Envoy, ALB, Cloudflare, or something similar. That layer usually has better context.
FastAPI-specific gotcha
If FastAPI sits behind a reverse proxy, request.url.scheme may be wrong unless forwarded headers are configured properly. If you rely on X-Forwarded-Proto, make sure you trust only your own proxy chain.
If you use Uvicorn directly, review its proxy header handling in the official docs:
And for FastAPI deployment patterns:
Option 2: Set HSTS at the reverse proxy
This is usually my preferred option.
If Nginx or another proxy is terminating TLS, that’s the cleanest place to enforce HSTS. The proxy already knows whether the request came over HTTPS, and it can apply the same policy to multiple apps consistently.
Nginx example
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://fastapi_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Pros
- best place when TLS terminates at the proxy
- centralized policy for many apps
- less app code
- lower chance of scheme detection mistakes
- easier to standardize across teams
Cons
- header config lives outside the Python app
- local parity can be weaker if developers run Uvicorn directly
- misconfigured proxies can still cause inconsistent behavior
- changes may require infra deploys rather than app deploys
For most production teams, this is the sweet spot. If you’re serving FastAPI through Nginx, Traefik, HAProxy, Envoy, or a cloud load balancer, put HSTS there unless you have a strong reason not to.
Option 3: Set HSTS at the CDN or edge platform
If your traffic goes through a CDN or edge gateway, you can often inject HSTS there.
Pros
- simplest central control point
- consistent across many origins
- no app or proxy changes required
- useful for teams with mixed stacks, not just Python
Cons
- hidden from app developers
- easy to forget during debugging
- can conflict with origin behavior
- edge config drift is a real thing
I’ve seen teams spend too long debugging “why is this header still present?” because the CDN was injecting it even after the app config changed. It works, but it can become a visibility problem.
If you choose this route, document it clearly.
Comparison: app vs proxy vs edge
Here’s the practical version.
FastAPI middleware
Best when:
- you want app-owned security behavior
- you don’t have a smart proxy layer
- each app needs different policies
Avoid when:
- TLS terminates elsewhere
- you run many services behind one ingress
- your forwarded header trust model is messy
Reverse proxy
Best when:
- Nginx, Traefik, or Envoy terminates TLS
- you want one consistent policy
- platform or infra teams manage ingress
Avoid when:
- every app truly needs custom behavior
- you don’t control the proxy config
CDN or edge
Best when:
- you already manage headers globally at the edge
- you need consistency across many domains and apps
Avoid when:
- you want developers to see security behavior in code
- your edge/origin ownership is fragmented
Choosing the HSTS value
The header supports a few directives:
max-age=<seconds>includeSubDomainspreload
Good starting point
Strict-Transport-Security: max-age=31536000; includeSubDomains
That’s one year plus subdomain coverage. Solid production default if every subdomain is HTTPS-ready.
Safer rollout option
Strict-Transport-Security: max-age=300
Five minutes. Great for testing. Then bump to a day, then a month, then a year.
That gradual rollout matters more than people think. HSTS is sticky in browsers. If you push a bad policy, users keep feeling that pain long after you fix the server.
includeSubDomains: great until it isn’t
This directive is where a lot of breakage starts.
Pros
- protects the whole domain tree
- prevents weaker subdomains from becoming downgrade targets
- usually the right long-term goal
Cons
- breaks any subdomain still serving plain HTTP
- impacts old admin panels, mail tools, forgotten dev hosts
- can cause ugly surprises in larger organizations
If you don’t control every subdomain, don’t casually enable it. Audit first.
preload: strong protection, strong commitment
Preload puts your domain into browser-maintained HSTS preload lists, which means browsers enforce HTTPS even on the very first request.
Pros
- closes the first-visit gap
- strongest practical HSTS posture
- great for mature HTTPS-only domains
Cons
- hard to roll back
- requires strict compliance across the domain
- can break things badly if your subdomain hygiene is weak
Preload is not a checkbox for “more security points.” It’s a commitment. If you’re still cleaning up subdomains or your staging setup is chaotic, don’t do it yet.
A preload-style header looks like this:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
That’s two years, which is typically expected for preload eligibility.
My opinionated recommendation for FastAPI teams
If you run FastAPI behind a reverse proxy, set HSTS at the proxy first.
Use FastAPI middleware only when:
- you don’t have a reliable ingress layer for header management, or
- you need app-specific behavior that the proxy can’t reasonably express
For rollout:
- start with
max-age=300 - verify behavior in production
- move to
max-age=86400 - then
max-age=31536000 - add
includeSubDomainsonly after auditing subdomains - add
preloadonly when you’re absolutely sure
That progression is boring, and boring is good in security headers.
Testing your FastAPI HSTS setup
You can verify manually:
curl -I https://example.com
You want to see:
Strict-Transport-Security: max-age=31536000; includeSubDomains
You should also confirm the header is not being served over plain HTTP by the app layer in a misleading way. Usually the HTTP endpoint should redirect to HTTPS, and the HSTS header should appear on the HTTPS response.
For a quick check of HSTS and related headers, you can scan your site with HeaderTest.
A complete FastAPI example with optional middleware
If you do want app-level control, keep it explicit:
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
class HSTSMiddleware(BaseHTTPMiddleware):
def __init__(self, app, max_age: int = 31536000, include_subdomains: bool = True, preload: bool = False):
super().__init__(app)
parts = [f"max-age={max_age}"]
if include_subdomains:
parts.append("includeSubDomains")
if preload:
parts.append("preload")
self.hsts_value = "; ".join(parts)
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
forwarded_proto = request.headers.get("x-forwarded-proto")
is_https = request.url.scheme == "https" or forwarded_proto == "https"
if is_https:
response.headers["Strict-Transport-Security"] = self.hsts_value
return response
app = FastAPI()
app.add_middleware(HSTSMiddleware, max_age=300, include_subdomains=False, preload=False)
@app.get("/")
async def read_root():
return {"ok": True}
That’s fine for development and controlled rollouts. I still wouldn’t treat it as a substitute for sane proxy configuration.
Final call: what should most teams do?
For a typical FastAPI deployment:
- best default: set HSTS at the reverse proxy
- best rollout: start with short
max-age - best long-term policy: one year
- use
includeSubDomainscarefully - use preload only when your domain is truly HTTPS-only everywhere
HSTS is simple on paper and unforgiving in production. That’s why I prefer the approach that matches where HTTPS is actually enforced. For most FastAPI stacks, that’s not the Python code. It’s the layer in front of it.
If you want the official references for framework and server behavior, check: