Skip to content

SSL redirect behind a proxy

Severity: mediumApplies to: Django 4.0+Applies to: Django 5.2 LTSApplies to: Django 6.1Facts last verified 2026-08-16 against Django 6.1

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.

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.

the fix
settings.py
SECURE_SSL_REDIRECT = True
SECURE_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.

verify it workedRun this in: http response
Terminal window
curl -sI http://example.com/ | head -1
curl -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.

settings on this page
SECURE_SSL_REDIRECT

Redirects 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
Read in: django/conf/global_settings.py @ stable/6.1.x
SECURE_PROXY_SSL_HEADER

Tells 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
Read in: django/conf/global_settings.py @ stable/6.1.x
if you see this
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.W008
The 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.1

The 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-Proto from all incoming requests — so a client cannot send its own;
  • the proxy sets X-Forwarded-Proto itself and only sends https for 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.

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() returns True;
  • SECURE_SSL_REDIRECT is satisfied and stops redirecting;
  • SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE are 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:

Terminal window
# resolve the name to the ORIGIN address, not the proxy's
curl -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.

before you ship this

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.