HSTS is one of those headers that looks trivial until you ship it wrong.
For Flask apps, the basic idea is simple: tell browsers to always use HTTPS for your domain. That blocks protocol downgrade attacks and kills off a whole class of “accidentally served over HTTP” mistakes. But HSTS also has sharp edges. If you enable it too early, on the wrong host, or behind a misconfigured proxy, you can lock users into a broken site.
Here’s how I set it up in Flask without stepping on a rake.
What HSTS actually does
HSTS stands for HTTP Strict Transport Security. The browser sees this response header over HTTPS:
Strict-Transport-Security: max-age=31536000; includeSubDomains
After that, for the next max-age seconds, the browser will refuse to use plain HTTP for that site. If the user types http://example.com, the browser upgrades it to https://example.com before making the request.
That matters because a redirect from HTTP to HTTPS is not enough by itself. On the first visit, an attacker on the network can interfere with the HTTP request before the redirect happens. HSTS closes that gap after the browser has seen the policy once.
If you go further and get onto the browser preload list, you can protect even the very first visit. More on that later.
The minimum Flask setup
The most direct way to add HSTS in Flask is with an after_request hook:
from flask import Flask, request
app = Flask(__name__)
@app.after_request
def set_security_headers(response):
if request.is_secure:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@app.route("/")
def index():
return "Hello, HTTPS"
A couple of things I care about here:
- I only send HSTS on secure requests
- I start with a sensible production value:
31536000seconds = 1 year - I include
includeSubDomainsonly if I’m sure every subdomain is HTTPS-capable
That request.is_secure check is not optional. Browsers ignore HSTS sent over HTTP anyway, but checking keeps your app behavior honest and avoids misleading test results.
The proxy problem Flask apps hit all the time
A lot of Flask apps run behind Nginx, Apache, HAProxy, Traefik, or a cloud load balancer. TLS terminates at the proxy, and Flask receives plain HTTP from the reverse proxy. In that setup, request.is_secure may be False unless Flask trusts the forwarded headers.
If you skip this part, you’ll think HSTS is enabled, but your app won’t send it.
Use ProxyFix when your proxy sets X-Forwarded-Proto correctly:
from flask import Flask, request
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
@app.after_request
def set_hsts(response):
if request.is_secure:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@app.route("/")
def index():
return "Secure app"
And make sure your reverse proxy sends the right header. For Nginx:
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
I’ve seen teams debug “why isn’t HSTS showing up?” for hours when the real issue was Flask not knowing the original request was HTTPS.
A cleaner option: Flask-Talisman
If you want a more complete security-header setup, Flask-Talisman is the usual shortcut.
Install it:
pip install flask-talisman
Use it like this:
from flask import Flask
from flask_talisman import Talisman
app = Flask(__name__)
Talisman(
app,
force_https=True,
strict_transport_security=True,
strict_transport_security_max_age=31536000,
strict_transport_security_include_subdomains=True,
strict_transport_security_preload=False,
)
@app.route("/")
def index():
return "Hello from Flask"
This is nice because it centralizes header policy, but I still like understanding the raw header myself. Libraries are great until you need to debug production behavior at 2 AM.
Don’t jump straight to one year
The biggest HSTS mistake is treating it like a harmless toggle.
Once a browser caches your HSTS policy, users are stuck with it until max-age expires. If your HTTPS setup breaks tomorrow, users can’t click through certificate warnings and “just try HTTP.” The browser will hard-fail.
So roll it out in stages.
Stage 1: short max-age
Start with something tiny:
response.headers["Strict-Transport-Security"] = "max-age=300"
That’s 5 minutes. Good for verifying behavior.
Then move to a day:
response.headers["Strict-Transport-Security"] = "max-age=86400"
Then a month:
response.headers["Strict-Transport-Security"] = "max-age=2592000"
Then a year when you’re confident:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
I would not enable includeSubDomains until I had audited every live subdomain, including weird internal leftovers that somehow became public years ago.
When to use includeSubDomains
This directive tells the browser the HSTS rule applies to the whole domain tree.
If you send:
Strict-Transport-Security: max-age=31536000; includeSubDomains
from example.com, then api.example.com, blog.example.com, and old-admin.example.com all need to be HTTPS-only and correctly configured.
That’s great when your infrastructure is consistent. It’s a disaster when you forgot that m.example.com still points to a dead service with an expired cert.
My rule: if you don’t fully control all subdomains, don’t enable it yet.
Preload: powerful and unforgiving
Browsers maintain an HSTS preload list for domains that want HTTPS enforced even on the first visit. To qualify, you generally need:
max-ageof at least 1 yearincludeSubDomainspreload- HTTPS on the apex and all subdomains
Header example:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
In Flask:
@app.after_request
def set_hsts(response):
if request.is_secure:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains; preload"
)
return response
Preload is not a casual checkbox. Removal from preload lists is slow and annoying. If your domain has any edge-case subdomains, partner-hosted services, or legacy junk, fix that first.
Redirect HTTP to HTTPS too
HSTS is not a replacement for redirects. You still want all HTTP traffic to get bounced to HTTPS.
Simple Flask example:
from flask import Flask, request, redirect
app = Flask(__name__)
@app.before_request
def redirect_to_https():
if not request.is_secure and not app.debug:
url = request.url.replace("http://", "https://", 1)
return redirect(url, code=301)
@app.after_request
def set_hsts(response):
if request.is_secure:
response.headers["Strict-Transport-Security"] = "max-age=31536000"
return response
@app.route("/")
def index():
return "Secure content"
Behind a proxy, this only works correctly if forwarded proto handling is configured right.
Also, don’t rely on app-level redirects alone if your front-end proxy can do it earlier and cheaper. I usually prefer HTTPS redirects at the load balancer or Nginx layer, then let Flask handle HSTS on the final HTTPS response.
How to test it
Use curl first. Fastest sanity check:
curl -I https://yourdomain.com
You want to see something like:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If you’re behind a proxy and not seeing it, check whether Flask thinks the request is secure.
You can also scan your headers with a free tool like HeaderTest to catch missing or malformed security headers quickly.
For automated testing in Flask, add a unit test:
def test_hsts_header(client):
response = client.get("/", base_url="https://localhost")
assert "Strict-Transport-Security" in response.headers
assert "max-age=" in response.headers["Strict-Transport-Security"]
That won’t catch proxy misconfiguration in production, but it does stop accidental regressions.
Common mistakes
Sending HSTS over HTTP only
Doesn’t count. Browsers ignore it.
Enabling it in local development
Usually pointless and occasionally annoying. Keep it for environments with real HTTPS.
Using includeSubDomains too early
Classic foot-gun.
Preloading before your house is in order
Even worse foot-gun.
Forgetting reverse proxy headers
Probably the most common Flask-specific issue.
Thinking HSTS fixes bad TLS
It doesn’t. If your certificate is broken, HSTS makes failure stricter, not safer by magic.
A production-ready Flask pattern
This is the shape I’d actually use:
from flask import Flask, request, redirect
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
HSTS_POLICY = "max-age=31536000; includeSubDomains"
@app.before_request
def enforce_https():
if not request.is_secure and not app.debug:
https_url = request.url.replace("http://", "https://", 1)
return redirect(https_url, code=301)
@app.after_request
def add_security_headers(response):
if request.is_secure:
response.headers["Strict-Transport-Security"] = HSTS_POLICY
return response
@app.route("/")
def home():
return "Flask over HTTPS"
Then I’d pair that with:
- TLS termination configured correctly at the proxy
- HTTP-to-HTTPS redirects at the edge
- staged rollout of
max-age - careful review before adding
includeSubDomains - a separate decision process before
preload
HSTS is one of the highest-value low-effort headers you can add to a Flask app. Just don’t treat it like a copy-paste security badge. The header is easy. The operational consequences are the real work.