HSTS looks simple: send one response header and browsers stop using HTTP for your site.
In practice, Starlette apps often get HSTS half-right, which is usually the worst version of security. The app sends the header sometimes, or only on a couple routes, or it trusts the wrong proxy headers, or someone enables preload without understanding the blast radius.
Here are the mistakes I see most often with Starlette, and how I’d fix them.
Mistake 1: Setting HSTS on HTTP responses
HSTS only works when the browser receives it over HTTPS. If your Starlette app sends:
Strict-Transport-Security: max-age=31536000
on a plain HTTP response, browsers ignore it.
I still see people add a middleware, test with curl http://localhost:8000, and assume it’s working. It isn’t.
Fix
Only serve HSTS on HTTPS responses, and make sure users actually land on HTTPS. In production, that usually means:
- TLS terminates at your load balancer, ingress, or reverse proxy
- HTTP gets redirected to HTTPS
- The app knows the original request scheme was
https
A basic Starlette middleware can guard on scheme:
from starlette.applications import Starlette
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
class HSTSMiddleware(BaseHTTPMiddleware):
def __init__(self, app, max_age=31536000, include_subdomains=True, preload=False):
super().__init__(app)
value = f"max-age={max_age}"
if include_subdomains:
value += "; includeSubDomains"
if preload:
value += "; preload"
self.header_value = value
async def dispatch(self, request, call_next):
response = await call_next(request)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = self.header_value
return response
app = Starlette()
app.add_middleware(HSTSMiddleware)
That’s the baseline, not the full solution.
Mistake 2: Trusting request.url.scheme without proxy setup
This one bites a lot of ASGI apps behind Nginx, Traefik, HAProxy, AWS ALB, or Kubernetes ingress.
Your app may be running plain HTTP behind the proxy, so Starlette sees the request as http unless forwarded headers are handled correctly. Then your middleware never sets HSTS, even though the browser connected over HTTPS.
Or worse, your app thinks a request is HTTPS because you blindly trust X-Forwarded-Proto from anywhere.
Fix
Configure your proxy to send the right headers, and only trust them from known proxy infrastructure.
A typical Nginx setup:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Then make sure your ASGI server is configured to respect forwarded headers safely. With Uvicorn, for example:
uvicorn app:app --proxy-headers --forwarded-allow-ips="10.0.0.10,10.0.0.11"
Do not set --forwarded-allow-ips="*" unless you fully understand the trust boundary. I try hard to avoid that in production. If clients can spoof forwarded headers directly, your app can make bad security decisions.
Mistake 3: Adding HSTS only on successful responses
A common middleware bug is setting HSTS only when the response is a normal 200 from your app. But browsers should also receive HSTS on redirects, 404s, and many error responses, as long as they’re served over HTTPS.
If your login page gets the header but your redirect from / to /app doesn’t, you’re leaving coverage gaps for no reason.
Fix
Set HSTS as late as possible on every HTTPS response, unless you have a very specific reason not to.
This middleware does that:
class HSTSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
return response
If you want to verify that the header is actually present across redirects and error pages, run a free scan with HeaderTest. It’s a quick way to catch “works on homepage only” mistakes.
Mistake 4: Using a tiny max-age in production forever
I get why people start with max-age=300. It’s safe while testing. The mistake is leaving it there for months.
A five-minute HSTS policy barely changes browser behavior. It won’t meaningfully protect repeat visitors.
Fix
Roll out in stages, then increase.
A sane progression looks like this:
- Start with
max-age=300 - Then
max-age=86400 - Then
max-age=31536000
Example:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
One year is the usual target once you’re confident the whole site and its subdomains are HTTPS-only.
Mistake 5: Enabling includeSubDomains before checking subdomains
This is where teams shoot themselves in the foot.
If you send:
Strict-Transport-Security: max-age=31536000; includeSubDomains
you are telling browsers that every subdomain must use HTTPS.
That includes forgotten hosts like:
old-admin.example.comdev.example.comstatus.example.com- random legacy things nobody has touched in three years
If any of them still serve HTTP only, users will get hard failures.
Fix
Inventory your subdomains before enabling includeSubDomains.
My rule: don’t turn this on until you know what exists and who owns it. Security headers are easy; ownership and asset sprawl are the hard part.
If you’re not sure, start with:
Strict-Transport-Security: max-age=31536000
Then add includeSubDomains later when you’ve cleaned up the estate.
Mistake 6: Turning on preload too early
Preload is not just another flag. It’s a commitment.
When you use:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
you’re signaling intent to be included in browser preload lists. Once accepted, removal is slow and painful. I’ve seen teams break obscure subdomains because somebody wanted the “most secure” setting without doing the boring operational work first.
Fix
Only use preload if all of these are true:
- your apex domain is always HTTPS
wwwis always HTTPS if it exists- all subdomains are HTTPS-only
- you already send
includeSubDomains - you’re comfortable with the long-term commitment
If any of that sounds shaky, don’t preload yet. HSTS without preload is still useful.
Mistake 7: Applying HSTS in local development
This one creates confusing browser behavior.
If you test HSTS on localhost or a dev domain in your real browser profile, the browser can cache the policy. Then later, even after you stop serving HTTPS, the browser forces HTTPS anyway. People waste time blaming Starlette, Docker, or their reverse proxy when it’s just cached HSTS state.
Fix
Don’t enable HSTS in local dev unless you really need to test it.
Gate the middleware by environment:
import os
from starlette.applications import Starlette
app = Starlette()
if os.getenv("ENABLE_HSTS") == "1":
app.add_middleware(
HSTSMiddleware,
max_age=31536000,
include_subdomains=True,
preload=False,
)
For local testing, I prefer a dedicated staging hostname over messing with browser HSTS state on dev machines.
Mistake 8: Assuming HTTPS redirects replace HSTS
Redirecting HTTP to HTTPS is good. It is not the same thing as HSTS.
Without HSTS, the first request can still start over HTTP. That first hop is exactly where downgrade and interception attacks matter.
Fix
Use both:
- redirect HTTP to HTTPS
- send HSTS on HTTPS responses
In Starlette, HTTPS redirect middleware is separate from HSTS:
from starlette.applications import Starlette
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
app = Starlette()
app.add_middleware(HTTPSRedirectMiddleware)
app.add_middleware(HSTSMiddleware, max_age=31536000, include_subdomains=True)
The redirect gets users onto HTTPS. HSTS teaches browsers to skip HTTP next time.
Mistake 9: Overwriting headers inconsistently across middleware layers
I’ve seen apps set HSTS in Starlette, then also set it in Nginx, then a CDN injects a different value. The final header depends on path, cache status, or which layer answered the request.
That’s messy and hard to debug.
Fix
Pick one layer as the source of truth.
If your proxy or CDN already handles security headers consistently for every response, I’d usually put HSTS there. If you need app-level control, keep it in Starlette and make sure upstream doesn’t override it.
What you want is one final header value, everywhere.
Mistake 10: Forgetting that HSTS is sticky
Once a browser has cached your policy, changing your mind is not instant. If you ship max-age=31536000, users may be pinned to HTTPS for a year.
That’s usually what you want. It’s also why careless rollout hurts.
Fix
Treat HSTS changes like infrastructure changes, not cosmetic header tweaks.
My rollout checklist is simple:
- Verify HTTPS works for all intended hosts
- Confirm proxy headers are trusted correctly
- Start with small
max-age - Check redirects, errors, and edge routes
- Increase
max-age - Add
includeSubDomainsonly after inventory - Add
preloadonly if you truly qualify
A production-friendly Starlette setup
Here’s a practical version I’d actually ship:
import os
from starlette.applications import Starlette
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Route
class HSTSMiddleware(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
self.value = "max-age=31536000; includeSubDomains"
async def dispatch(self, request, call_next):
response = await call_next(request)
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = self.value
return response
async def homepage(request):
return JSONResponse({"ok": True})
app = Starlette(routes=[
Route("/", homepage),
])
if os.getenv("ENABLE_HTTPS_REDIRECT") == "1":
app.add_middleware(HTTPSRedirectMiddleware)
if os.getenv("ENABLE_HSTS") == "1":
app.add_middleware(HSTSMiddleware)
That gives you:
- explicit environment control
- HTTPS redirects when enabled
- HSTS only on HTTPS requests
- a single, predictable header value
Clean and boring. That’s what you want for transport security.
HSTS is one of those controls that works great when the surrounding setup is disciplined. The mistakes usually aren’t in the header syntax. They’re in proxy trust, rollout order, and domain hygiene. That’s where I’d spend the time.