A lot of backend teams assume HSTS is a “browser thing” that only matters for HTML pages. I’ve seen this mistake more than once on API-only services: the frontend is on HTTPS, the API is on HTTPS, everybody checks the box mentally, and nobody notices the API never sends Strict-Transport-Security.
That gap usually survives until someone looks at headers closely, or until an auth callback, API docs page, or admin route gets hit over plain HTTP somewhere in the chain.
Here’s a real-world style case study based on a FastAPI deployment pattern I see all the time.
The setup
A team runs a FastAPI REST API for a SaaS product:
- FastAPI app behind Uvicorn
- Nginx as reverse proxy
- TLS termination at Nginx
- Endpoints like:
/v1/users/v1/tokens/refresh/docs/openapi.json
- JWT auth in headers
- Cookie-based session for internal admin tools
They had HTTPS working. They also had an HTTP listener on port 80 redirecting traffic to HTTPS.
They thought that was enough.
It wasn’t.
Before: “We already redirect HTTP”
Their original Nginx config looked roughly like this:
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/ssl/certs/fullchain.pem;
ssl_certificate_key /etc/ssl/private/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
And the FastAPI app was pretty standard:
from fastapi import FastAPI
app = FastAPI(title="Customer API")
@app.get("/v1/health")
def health():
return {"status": "ok"}
@app.get("/v1/users/me")
def me():
return {"id": 123, "email": "[email protected]"}
On paper, this looks fine. HTTP gets redirected. HTTPS works. No mixed content because it’s an API.
But here’s the practical problem: the first request can still happen over HTTP.
That matters because HSTS exists to tell clients: “for this host, never try HTTP again for a while.”
Without HSTS:
- a user can type
api.example.comand hit HTTP first - an old bookmark can use
http:// - an internal tool or docs link can point to
http:// - a downgrade attempt on the network still gets a shot before the redirect lands
For pure machine-to-machine API clients, HSTS is less central because many SDKs already pin the base URL to HTTPS. But APIs are rarely pure machine-to-machine in the real world. They have docs, browser-based testing, admin panels, OAuth flows, and engineers pasting URLs into browsers at 2 AM.
That’s where missing HSTS becomes a real operational gap.
The bug they actually hit
The team noticed something weird during an internal security review.
Their /docs page was reachable via HTTP first, then redirected to HTTPS. No credentials were sent in the first request, but that still meant the browser was willing to begin the session over an insecure scheme.
Worse, their admin cookie had Secure, but some internal links still referenced http://api.example.com/docs. That produced inconsistent behavior across environments and made the whole deployment look sloppier than it was.
The fix wasn’t “more redirects.” The fix was teaching browsers to stop using HTTP for that host.
After: add HSTS at the edge
For FastAPI behind a reverse proxy, I strongly prefer setting HSTS in Nginx or whatever terminates TLS. That’s the cleanest place because:
- it guarantees the header is only sent on HTTPS responses
- it covers everything on that host, not just FastAPI routes
- it avoids app-level mistakes when multiple services sit behind one proxy
Here’s the updated Nginx config:
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/ssl/certs/fullchain.pem;
ssl_certificate_key /etc/ssl/private/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
That one line changes browser behavior in a big way:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Why these directives
max-age=31536000tells the browser to remember the rule for one yearincludeSubDomainsapplies the rule to subdomains too
If you’re not fully confident every subdomain is HTTPS-only, do not turn on includeSubDomains yet. I’ve seen teams break old internal tools with this.
Start narrower if you need to:
add_header Strict-Transport-Security "max-age=86400" always;
A day is a safe rollout period. Then move to a month. Then a year when you know your estate is clean.
What changed in practice
After deployment, the browser behavior shifted from:
- user requests
http://api.example.com/docs - server returns 301 redirect
- browser retries on HTTPS
to:
- browser already knows
api.example.comis HTTPS-only - browser upgrades request locally before sending anything over the network
- request goes directly to
https://api.example.com/docs
That’s the whole point of HSTS. Fewer insecure first hops. Fewer downgrade opportunities. Less reliance on redirects doing all the work.
Can you set HSTS from FastAPI itself?
Yes, but I’d treat this as second-best when TLS terminates upstream.
Still, sometimes you need app-level control, especially in containerized setups or platform deployments where you can’t edit the proxy.
Here’s a simple FastAPI middleware:
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
class HSTSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
return response
app = FastAPI()
app.add_middleware(HSTSMiddleware)
@app.get("/v1/health")
def health():
return {"status": "ok"}
This works, but there’s a catch: request.url.scheme must correctly reflect the original client scheme. Behind a proxy, that means FastAPI/Uvicorn needs trusted forwarded headers configured properly.
For example, when starting Uvicorn behind a trusted proxy:
uvicorn app:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips="127.0.0.1"
If you get this wrong, your app may think requests are plain HTTP and skip the header, or worse, trust spoofed headers from untrusted clients.
That’s why I still prefer setting HSTS at Nginx, Traefik, Envoy, or the cloud load balancer layer.
Common FastAPI deployment mistakes
1. Sending HSTS on HTTP responses
Don’t do this at the app layer blindly. HSTS should only be set for HTTPS responses. Browsers ignore it on insecure transport anyway, but misconfiguration here usually means your proxy chain is confused.
2. Enabling includeSubDomains too early
This one bites teams with forgotten subdomains:
old-admin.example.comstaging-api.example.comgrafana.example.com
If any of them still allow HTTP-only access, HSTS with includeSubDomains can create outages.
3. Using a huge max-age on day one
Don’t jump straight to preload-style settings unless you really mean it. HSTS is sticky. Bad settings linger in browsers.
A staged rollout is boring and boring is good:
max-age=300max-age=86400max-age=31536000
4. Forgetting the docs and browser-facing API routes
Even if your core clients are non-browser SDKs, FastAPI often exposes browser-facing surfaces:
/docs/redoc- OAuth redirect endpoints
- admin routes
- health pages viewed in a browser
Those absolutely benefit from HSTS.
How they verified the fix
First, with curl:
curl -I https://api.example.com/v1/health
Expected result:
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
content-type: application/json
Then they checked the redirect still worked:
curl -I http://api.example.com/v1/health
Expected result:
HTTP/1.1 301 Moved Permanently
location: https://api.example.com/v1/health
And finally they ran a headers scan to catch anything else missing. If you want a quick check, HeaderTest is handy for spotting whether HSTS is present and consistently returned.
For framework details, the official docs worth keeping handy are the FastAPI deployment docs and Starlette middleware docs:
The result
After the change, the team didn’t get some dramatic dashboard spike or magical “security improved by 80%” badge. Real security work is usually less cinematic than that.
What they got was better transport hygiene:
- browsers stopped attempting HTTP first after the initial secure visit
- docs and admin routes behaved consistently
- security review findings went away
- the API edge looked like a service run by adults
That’s the real value of HSTS on a FastAPI REST API. Not hype. Just one less avoidable weakness at the transport layer.
If your API is already HTTPS-only, adding HSTS is usually a small change with a good payoff. Just roll it out carefully, especially if you’re tempted to cover every subdomain in one shot.