Skip to content

SSL redirect behind a proxy

Severity: mediumApplies to: Django 4.0+Applies to: Django 5.2 LTSApplies to: Django 6.0Facts last verified 2026-07-26 against Django 6.0 (main)

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 silently never matches.

Django accepts the header if its value is https, or if the leftmost value of a comma-separated list is https (e.g. https,http,http) — worth knowing when there is more than one proxy in the chain.

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.

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 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.

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.

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.