Stop Trusting Your Reverse Proxy: Secure Django the Right Way
1: Intro
Most Django developers breathe easy once they put their app behind a reverse proxy.
Nginx, Caddy, AWS ALB, Cloudflare — they handle HTTPS, forward requests, and set a few headers.
On paper, that sounds like bullet-proof security.
But here’s the catch: Django itself doesn’t actually see the client’s HTTPS connection.
It relies on signals from the proxy — headers like X-Forwarded-Proto — to decide whether a request is secure.
If those signals are wrong or spoofed, Django can be tricked into making dangerous assumptions.
That’s how you end up with:
- Cookies marked secure but sent over plain HTTP
- CSRF protections failing mysteriously in production
- Infinite redirect loops you can’t explain
The painful truth?
Your reverse proxy can’t be your only line of defense.
In this article, I’ll unpack why trusting proxy headers blindly is risky, show real scenarios where Django apps break because of it, and walk through the right way to secure Django in production.
2: How Django Decides If a Request Is Secure
Before we talk about misconfigurations, we need to understand how Django actually decides if a request is “secure.”
Django doesn’t know whether the client connected with HTTP or HTTPS. That information gets lost once the request passes through a reverse proxy or load balancer.
Instead, Django relies on a mix of:
- request.is_secure() → Django’s internal check to decide if the request was HTTPS.
- SECURE_PROXY_SSL_HEADER → A setting that tells Django which header to trust (for example, ('HTTP_X_FORWARDED_PROTO', 'https')).
- SECURE_SSL_REDIRECT → If enabled, forces all requests onto HTTPS, based on whether request.is_secure() returns true or false.
- Here’s the critical detail:
- If Django believes a request is secure, it will mark cookies as secure, enforce CSRF/referrer checks, and allow HTTPS-only redirects.
- If it believes the request is not secure, it might redirect to HTTPS, or worse — accidentally serve secure cookies over plain HTTP.
Imagine this flow:
Client ---> Proxy (terminates HTTPS) ---> Django
- If the proxy sets X-Forwarded-Proto: https and Django is configured to trust it → ✅ all good.
- If the proxy passes headers from the client unchecked → ❌ attackers can spoof them.
- If the proxy uses a different header than Django expects → ❌ redirect loops and CSRF errors.
That’s why misaligned proxy + Django configurations are so common—and why simply trusting the proxy can backfire.
3: The Problem with Trusting Proxies Blindly
When Django sits behind a reverse proxy, it’s easy to assume the setup is safe.
But if your configuration isn’t airtight, attackers — or even just misaligned headers — can break core parts of your app.
Here are the three biggest failure modes:
1. Header Spoofing
Most proxies forward the header X-Forwarded-Proto to signal whether the client used HTTP or HTTPS. If you haven’t explicitly told your proxy to strip client-supplied headers, an attacker can inject their own:
GET /account
Host: yourapp.com
X-Forwarded-Proto: https
- Django sees this and thinks: “Great, this request is secure!”
- It sets cookies with the Secure flag — but sends them over plain HTTP.
- It skips HTTPS redirection logic.
Result: cookies leak, sessions hijackable.
2. CSRF Failures and Host Mismatches
CSRF protection depends on Django knowing the correct Origin and Host. But proxies often rewrite these headers — or forward them inconsistently.
- If the Host is rewritten incorrectly, Django rejects legitimate CSRF requests.
- Worse, if Host headers are too permissive, attackers can trick Django into accepting cross-origin requests it shouldn’t.
These bugs are infamous for showing up only in production, after you’ve deployed behind ALB, Cloudflare, or Nginx.
3. Redirect Loops from SSL Enforcement
A common security best practice is to enable:
SECURE_SSL_REDIRECT = True
This tells Django to redirect all HTTP requests to HTTPS.
But if Django never recognizes a request as secure (because the wrong header is set, or not trusted), here’s what happens:
- Client requests https://yourapp.com.
- Proxy terminates TLS, sends traffic to Django as plain HTTP.
- Django thinks it’s insecure → redirects to HTTPS.
- Proxy receives the request again → same cycle repeats.
Boom — infinite redirect loop.
The scary part? All of these failures look like random, unrelated bugs: cookies disappearing, CSRF errors, endless redirects.
But the root cause is the same: trusting your proxy blindly.
4: When Proxies Break Django Apps
Let’s make this real. These aren’t theoretical security lectures — they’re bugs you or your teammates have probably hit in production without realizing the proxy was the culprit.
The “Session Won’t Stick” Bug
A developer enables:
SESSION_COOKIE_SECURE = True
expecting cookies to only travel over HTTPS.
But the proxy isn’t stripping client-supplied headers. An attacker (or just a misaligned config) injects X-Forwarded-Proto: https.
- Django thinks the request is HTTPS and sets the cookie with the Secure flag.
- The browser refuses to send that cookie back over plain HTTP.
- Result? Users complain: “I keep getting logged out randomly.”
The CSRF Nightmare in Production
Everything works on staging. Then you deploy behind AWS ALB. Suddenly, users get endless CSRF errors.
Why? ALB rewrites the Host header to its internal domain, while Django expects the public one. CSRF origin checks explode. Developers waste hours digging through JavaScript, tokens, and frontend code — when the real issue was the proxy all along.
The Infinite Redirect Loop
A team enables:
SECURE_SSL_REDIRECT = True
But behind Cloudflare, users get trapped in endless HTTPS redirects.
Why? Cloudflare is setting X-Forwarded-Proto: https, but Django doesn’t know it should trust that header (SECURE_PROXY_SSL_HEADER is unset).
So Django thinks every request is insecure → redirects forever.
Conclusion
These aren’t corner cases. They’re everyday realities when Django apps leave localhost and meet the messy world of Nginx, Caddy, ALB, or Cloudflare.
The common thread: proxies are doing their job, Django is doing its job — but neither agrees on what “secure” means.
5: Secure Django the Right Way
Now that we’ve seen how proxies can quietly break Django’s security, let’s walk through the right way to configure things.
The key principle is simple:
👉 Your proxy should never pass along untrusted headers. Django should only trust headers you explicitly configure.Here’s a step-by-step approach:
1. Terminate TLS at a Trusted Proxy
Your proxy (Nginx, AWS ALB, Cloudflare, etc.) should be the single point of TLS termination.
- That means:
- Clients connect via HTTPS.
- Proxy handles the TLS handshake.
- Proxy forwards traffic to Django over plain HTTP or private TLS.
🤔❓ Why? Because then the proxy knows whether the original request was HTTPS — and Django can rely on that signal.
2. Strip and Overwrite Forwarding Headers
Make sure your proxy removes any client-supplied headers like X-Forwarded-Proto or X-Forwarded-For. It should overwrite them with its own trusted values.
In Nginx, for example:
proxy_set_header Host $host; # real host
proxy_set_header X-Forwarded-Host $host; # optional
proxy_set_header X-Forwarded-Proto $scheme; # http/https
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
This ensures attackers can’t spoof headers.
Set SECURE_PROXY_SSL_HEADER in Django
Tell Django exactly which header to trust:
# settings.py
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
⚠️ Important
⚠️ Important: Only set this if your proxy is under your control and you know it overwrites headers. Otherwise, Django could be tricked into thinking plain HTTP is HTTPS.4. Enforce HTTPS in Django
Now that Django can reliably detect HTTPS, enable its security features:
# settings.py
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
- These settings:
- Force all requests to HTTPS.
- Protect cookies from being sent over HTTP.
- Tell browsers to always use HTTPS for your domain (HSTS).
5. Lock Down Hosts and CSRF Origins
Misconfigured Host headers are a common CSRF killer.
# settings.py
ALLOWED_HOSTS = ["yourapp.com", "www.yourapp.com"]
CSRF_TRUSTED_ORIGINS = ["https://yourapp.com", "https://www.yourapp.com"]
Be strict. Only list the domains you actually serve.
✅ With these steps, Django no longer blindly trusts whatever headers arrive. Instead, it cooperates with your proxy in a clear contract of trust.
6: Safer Alternatives to Header Trust
Even with SECURE_PROXY_SSL_HEADER set correctly, you’re still leaning on headers to tell Django what’s secure. That works — but there are more robust options if you want extra safety.
Use the PROXY Protocol
Instead of forwarding information in headers, the proxy can use the PROXY protocol to pass the original client’s IP and protocol to Django.
- Harder to spoof than headers.
- Supported by HAProxy, AWS NLB, and some Nginx builds.
- Django can read this info reliably if your stack supports it.
Mutual TLS Between Proxy and App
Don’t just trust the proxy because it’s “inside your network.”
- Use mTLS (mutual TLS) so Django only accepts traffic from proxies presenting a valid certificate.
- Prevents rogue services or misconfigured internal traffic from reaching Django.
Private Networking (VPC or Internal Subnet)
If your proxy and Django run in the same cloud, keep their connection private:
- Put Django servers behind a private subnet or VPC.
- Only allow traffic from the proxy’s internal IPs.
- No exposure to the public internet, even by accident.
TLS Passthrough to Django
In some setups, you can skip proxy header trust entirely by letting Django terminate TLS itself:
- Client connects directly to Django over HTTPS.
- No need for SECURE_PROXY_SSL_HEADER at all.
- Tradeoff: your app servers now handle certificate management and TLS performance.
💡 These alternatives aren’t required for every project. But if you’re building for high-security environments, they’re worth considering.
7: Testing & Verification
Config is half the job. The other half is proving your proxy ↔ Django trust contract actually works. Do these in staging first, then bake the checks into CI/smoke tests.
1) Verify Django’s idea of the scheme
Add a temporary middleware to log what Django thinks:
# middleware.py
def log_scheme(get_response):
def mw(request):
print("scheme=", request.scheme, "is_secure=", request.is_secure())
return get_response(request)
return mw
Hit both URLs through the proxy:
curl -I http://yourapp.com
curl -I https://yourapp.com
- Expect:
- http:// → scheme=http, is_secure=False
- https:// → scheme=https, is_secure=True
- If both show http → Django isn’t trusting the header.
- If both show https → your proxy is lying.
2) Prove header spoofing is ignored
Try to trick it:
curl -I https://yourapp.com -H "X-Forwarded-Proto: http"
- Expect:
- Django still logs scheme=https.
- If it flips to http, your proxy isn’t overwriting client headers.
3) Check secure cookie flags
After login (or any set-cookie response):
curl -I https://yourapp.com/login
Look at Set-Cookie. Expect:
Secure; HttpOnly; SameSite=Lax (or your chosen SameSite).
Then try over HTTP (if reachable):
curl -I http://yourapp.com/login
Expect: either a redirect to HTTPS, or no sensitive cookies set on plain HTTP.
4) Confirm HSTS
In the HTTPS response:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Expect: present in prod, absent in local/dev. Don’t enable preload until you’re ready.
5) Lock down Host
Bad Host http header should be rejected:
curl -I https://yourapp.com -H "Host: evil.com"
- Expect:
- 400 Bad Request response
- If you get a normal response, your ALLOWED_HOSTS or proxy Host handling is wrong.
6) CSRF origins are exact (schemes included)
Your POST with a valid CSRF token from https://yourapp.com should pass.
From a fake origin:
curl -sS -D- -o /dev/null \
-H "Origin: https://attacker.com" \
-H "Cookie: csrftoken=...; sessionid=..." \
-H "X-CSRFToken: ..." \
https://yourapp.com/protected-endpoint
Expect: 403 http response code unless attacker.com is in CSRF_TRUSTED_ORIGINS (it shouldn’t be).
7) Redirect behavior is sane
With SECURE_SSL_REDIRECT=True:
curl -I http://yourapp.com/
Expect: single 301/302 to https://yourapp.com/, not a loop. Test Cloudflare/ALB paths too (they often follow different listeners).
8) Health checks don’t loop
If your LB probes /healthz over HTTP and you use SECURE_REDIRECT_EXEMPT:
curl -I http://yourapp.com/healthz
Expect: 200 http response code (or whatever your health endpoint returns), not a 301 to HTTPS.
9) Log what matters (short-term)
Temporarily log the headers your trust relies on:
# simple example in the same middleware
print("xfp=", request.META.get("HTTP_X_FORWARDED_PROTO"),
"host=", request.get_host(),
"xff=", request.META.get("HTTP_X_FORWARDED_FOR"))
You’re looking for consistency across the stack and absence of client-controlled garbage.
10) Automate as smoke tests
- Add the curl checks to a CI job that runs against staging after deploy.
- Fail the build if: HSTS missing (prod), spoofed X-Forwarded-Proto is honored, evil.com Host doesn’t 400, or HTTP causes redirect loops.
💡 Pro tip: keep the scheme-logging middleware behind an env flag. Turn it on when debugging, off when done. Your future self will thank you.
8: Final Thoughts
Reverse proxies are powerful — but they aren’t magical security blankets. Django will happily trust whatever your proxy whispers in its ear, even if that whisper came from a malicious client or a misaligned config.
The fix is simple but critical:
- Don’t rely on “default” proxy behavior.
- Make proxy headers explicit.
- Make Django’s trust explicit.
- Verify with real tests, not assumptions.
- Key points of Django behing proxy server
- Never trust client headers. Your proxy must strip and overwrite them.
- Be explicit in Django. Set SECURE_PROXY_SSL_HEADER only if you know the proxy enforces it.
- Enforce HTTPS. Redirect, secure cookies, enable HSTS (stage it carefully).
- Lock it down. ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS should be strict, not lazy.
- Think beyond headers. PROXY protocol, mTLS, and private networking give stronger guarantees when you need them.
- Always test. A single curl -H "Host: evil.com" tells you more than a thousand code reviews.
👉 Stop assuming your reverse proxy will “do the right thing.”
Build a clear trust contract. Test it. Enforce it.
When Django and your proxy agree on what “secure” really means, your app stops breaking in weird ways and starts behaving like the fortress you intended.