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.com
  • dev.example.com
  • status.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
  • www is 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:

  1. Verify HTTPS works for all intended hosts
  2. Confirm proxy headers are trusted correctly
  3. Start with small max-age
  4. Check redirects, errors, and edge routes
  5. Increase max-age
  6. Add includeSubDomains only after inventory
  7. Add preload only 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.