SSL redirect behind a proxy
This is the control that produces the most confusing failure in the whole cluster, because getting it half-right yields an infinite redirect loop and getting it wrong-but-working yields a security hole that looks fine.
SECURE_SSL_REDIRECT defaults to False; SECURE_PROXY_SSL_HEADER defaults to
None.
Why they have to be set together
Section titled “Why they have to be set together”SECURE_SSL_REDIRECT redirects any request where request.is_secure() is false. By
default is_secure() just asks whether the URL scheme is https.
Behind a proxy that terminates TLS, Django receives plain HTTP. So is_secure() is
always False, so Django redirects to HTTPS, so the proxy terminates TLS and forwards
plain HTTP again, so is_secure() is False. That is the loop, and it is the single
most common Django deployment failure.
SECURE_PROXY_SSL_HEADER is how Django learns the truth from the proxy.
SECURE_SSL_REDIRECT = TrueSECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")The header name is in request.META format — uppercase, underscores, and prefixed
with HTTP_, which Django adds to incoming headers automatically. "X-Forwarded-Proto"
in that tuple never matches anything.
Every way of getting this wrong fails closed
Section titled “Every way of getting this wrong fails closed”Worth stating plainly, because the setting has a reputation for being dangerous to
mistype. It is not. request.scheme reads the header, and each failure resolves to
http. Measured on Django 6.1 with SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"):
Incoming X-Forwarded-Proto |
request.scheme |
is_secure() |
|---|---|---|
| (absent) | http |
False |
https |
https |
True |
http |
http |
False |
httpsssss — a typo in the setting’s value |
http |
False |
https,http,http |
https |
True |
http,https |
http |
False |
https — padded |
https |
True |
https, but the setting names "X-Forwarded-Proto" |
http |
False |
Two things follow. Django takes the leftmost entry of a comma-separated list and
strips surrounding whitespace, so a chain of proxies is read at the client-facing end.
And a mistyped value or a mistyped header name both produce http, which means they
produce the redirect loop — noisy, immediate, and impossible to miss on the first
request.
A misconfiguration here announces itself. That is the reassuring half.
curl -sI http://example.com/ | head -1curl -s https://example.com/ -o /dev/null -w '%{http_code}\n'The first should be a 301 to https://, the second a 200. If the second is also a
redirect, you have the loop.
SECURE_SSL_REDIRECTRedirects HTTP to HTTPS in Django. Behind a TLS-terminating proxy Django sees plaintext, so turning this on without SECURE_PROXY_SSL_HEADER is the classic redirect loop.
- accepts
True|False- default
False- set in
settings.py
SECURE_PROXY_SSL_HEADERTells Django which request header proves the original request was HTTPS. The name must be in request.META form — HTTP_X_FORWARDED_PROTO, not X-Forwarded-Proto, which silently never matches. Only safe if the proxy strips that header from inbound requests.
- accepts
("HTTP_X_FORWARDED_PROTO", "https")- default
None- set in
settings.py
Your SECURE_SSL_REDIRECT setting is not set to True. Unless your site should be available over both SSL and non-SSL connections, you may want to either set this setting True or configure a load balancer or reverse-proxy server to redirect all connections to HTTPS.Django is not redirecting HTTP to HTTPS. Note the check itself offers the alternative — doing it at the proxy is an equally valid answer, and often the better one.
Read in: django/core/checks/security/base.py — id security.W008The SECURE_PROXY_SSL_HEADER setting must be a tuple containing two values.Raised as ImproperlyConfigured on the first request that reads request.scheme, not at startup — so a one-element tuple deploys clean and fails on live traffic.
Read in: django/http/request.py — HttpRequest.scheme; reproduced on Django 6.1The header is a promise the proxy has to keep
Section titled “The header is a promise the proxy has to keep”Django’s documentation carries an explicit warning on this setting: “Modifying this setting can compromise your site’s security. Ensure you fully understand your setup before changing it.” It then requires all of the following to be true:
- the app is genuinely behind a proxy;
- the proxy strips
X-Forwarded-Protofrom all incoming requests — so a client cannot send its own; - the proxy sets
X-Forwarded-Protoitself and only sendshttpsfor real HTTPS.
The second condition is the one that gets missed. If the proxy passes a
client-supplied X-Forwarded-Proto: https through untouched, then any attacker can
make is_secure() return True for a plaintext request — which re-enables Secure
cookies over plaintext and defeats the redirect. The setting is only as trustworthy as
the proxy’s stripping rule.
This is the seam described on application vs server: the application side is one line, and it is worthless without the server side.
Behind Cloudflare specifically
Section titled “Behind Cloudflare specifically”The stripping condition is satisfied. Cloudflare’s documentation states that for
incoming requests the value of X-Forwarded-Proto is set to the protocol the client
used, and that if the client set a different value it will be overwritten. A
visitor cannot forge this header through Cloudflare, so the spoofing case above is
closed by the proxy, not by anything you configure in Django.
The problem behind Cloudflare is a different one, and it is the reason this seam is
worth its own section: the header is truthful about a leg of the journey that is not
the one your Secure cookies travel on.
Cloudflare’s SSL/TLS mode decides what happens between Cloudflare and you. In
Flexible mode — in Cloudflare’s own words, traffic from visitors to Cloudflare can
be encrypted via HTTPS but traffic from Cloudflare to the origin server is not — the
visitor really did arrive over HTTPS, so X-Forwarded-Proto: https is accurate, and:
request.is_secure()returnsTrue;SECURE_SSL_REDIRECTis satisfied and stops redirecting;SESSION_COOKIE_SECUREandCSRF_COOKIE_SECUREare satisfied and set their cookies;- and every one of those cookies crosses the Cloudflare-to-origin hop in plaintext.
Nothing in Django is misconfigured, and nothing in Django can detect it. Every signal
the framework has says the request was secure, because for the visitor’s leg it was.
The state that looks healthiest from the application side — no loop, is_secure() true,
secure cookies flowing — is exactly what this produces.
Cloudflare’s own guidance is to use Full or Full (strict) rather than Flexible. Treat the SSL/TLS mode as part of this control: the Django half of the setting is correct and complete either way, and it is still not enough on its own.
The check cannot be run from inside Django, because Django is downstream of the hop in question. Ask the origin directly instead, bypassing the proxy:
# resolve the name to the ORIGIN address, not the proxy'scurl -sk --resolve example.com:443:<origin-ip> https://example.com/ \ -o /dev/null -w 'origin TLS: %{http_code}\n'An origin that cannot answer on 443 at all is an origin that is being reached over plaintext, whatever the visitor’s address bar shows.
Enabling the redirect without the proxy header is the loop. Enabling the proxy header without the proxy stripping it is the hole. Deploy both sides together.
Moving a proxy from a plaintext origin connection to an encrypted one is the change that fixes the case above, and it is the one that can take a site down: the origin now has to answer on 443 with a certificate the proxy will accept. Get the origin serving TLS and verify it directly first, then change the mode — in that order, not together.
SECURE_REDIRECT_EXEMPT takes a list of regexes for paths that must stay reachable
over HTTP — health checks that predate TLS are the usual case. Use it sparingly; every
entry is a path where the redirect no longer applies.
Related
Section titled “Related”- Session and CSRF cookie flags — the other thing that breaks behind a proxy
- Security headers — HSTS belongs here once the redirect works
- Application vs server — the proxy half of this control, and the two other places the same seam shows up