Express trust proxy: architecture note for IP, protocol and hostname behind a reverse proxy
An architecture note on Express trust proxy: how req.ip, req.protocol and req.hostname change behind a reverse proxy, the smallest safe trust setting, spoofing and rate-limit failure modes, and when to redesign.
30 Oct 2025, 13:17 UTC

The problem
An Express app behind a reverse proxy or load balancer will see the proxy as the TCP peer unless it explicitly decides which hops to trust. With trust proxy disabled, req.ip is the socket address, req.protocol is http, and req.hostname ignores X-Forwarded-Host. With trust proxy enabled, those values are derived from X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host headers supplied by the front end. The decision is a trust boundary, not a convenience toggle.
The useful takeaway: use the smallest trust you need, expressed as a hop count or explicit proxy address, and never trust headers from the public internet.
Requirements
When the app uses client identity for rate limiting, audit logging, HTTPS redirects, secure cookies, or host-based routing, it needs correct values for req.ip, req.secure, req.protocol and req.hostname.
Behind a terminating proxy those values are only correct if Express is told which proxy hops are authoritative. The headers in question are:
- X-Forwarded-For: comma-separated client IP chain
- X-Forwarded-Proto: original scheme
- X-Forwarded-Host: original host
Express does not read them by default. Enabling trust proxy moves authority for those properties from the socket to HTTP headers.
Smallest suitable design for one trusted proxy
Default: app.set('trust proxy', false). req.ip is the direct TCP peer. This is correct when the app is internet-facing.
One known reverse proxy in front, e.g., Nginx or an internal load balancer, with no other hops:
// app.js, run as the Node process owner
const express = require('express');
const app = express();
// trust exactly one hop
app.set('trust proxy', 1);
// or trust a specific address / CIDR
// app.set('trust proxy', '10.0.0.1');
// app.set('trust proxy', '10.0.0.0/8');
With trust proxy set to 1, Express uses the leftmost untrusted entry in X-Forwarded-For as the client IP, and respects X-Forwarded-Proto for req.secure and req.protocol. req.hostname will honor X-Forwarded-Host when trusted.
Do not use app.set('trust proxy', true) unless the app is unreachable except through the proxy. On shared networks true effectively trusts every peer.
Trust and data boundaries
Enabling trust proxy shifts the source of truth for identity and scheme from the transport layer to headers. The leftmost entries in X-Forwarded-For are client-controlled. Express applies the trust configuration to strip trusted hops from the right, then takes the next IP as req.ip. req.ips contains the full chain after stripping.
Enumerating your own proxy addresses is safer than a hop count because it binds trust to a network location rather than an assumed depth. A custom function is accepted by Express and can encode more complex rules, but it must be reviewed per major version.
Consume forwarded data via req.ip, req.ips, req.protocol, req.secure and req.hostname. Reading req.headers['x-forwarded-for'] directly bypasses the trust logic.
Failure modes
Trust too broad. A hop count that is too high or true allows a client to prepend arbitrary IPs to X-Forwarded-For. req.ip can be spoofed, breaking IP rate limits, allowlists, geo rules and audit trails.
Trust disabled behind a proxy. Every request appears to come from the proxy IP. Rate limiting keys collide, logging loses client granularity, and HTTPS redirects may loop because req.secure stays false when TLS is terminated upstream.
Host confusion. When trusted, req.hostname follows X-Forwarded-Host. If the app uses hostname for routing or origin checks, an attacker can influence it unless the proxy validates Host.
Operational checks
- Log the effective trust setting at startup and a sample req.ip per environment to catch drift between staging and production.
- In staging behind the real proxy, send a request with a forged X-Forwarded-For header and confirm req.ip reflects only the hop your own proxy appended.
- Toggle the setting and confirm req.protocol and req.secure flip correctly for TLS terminated at the proxy, and that secure cookies are still set.
- Log req.ip alongside the raw X-Forwarded-For chain in staging for a short period to validate the proxy appends rather than overwrites.
- Alert on rate-limit key collisions or sudden concentration of traffic to a single IP, which can indicate trust misconfiguration.
Verify the installed major version before changing configuration, e.g., npm ls express in the project directory. Accepted forms for trust proxy vary slightly between Express 4 and 5.
When the design must change
Multiple chained proxies or a CDN: a single hop count is brittle. Prefer an explicit CIDR list of your own edge and origin proxies, or a custom trust function that matches only those networks.
Proxy that overwrites X-Forwarded-For instead of appending, or uses non-standard header names. Validate with real traffic; if behavior is opaque, do not rely on client IP for security decisions.
Opaque platform ingress where hop count is unknown. Prefer a proxy-generated request identifier over client IP for rate limiting and audit.
Need for stronger identity. IP is weak. When the threat model requires non-repudiation, move to signed tokens or mutual TLS and treat IP as observability only.
Limitations
Express trust proxy does not authenticate headers, it only applies a hop filter. It cannot protect against a compromised internal proxy. Some proxies and CDNs rewrite headers, so assumptions must be validated per deployment.
Core semantics are stable across Express 4 and 5, but edge cases and accepted value forms should be confirmed against the API documentation for the major version actually installed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.