Blog
Don't Trust X-Forwarded-For
- security
- laravel
- typescript
- networking
You add rate limiting. You log who triggered a password reset. You block a noisy IP after a scrape.
All of that hangs on one value: the client's IP. And in most production setups, the IP your framework hands you is only as honest as your proxy config.
Why the header exists, and why it lies
Without a reverse proxy, $request->ip() in Laravel or req.ip in Express reads the TCP peer. Simple enough.
Put a load balancer, CDN, or nginx in front of the app and the TCP peer becomes that proxy. Every request looks like it came from the same one or two internal addresses. Your rate limiter collapses into a single shared bucket for every user on the site, and your audit log blames the load balancer for everything.
So proxies pass the original address in a header, usually X-Forwarded-For. Each proxy in the chain appends the IP that connected to it:
X-Forwarded-For: client, proxy1, proxy2Here's the catch. "Appends" means the proxy keeps whatever was already in the header when the request showed up from the internet. If a user sends X-Forwarded-For: 203.0.113.9, a standard load balancer turns that into:
X-Forwarded-For: 203.0.113.9, 198.51.100.42Read that header naively from the left and the attacker gets whatever IP they typed. Read X-Forwarded-For directly in your controller instead of going through the framework, and you just built a "pick your own rate limit bucket" feature.
Walk from the right, and only through proxies you own
The safe rule has two parts:
- Only read forwarded headers when the TCP peer is one of your own proxies.
- Walk
X-Forwarded-Forfrom right to left, skipping trusted proxy hops, and stop at the first IP you don't trust.
In Laravel, configure trustProxies in bootstrap/app.php (or App\Http\Middleware\TrustProxies in older apps) with your actual proxy CIDRs:
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(
at: [
'10.0.0.0/8',
'172.16.0.0/12',
],
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO
);
})On AWS Elastic Load Balancing, swap headers to Request::HEADER_X_FORWARDED_AWS_ELB.
Watch out for the two shortcuts people copy from Stack Overflow:
at: '*'trusts whatever IP connected directly to PHP (REMOTE_ADDR). If anything can reach your origin port directly, bypassing Cloudflare or the load balancer, it can spoof any IP it wants. And if you have two hops (say Cloudflare into an ALB),*only trusts the ALB and stops early, handing you a Cloudflare edge IP instead of the user's.at: '0.0.0.0/0'is worse. It tells Symfony that every IP in the world is a trusted proxy, so it strips the whole chain from right to left and hands back the leftmost value, the exact one the client forged.
Express makes the same trap a one-liner
In Express, app.set('trust proxy', true) looks harmless. It isn't. Setting it to true tells Express to use the leftmost entry in X-Forwarded-For.
Unless your edge proxy explicitly strips incoming X-Forwarded-For headers before adding its own (and most don't by default, AWS ALB and nginx proxy_add_x_forwarded_for both append), any client on the internet can spoof req.ip even when your app server is locked in a private subnet.
Give Express a hop count or a subnet list instead:
import express from 'express';
import rateLimit from 'express-rate-limit';
const app = express();
// One trusted reverse proxy (e.g. ALB or nginx) in front of Node.
// Use 2 if requests always go CDN -> LB -> Node, or pass proxy CIDRs.
app.set('trust proxy', 1);
const apiLimiter = rateLimit({
windowMs: 60_000,
max: 60,
});
app.use('/api', apiLimiter);Notice there's no custom keyGenerator reading req.headers['x-forwarded-for']. express-rate-limit already keys on req.ip out of the box, and recent versions will actually warn you with ERR_ERL_PERMISSIVE_TRUST_PROXY if you accidentally left trust proxy set to true. Writing your own keyGenerator turns that safety check off.
One wrinkle with numeric hop counts: if some traffic goes through a CDN and a load balancer (two hops) while other traffic hits the load balancer directly (one hop), a fixed number will be wrong on one of those paths. Subnet lists (['loopback', 'uniquelocal'] plus your CDN's published ranges) handle uneven paths better, as long as random tenants can't talk to your origin directly.
Don't bypass the origin lock
Proxy config in code only works if nobody can talk to the app server around the proxy.
If you're behind Cloudflare or an ALB, lock the origin security group or firewall so port 80 and 443 only accept connections from the load balancer or the CDN's IP ranges. Otherwise someone finds your origin IP in historical DNS records, connects straight to it with a fake header, and skips your edge rules and WAF entirely.
What an IP is good for (and what it isn't)
Even when resolved cleanly, a client IP is a noisy signal, not an identity.
- Good: rate limits, brute-force throttling, rough geo defaults, and audit breadcrumbs alongside a user ID.
- Careful: hard IP bans. Mobile carriers, hotel Wi-Fi, and corporate NATs put thousands of real people behind one address. Blocking an IP for a week can lock out a whole office. Prefer account or token limits once someone is authenticated.
- Never: authorization. Don't write
if ($request->ip() === $officeIp)around an admin tool and call it auth. Put real authentication in front of it, and treat network allowlists as a second layer.
Test it from outside in thirty seconds
Before you trust $request->ip() or req.ip for anything security-related, hit a temporary debug route or check your access logs from outside your network with a forged header:
curl -H 'X-Forwarded-For: 203.0.113.9' https://your-app.example.com/Check what your app resolved as the client IP:
- If it logged your load balancer's private IP, proxy trust isn't turned on yet.
- If it logged
203.0.113.9, you're trusting too many hops (or reading the raw header), and anyone can spoof their IP. - If it logged your real public IP, your hop count and trust list match reality.
Check it again whenever you add a CDN or move hosts. One extra proxy in front shifts the whole chain by one hop, and your app won't complain when it starts reading the wrong column.