In Django, why is setting SECURE_PROXY_SSL_HEADER dangerous when the proxy passes client-supplied X-Forwarded-Proto headers through, and what does a spoofed value change?
answer
- the header becomes the truth
- leftmost value wins
- overwrite, never append
- everything that reads is_secure()
basics
~10 sSECURE_PROXY_SSL_HEADER makes request.is_secure() trust the named header. If clients can supply it, a plain-HTTP request claiming https is treated as secure, skipping the HTTPS redirect and misleading every is_secure()-based check.
solid answer
~40 s`SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")` turns a header into the source of truth for `request.scheme` and `is_secure()`. Django takes the header's leftmost comma-separated value, so a proxy that appends its own value after one sent by the client leaves the client's value in control. An attacker, or a man in the middle on plain HTTP, can then send `X-Forwarded-Proto: https` and the request counts as secure: `SECURE_SSL_REDIRECT` does not fire, `build_absolute_uri()` produces `https` links for a plain-HTTP page, and code that gates on `is_secure()` is fooled. Django's docs require three things before setting it: the app is behind a proxy, the proxy strips any incoming copy of the header, and it sets the header only for requests that really arrived over HTTPS.
code
python · 10 linesfrom django.test import RequestFactory, override_settings
rf = RequestFactory()
with override_settings(SECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO", "https")):
appended = rf.get("/", headers={"x-forwarded-proto": "https, http"})
assert appended.is_secure() # leftmost value wins: client-supplied https
overwritten = rf.get("/", headers={"x-forwarded-proto": "http"})
assert not overwritten.is_secure()go deeper
Recall that SECURE_PROXY_SSL_HEADER makes Django trust a header for is_secure(), so that header must come only from your proxy.
Explain the leftmost-value rule, the fallback when the header is absent, and why appending proxies are unsafe.
List what a spoofed value breaks (redirect, absolute URLs, CSRF assumptions, app logic) and verify the proxy strips the header with a real external request.
Own the trust boundary: firewall app servers to the proxy tier and document which hop writes forwarding headers.
## What the setting actually does By default, `request.is_secure()` answers a narrow question: did this request reach Django over HTTPS? Behind a TLS-terminating proxy that answer describes the wrong hop, so Django offers `SECURE_PROXY_SSL_HEADER`, a two-item tuple of a `request.META` header name and the value that means secure. Once set, `HttpRequest.scheme` does this: 1. look up the named header in `request.META`; 2. if it is present, split it on the first comma and strip the leftmost value; 3. return `https` if that value equals the configured value, otherwise `http`; 4. if the header is absent, fall back to the scheme of Django's own connection. A tuple of the wrong shape raises `ImproperlyConfigured` when the scheme is first read. The important consequence: **the header is now trusted input**. Whoever controls its leftmost value controls what Django believes about the connection. ## How a proxy can make it spoofable Proxies handle forwarding headers in one of three ways: | Proxy behaviour | Client sends `X-Forwarded-Proto: https` over HTTP | Result in Django | |---|---|---| | overwrites the header with its own value | replaced by `http` | correct: insecure | | appends its value to the client's | becomes `https, http` | **spoofed**: leftmost is `https` | | passes the client's header through untouched | stays `https` | **spoofed** | | strips the header and sets it only on real HTTPS | removed | correct: falls back to the connection, which is HTTP | Django's documentation lists the preconditions explicitly: the app is behind a proxy, the proxy strips the header from all incoming requests (even comma-separated lists), and the proxy sets it only for requests that originally came in over HTTPS. If any of these is false, the setting should stay `None`. ## What a spoofed value changes `is_secure()` feeds several behaviours, so one forged header has several effects: - **No HTTPS redirect.** `SECURE_SSL_REDIRECT` checks `is_secure()`, so the attacker's plain-HTTP request is served directly instead of redirected. On an open network this keeps a victim on HTTP if the attacker can rewrite their requests. - **Wrong absolute URLs.** `request.build_absolute_uri()` uses `request.scheme`, so links in the response and in emails claim `https` while the page was delivered insecurely, or the reverse when misconfigured the other way. - **CSRF assumptions.** Django applies stricter referer checks to secure requests; a request mislabelled as secure is checked under assumptions that do not hold for it. - **Application logic.** Any code that says "only do this over HTTPS" by checking `request.is_secure()` is fooled. HSTS is not the danger here: browsers ignore a `Strict-Transport-Security` header received over plain HTTP, so a spoofed request cannot plant it. ## Safe configurations 1. **Proxy overwrites.** Configure the proxy to set `X-Forwarded-Proto` to its own view of the scheme on every request. Then set `SECURE_PROXY_SSL_HEADER`. 2. **Direct exposure.** If Django is reachable directly as well as through the proxy, anyone bypassing the proxy can send the header. Firewall the application servers so only the proxy can reach them. 3. **Multiple proxies.** With a chain (a CDN, then a balancer), make sure the hop nearest Django is the one writing the header and that it does not simply append. 4. **No trustworthy header.** Leave the setting `None` and determine HTTPS another way, for example custom middleware that trusts a header only from known proxy addresses. ## The same question for other forwarded headers `SECURE_PROXY_SSL_HEADER` is one of several settings that turn a proxy header into trusted input. `USE_X_FORWARDED_HOST` and `USE_X_FORWARDED_PORT` (both `False` by default) do the same for the host and port that Django uses to build URLs. The rule is identical for all of them: enable one only if the hop in front of Django overwrites that header on every request. Host handling and `ALLOWED_HOSTS` have their own pitfalls; the point here is that a proxy that is careless with one forwarding header is usually careless with all of them. ## Testing the configuration From outside, send a plain-HTTP request with `X-Forwarded-Proto: https` and check that it is still redirected to HTTPS. From a Django shell or a test, assert that a request with the header but arriving through the real proxy path reports the expected scheme. The Django test client can simulate the header with `self.client.get("/", headers={"x-forwarded-proto": "https"})` to test your code paths, but only a request through the real proxy proves the proxy strips it.
- What does Django do if SECURE_PROXY_SSL_HEADER is set but the header is missing from a request?It falls back to the scheme of Django's own connection, the WSGI or ASGI scheme. It does not assume `http`. Behind a proxy speaking plain HTTP that is `http`, so a request that somehow skipped the header is treated as insecure, which is the safe direction.
- Why does Django use the leftmost value of a comma-separated X-Forwarded-Proto?In a chain, each proxy conventionally appends, so the leftmost entry describes the first hop, the client's original connection. That is only trustworthy if the first proxy discarded whatever the client sent; otherwise the leftmost entry is the client's own claim, which is why Django requires the proxy to strip the header.
saying these in an interview costs you the question
- Django uses the last value of a comma-separated X-Forwarded-Proto.
- Setting SECURE_PROXY_SSL_HEADER is harmless even if clients can send the header.
- A spoofed header lets an attacker plant HSTS over plain HTTP.
- If the header is missing, Django assumes the request was HTTPS.
- Appending the proxy's value to the client's header is as safe as overwriting it.