X-Forwarded-Proto in nginx: $scheme vs passing it through (tested)
Your app sits behind nginx, nginx talks plain HTTP to it, and the app needs to know whether the visitor used https. X-Forwarded-Proto is how it finds out. We checked what the backend really got under three common configs.
1. What the backend received
| nginx config | Client sends nothing | Client sends X-Forwarded-Proto: https |
|---|---|---|
no proxy_set_header X-Forwarded-Proto | (header absent) | https (client value passed through) |
proxy_set_header X-Forwarded-Proto $scheme; | http | http (client value replaced) |
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; | (header absent) | https |
- With no setting, nginx does not add the header, but it does forward one the client sent. So "I never set it" does not mean the backend never sees it.
$schemeis the scheme of the connection to this nginx. Our listener was plain HTTP, so it was alwayshttp, and it overwrote the client'shttps. That is correct when nginx itself terminates TLS, and wrong when a load balancer in front of it does.$http_x_forwarded_protois just the incoming request header. It carried the load balancer'shttpsthrough, and when nothing was sent, nginx omitted the header entirely (an empty value is not sent).
2. The redirect bugs
Our backend did two common things: after login it redirected to {proto}://{host}/dashboard, and on /secure it redirected to https unless X-Forwarded-Proto was https. With the request arriving as https from a load balancer (simulated by sending X-Forwarded-Proto: https):
$ curl -sD- -o/dev/null -H 'X-Forwarded-Proto: https' http://localhost:8961/scheme/login
HTTP/1.1 302 Found
Location: http://localhost/dashboard # downgraded to http
$ curl -sD- -o/dev/null -H 'X-Forwarded-Proto: https' http://localhost:8961/scheme/secure
HTTP/1.1 301 Moved Permanently
Location: https://localhost/secure # same URL, every time
$ curl -sD- -o/dev/null -H 'X-Forwarded-Proto: https' http://localhost:8961/pass/secure
HTTP/1.1 200 OK
With $scheme behind a TLS-terminating load balancer, the app always believed the request was http: login redirects pointed to http://, and the "force https" check answered every request, already https, with a 301 to itself. In a browser that is the ERR_TOO_MANY_REDIRECTS loop. Passing the header through fixed both. (Side note: proxy_set_header Host $host dropped our :8961 port from the redirect; use $http_host if the public port is not the default.)
3. Spoofing
The passthrough config trusts whatever the client sends. If anyone can reach nginx directly, not only through your load balancer, they can claim https on a plain-http request, and our /secure check returned 200 ok for it. Same for the no-setting config. With $scheme the client value was always replaced. The header is not a secret, so the risk is wrong security decisions (skipped https redirects, cookies marked or not marked Secure), not data theft by itself. Same trust problem as X-Forwarded-For.
4. Config that worked
# nginx terminates TLS itself:
proxy_set_header X-Forwarded-Proto $scheme;
# behind a TLS-terminating load balancer (nginx reachable only from it):
map $http_x_forwarded_proto $fwd_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
proxy_set_header X-Forwarded-Proto $fwd_proto;
We tested the map: no client header gave http (from $scheme), and https from the "load balancer" was passed through, so /secure returned 200. Lock the port down (firewall or allow/deny to the load balancer's addresses) so only the load balancer can set it.
5. Telling the framework to trust it (doc-based)
- Express:
app.set('trust proxy', ...)makesreq.protocolandreq.secureuse X-Forwarded-Proto. - Django:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"); Django's docs warn to set it only if your proxy strips or overwrites the client's value. - Rails: Rack reads X-Forwarded-Proto for
request.ssl?; withconfig.force_sslthe same $scheme mistake causes the redirect loop.
We did not run these frameworks; check their current docs. Related: sending X-Forwarded-For and other headers from Chrome.
Frequently asked questions
Does nginx send X-Forwarded-Proto by default?
No. In our test the backend got no X-Forwarded-Proto unless the config set one, but a header sent by the client was forwarded unchanged.
Should I use $scheme or $http_x_forwarded_proto?
$scheme if nginx terminates TLS itself. Behind a load balancer that terminates TLS, $scheme was always http in our test, so pass $http_x_forwarded_proto through (with a $scheme fallback via map) and only accept traffic from the load balancer.
Why do I get ERR_TOO_MANY_REDIRECTS behind a load balancer?
Typically the app thinks the request is http and redirects to https forever. We reproduced it with proxy_set_header X-Forwarded-Proto $scheme behind a simulated TLS load balancer: every https request got a 301 to itself.
Why are my redirects going to http:// instead of https://?
The app builds the URL from X-Forwarded-Proto (or the connection) and it says http. In our test that happened with $scheme behind a TLS-terminating proxy; passing the load balancer's header fixed it.
Can a client spoof X-Forwarded-Proto?
Yes. With no setting or with passthrough, a client-sent https reached the backend unchanged. With $scheme nginx replaced it. Only trust the header when the request came through your own proxy.
Written with AI assistance. Measured on 2026-10-01 with a throwaway nginx 1.31.6 container (nginx:alpine, plain HTTP listener) proxying to a small Node.js server that echoed the X-Forwarded-Proto it received and built redirects from it; requests made with curl -sD-, with and without a client-sent X-Forwarded-Proto: https to stand in for a TLS-terminating load balancer. Removed afterwards. Express, Django and Rails settings are doc-based, not tested. PlainHeaders is our extension; the other methods are free.