skip to content

In Django, how do you roll out HSTS with SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS and SECURE_HSTS_PRELOAD without locking users out?

level: seniorimportance: should knowfreq 40%

answer

  1. only on HTTPS responses
  2. start with an hour
  3. subdomains inherit the promise
  4. preload is hard to undo

basics

~10 s

Set SECURE_HSTS_SECONDS to a small value such as 3600, confirm every page works over HTTPS, then raise it to about a year. Add includeSubDomains only when every subdomain is HTTPS, and preload last.

solid answer

~30 s

`SecurityMiddleware` adds `Strict-Transport-Security: max-age=<SECURE_HSTS_SECONDS>` to HTTPS responses when the setting is non-zero and the header is not already present; `SECURE_HSTS_INCLUDE_SUBDOMAINS` appends `includeSubDomains` and `SECURE_HSTS_PRELOAD` appends `preload`. Browsers then refuse plain HTTP to the host for `max-age` seconds and will not let users click past certificate errors. So I roll it out in stages: 3600 seconds first, confirm nothing breaks, then 31536000 (one year). I add `includeSubDomains` only after checking every subdomain serves HTTPS, and `preload` last, because being on browser preload lists is slow to undo. If the header never appears behind a proxy, `is_secure()` is false and `SECURE_PROXY_SSL_HEADER` is missing.

code

python · 10 lines
python
# Stage 1: trial
SECURE_HSTS_SECONDS = 3600

# Stage 2, after verification:
# SECURE_HSTS_SECONDS = 31536000
# SECURE_HSTS_INCLUDE_SUBDOMAINS = True   # only once every subdomain serves HTTPS
# SECURE_HSTS_PRELOAD = True              # only if you intend to submit to preload lists

# Header on HTTPS responses at stage 2:
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

go deeper

for a junior

Recall that SECURE_HSTS_SECONDS turns HSTS on and that 0, the default, means no header.

for a middle

Explain when SecurityMiddleware adds the header (non-zero seconds, HTTPS request, header absent) and what the two flags append.

for a senior

Run a staged rollout, audit subdomains before includeSubDomains, treat preload as nearly irreversible, and diagnose a missing header behind a proxy.

for a principal

Set domain-wide policy: who may add subdomains, how certificates are monitored, and whether the organisation commits to preload.

## What HSTS is **HTTP Strict Transport Security (HSTS)** is a response header, `Strict-Transport-Security`, that tells a browser: for the next `max-age` seconds, only ever connect to this host over HTTPS. After seeing it, the browser rewrites `http://` links to `https://` before sending anything, which defeats attacks that intercept the first plain-HTTP request and keep the victim on HTTP. Browsers that honour it also refuse to let users bypass certificate errors for that host. ## How Django sends it `SecurityMiddleware.process_response()` adds the header when: 1. `SECURE_HSTS_SECONDS` is non-zero (the default is `0`, meaning off); 2. `request.is_secure()` is true; 3. the response does not already have a `Strict-Transport-Security` header. The value is built from three settings: | Setting | Default | Adds | |---|---|---| | `SECURE_HSTS_SECONDS` | `0` | `max-age=<seconds>` | | `SECURE_HSTS_INCLUDE_SUBDOMAINS` | `False` | `; includeSubDomains` | | `SECURE_HSTS_PRELOAD` | `False` | `; preload` | The two flags have no effect while `SECURE_HSTS_SECONDS` is `0`. Because the header is added only to secure requests, a Django app behind a TLS-terminating proxy without `SECURE_PROXY_SSL_HEADER` never sends it; Django's own documentation calls out this symptom. ## Why it can lock users out HSTS is enforced by the **browser**, and the browser remembers it. If you later need plain HTTP (a certificate lapses, a subdomain is HTTP-only, a staging host shares the domain), every browser that saw the header refuses to connect for the rest of `max-age`, and users cannot click through the warning. You cannot recall the header; you can only send `max-age=0` over HTTPS to browsers that come back. ## A staged rollout 1. **Prepare.** Serve everything over HTTPS, including assets and redirects; turn on `SECURE_SSL_REDIRECT` or redirect at the edge. 2. **Short trial.** `SECURE_HSTS_SECONDS = 3600`. Django's documentation suggests one hour for testing. 3. **Observe.** Check that nothing on the site, including embedded resources and other hosts you link to under the same name, needs plain HTTP. 4. **Long max-age.** Raise to `31536000` (one year), a common production value, so infrequent visitors stay protected. 5. **Subdomains.** Inventory every subdomain (intranet tools, mail-related hosts, legacy apps). Only when all serve HTTPS, set `SECURE_HSTS_INCLUDE_SUBDOMAINS = True`. 6. **Preload, optionally.** `SECURE_HSTS_PRELOAD = True` only adds the `preload` directive; getting into browsers' built-in lists is a separate submission to the list maintainers. Once listed, browsers enforce HTTPS even on the first visit, and removal takes a long time to reach users. ## Common mistakes - **Starting at one year.** Any mistake then lasts a year for every visitor. - **includeSubDomains on the apex without an inventory.** An HTTP-only subdomain becomes unreachable in browsers that visited the main site. - **Expecting the header in development.** The dev server is plain HTTP, so `is_secure()` is false and nothing is sent; that is correct behaviour, not a bug. - **Setting the header at both proxy and Django.** Django will not add a second one if a view set it, but a proxy may add another after Django; choose one layer. - **Letting certificates lapse.** Under HSTS, an expired certificate is a hard outage, not a warning users can bypass. ## Verifying in tests The test client can simulate an HTTPS request with `secure=True`, which makes `is_secure()` true without any proxy header: ```python from django.test import TestCase, override_settings @override_settings(SECURE_HSTS_SECONDS=3600) class HstsTests(TestCase): def test_sent_on_https_only(self): secure = self.client.get("/", secure=True) self.assertEqual(secure.headers["Strict-Transport-Security"], "max-age=3600") plain = self.client.get("/") self.assertNotIn("Strict-Transport-Security", plain.headers) ``` This confirms the exact header string each stage of the rollout will send, and that plain-HTTP responses stay without it. ## Where it fits HSTS complements, not replaces, the HTTPS redirect. The redirect handles a user's very first visit (unless preloaded); HSTS makes the browser skip plain HTTP afterwards. Cookie-level `Secure` flags are configured separately.

  • Your production responses have no Strict-Transport-Security header even though SECURE_HSTS_SECONDS is set. What do you check?
    First whether Django considers the request secure. `SecurityMiddleware` adds HSTS only when `request.is_secure()` is true, so behind a TLS-terminating proxy you need a correct `SECURE_PROXY_SSL_HEADER`. Then check that `SecurityMiddleware` is in `MIDDLEWARE` and that nothing strips the header after Django.
  • How do you back out HSTS if you must serve a host over plain HTTP again?
    Send `Strict-Transport-Security: max-age=0` over HTTPS, for example by setting `SECURE_HSTS_SECONDS` to a tiny value and removing the flags. Only browsers that revisit over HTTPS learn it; others keep enforcing until their stored max-age expires, which is why the rollout starts short.

HSTS is like telling the post office to forward all your mail by registered delivery for a year: it protects you from interception, but if you move to an address that cannot sign for parcels, you cannot cancel the instruction until the year runs out.

saying these in an interview costs you the question

  • SecurityMiddleware sends HSTS on every response, HTTP included.
  • SECURE_HSTS_PRELOAD = True adds the site to browser preload lists.
  • HSTS can be switched off instantly by removing the setting.
  • includeSubDomains is safe to add before auditing subdomains.
  • Starting with a one-year max-age is fine because HSTS is easy to undo.