HTTP Strict Transport Security, or HSTS, is one of those security headers that feels deceptively simple. Set one response header, ship it, done.

Except that’s not really how it works in production.

If you run a Django app and want HSTS configured properly, you need to understand what it actually does, how Django exposes it, and how to roll it out without accidentally breaking local development, subdomains, or a half-migrated environment.

What HSTS actually does

HSTS tells browsers:

“For this domain, only use HTTPS for a period of time. If the user tries HTTP, upgrade it to HTTPS before making the request.”

The header looks like this:

Strict-Transport-Security: max-age=31536000; includeSubDomains

That means:

  • max-age=31536000: remember this rule for 1 year
  • includeSubDomains: apply it to all subdomains too

Optionally, you can also use:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

The preload token signals your intent to be added to browser preload lists, which is a much bigger commitment. I’ll get to that.

HSTS protects against protocol downgrade attacks and the classic “first request over HTTP” problem. Without HSTS, a user can still be tricked into hitting http://example.com first. With HSTS already cached, the browser upgrades that request locally before it leaves the machine.

That “already cached” part matters. HSTS is not magic for a user’s very first visit unless your domain is preloaded.

The Django settings you actually need

Django has built-in support for HSTS. You don’t need custom middleware for the basic case.

The main settings are:

# settings.py

SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

If SECURE_HSTS_SECONDS is greater than 0, Django sends the Strict-Transport-Security header on secure responses.

A practical production example looks like this:

# settings/production.py

SECURE_SSL_REDIRECT = True

SECURE_HSTS_SECONDS = 31536000  # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

This is the usual cluster of HTTPS-related settings I expect in a real deployment.

What these do

  • SECURE_SSL_REDIRECT = True
    Redirects HTTP requests to HTTPS.

  • SECURE_HSTS_SECONDS = 31536000
    Enables HSTS and tells browsers to remember it for 1 year.

  • SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    Applies HSTS to api.example.com, admin.example.com, and every other subdomain.

  • SECURE_HSTS_PRELOAD = True
    Adds the preload directive to the header.

  • SESSION_COOKIE_SECURE = True and CSRF_COOKIE_SECURE = True
    Ensure sensitive cookies are only sent over HTTPS.

  • SECURE_PROXY_SSL_HEADER
    Critical if Django sits behind a reverse proxy or load balancer that terminates TLS.

Don’t enable HSTS until HTTPS is actually correct

This is where people get burned.

If your app is behind Nginx, AWS ALB, Cloudflare, or some other proxy, Django needs to correctly detect secure requests. If it doesn’t, you’ll get weird redirect behavior, mixed signals, or missing headers.

For example, behind Nginx:

location / {
    proxy_pass http://django_app;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Then in Django:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

If you skip this, Django may think the request is plain HTTP even when the browser used HTTPS.

I’ve seen teams enable HSTS while their proxy config was still sloppy. That’s how you end up debugging redirect loops at 2 a.m.

Roll out HSTS gradually

The safest rollout is not “set one year immediately and hope.”

Start small:

SECURE_HSTS_SECONDS = 300
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False

That gives you 5 minutes. Enough to verify the header is present and your site behaves correctly.

Then increase it:

SECURE_HSTS_SECONDS = 86400  # 1 day

Then later:

SECURE_HSTS_SECONDS = 31536000  # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True

This staged rollout matters a lot if you have old subdomains, forgotten staging hosts, or weird internal tools hanging off the same parent domain.

A safer environment-based configuration

I prefer making HSTS conditional by environment.

# settings.py
import os

DJANGO_ENV = os.getenv("DJANGO_ENV", "development")

SECURE_SSL_REDIRECT = DJANGO_ENV == "production"
SESSION_COOKIE_SECURE = DJANGO_ENV == "production"
CSRF_COOKIE_SECURE = DJANGO_ENV == "production"

if DJANGO_ENV == "production":
    SECURE_HSTS_SECONDS = 31536000
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
else:
    SECURE_HSTS_SECONDS = 0
    SECURE_HSTS_INCLUDE_SUBDOMAINS = False
    SECURE_HSTS_PRELOAD = False

For local development, keep HSTS off. Browsers cache HSTS aggressively, and once they do, testing plain HTTP on the same hostname gets annoying fast.

How to verify it’s working

Use curl first. It’s fast and honest.

curl -I https://example.com

You want to see something like:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

If you’re debugging redirects too:

curl -I http://example.com

That should return a redirect to HTTPS.

You can also run a free security headers scan with HeaderTest to verify HSTS and catch other missing headers while you’re there.

Browser DevTools also work, but curl is still my first check because it removes frontend noise.

Common mistakes with HSTS in Django

1. Enabling includeSubDomains too early

This is the biggest one.

If you send:

Strict-Transport-Security: max-age=31536000; includeSubDomains

then every subdomain must support HTTPS properly. Not just your main app.

That includes things like:

  • old-admin.example.com
  • staging.example.com
  • status.example.com
  • random legacy services nobody wants to admit still exist

If any of those break on HTTPS, users will hit hard failures.

2. Using preload casually

Preloading is not just “extra security.” It’s closer to a long-term contract.

To qualify, you generally need:

  • HTTPS on the main domain
  • HTTPS on all subdomains
  • max-age of at least 1 year
  • includeSubDomains
  • preload in the header

Once your domain is in browser preload lists, backing out takes time. Browser vendors ship those lists on their own release schedules. So if you preload before your entire domain estate is clean, you can create pain that lingers for weeks or months.

I only recommend preload when you’re genuinely sure every current and future subdomain will stay HTTPS-only.

3. Forgetting that HSTS persists in the browser

If you test HSTS on a real hostname, your browser remembers it.

That can confuse local testing because even after you remove the header server-side, the browser may continue forcing HTTPS until max-age expires.

For temporary rollback, set:

SECURE_HSTS_SECONDS = 0

That tells browsers to clear the policy when they receive the new header. But users have to actually receive that response over HTTPS for the reset to happen.

4. Thinking HSTS replaces redirects

It doesn’t.

You still want:

SECURE_SSL_REDIRECT = True

HSTS helps browsers enforce HTTPS after they learn the policy. Redirects still matter for clients that don’t have it cached yet.

What header should you use?

For a mature production setup, this is the usual target:

SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

But only if:

  • every subdomain supports HTTPS
  • your proxy setup correctly tells Django when a request is secure
  • you’ve already tested with shorter max-age values
  • you actually want preload

If you’re not there yet, this is a perfectly sane intermediate config:

SECURE_HSTS_SECONDS = 86400
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False

That still gives you meaningful protection without overcommitting.

Full production example

Here’s a more realistic Django security block I’d be comfortable shipping:

# settings/production.py

DEBUG = False
ALLOWED_HOSTS = ["example.com", "www.example.com"]

SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = False  # obsolete in modern browsers
X_FRAME_OPTIONS = "DENY"

CSRF_TRUSTED_ORIGINS = [
    "https://example.com",
    "https://www.example.com",
]

If you’re on a recent Django version, skip old cargo-cult settings that no longer matter. HSTS is useful. Random obsolete headers copied from a 2018 blog post usually aren’t.

My rule of thumb

HSTS is absolutely worth enabling on Django apps. But I treat it like a deployment decision, not a checkbox.

If your HTTPS setup is solid, turn it on. If your subdomains are messy, don’t pretend they aren’t. If you’re considering preload, assume it’s harder to undo than you think.

Django makes HSTS easy to configure. The hard part is being honest about your infrastructure before you flip the switch.