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: 31536000 seconds = 1 year
  • I include includeSubDomains only 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-age of at least 1 year
  • includeSubDomains
  • preload
  • 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.