A Django site behind a TLS-terminating load balancer enables SECURE_SSL_REDIRECT and every page now loops with redirects; why, and how do you fix it?
answer
- what is_secure() sees
- the proxy-to-Django hop
- a forwarded protocol header
- only if the proxy overwrites it
- exempt the health check
basics
~10 sThe balancer talks plain HTTP to Django, so request.is_secure() is False and SecurityMiddleware redirects HTTPS users again forever. Set SECURE_PROXY_SSL_HEADER to the header the balancer sets, or redirect at the balancer instead.
solid answer
~40 s`SecurityMiddleware` redirects when `SECURE_SSL_REDIRECT` is on and `request.is_secure()` is false. Behind a balancer that terminates TLS and forwards plain HTTP, `is_secure()` reflects that inner hop, so it is false even for a user on HTTPS: Django sends a 301 to the same `https://` URL, the browser follows, and the cycle repeats until the browser gives up with too many redirects. The fix is to tell Django which header marks the original scheme, `SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")`, but only when the balancer overwrites that header on every request. Alternatively, do the redirect at the balancer and leave `SECURE_SSL_REDIRECT` off. I also add the balancer's plain-HTTP health-check path to `SECURE_REDIRECT_EXEMPT`, without a leading slash.
code
python · 5 lines# settings.py, behind a load balancer that terminates TLS and
# overwrites X-Forwarded-Proto on every request
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [r"^healthz/$"] # no leading slash: it is stripped before matchinggo deeper
Recall that SECURE_SSL_REDIRECT redirects when request.is_secure() is false and that a TLS-ending proxy makes Django see plain HTTP.
Explain the loop step by step and the SECURE_PROXY_SSL_HEADER tuple format, including the HTTP_ META name.
Choose between redirecting at the edge and in Django, require that the proxy overwrites the header, exempt health checks, and check build_absolute_uri and HSTS afterwards.
Standardise how every service learns the original scheme so each team does not reinvent proxy trust.
## The setup A typical deployment puts a **load balancer** or reverse proxy in front of Django. The balancer holds the certificate and **terminates TLS**: browsers speak HTTPS to it, and it forwards requests to the application servers over plain HTTP on a private network. Someone then enables `SECURE_SSL_REDIRECT = True` to force HTTPS, and the site stops loading with a too-many-redirects error. ## Why it loops `SecurityMiddleware.process_request()` redirects when all of these hold: 1. `SECURE_SSL_REDIRECT` is `True`; 2. `request.is_secure()` is `False`; 3. the path (with its leading slash stripped) matches no pattern in `SECURE_REDIRECT_EXEMPT`. `is_secure()` returns `request.scheme == "https"`. With `SECURE_PROXY_SSL_HEADER` unset, the scheme comes from the server interface (`wsgi.url_scheme` under WSGI), which describes the balancer-to-Django hop: plain HTTP. So: - the user requests `https://shop.example.com/cart/`; - the balancer forwards it as HTTP; - Django sees an insecure request and returns a **301** to `https://shop.example.com/cart/`; - the browser follows to the same URL and the same thing happens again. The redirect is permanent, which also means browsers may cache it; fix configuration before testing again with a clean browser. ## Fix 1: tell Django which header carries the original scheme Most balancers can add a header such as `X-Forwarded-Proto: https` on requests that arrived over HTTPS. Django reads it through: ```python SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") ``` The header name is written as it appears in `request.META`: upper case, dashes turned into underscores, prefixed with `HTTP_`. When the header is present, Django takes its leftmost comma-separated value and treats the request as secure if it equals the second element of the tuple. When the header is absent, it falls back to the scheme of the connection itself. This setting is only safe if the balancer **overwrites** the header on every request, discarding whatever the client sent. Otherwise a client can send `X-Forwarded-Proto: https` over plain HTTP and be treated as secure. ## Fix 2: redirect at the edge If the balancer can redirect HTTP to HTTPS itself, Django never sees plain-HTTP user traffic, and `SECURE_SSL_REDIRECT` can stay `False`. You still want `SECURE_PROXY_SSL_HEADER` for everything else that depends on `is_secure()`. ## Why is_secure() matters beyond the redirect | Feature | Depends on `is_secure()` | Symptom when wrong | |---|---|---| | `SECURE_SSL_REDIRECT` | yes | redirect loop | | HSTS header (`SECURE_HSTS_SECONDS`) | sent only on secure requests | header never appears | | `request.build_absolute_uri()` | uses `request.scheme` | emails and API responses link to `http://` | | CSRF checks on HTTPS | stricter referer checks for secure requests | behaviour differs from what was tested | So the proxy header is not just a redirect fix; it makes Django's view of the request match the user's. ## Health checks and other exemptions Balancers often probe instances over plain HTTP. With the redirect on, the probe gets a 301 and may mark every instance unhealthy. Exempt the probe path with a regular expression, remembering that `SecurityMiddleware` strips the leading slash before matching: ```python SECURE_REDIRECT_EXEMPT = [r"^healthz/$"] ``` `SECURE_SSL_HOST` can send redirects to a different host, for example a canonical `www` name; if it is `None`, the redirect keeps the request's host. ## Seeing what Django sees When the loop is not obvious, look at the request from Django's side rather than the browser's: - `curl -I http://...` and `curl -I https://...` against the public URL show the `Location` header of each 301 and whether it points back at itself; - a temporary, staff-only debug view that returns `request.scheme`, `request.is_secure()` and `request.META.get("HTTP_X_FORWARDED_PROTO")` shows exactly what the balancer forwards; - application logs of the redirecting request show the path and host Django used to build the target. If `request.scheme` is `http` while the forwarded header says `https`, the setting is missing or misnamed; if the header is absent, the balancer is not sending it. ## Checklist - Confirm how the balancer forwards: plain HTTP or re-encrypted, and which header it sets. - Confirm it strips client-supplied copies of that header. - Set `SECURE_PROXY_SSL_HEADER` accordingly, or leave it `None` and find another signal. - Exempt health checks, or point them at an HTTPS listener.
- If the balancer re-encrypts traffic to Django over HTTPS, is there still a problem?The loop goes away, because Django's own connection is HTTPS. But then `is_secure()` is true for every request, including users who arrived over plain HTTP, so `SECURE_SSL_REDIRECT` never fires for them. The redirect has to happen at the balancer, or Django still needs a forwarded-scheme header to tell the two apart.
- Why must SECURE_REDIRECT_EXEMPT patterns omit the leading slash?`SecurityMiddleware` matches the patterns against `request.path.lstrip("/")`, so `/healthz/` becomes `healthz/`. A pattern like `r"^/healthz/$"` never matches and the health check keeps receiving 301s.
saying these in an interview costs you the question
- The loop means the TLS certificate is invalid.
- Behind a proxy, Django detects HTTPS automatically from the Host header.
- Set SECURE_PROXY_SSL_HEADER even if clients can send the header through the proxy.
- SECURE_REDIRECT_EXEMPT patterns should start with a slash.
- The HTTPS redirect is a temporary 302, so browsers never cache it.