← Toutes les actualités
security4 min de lecture

The X-Forwarded-For header your rate limiter trusts is whatever the client says it is

Par Seaworthy · 21 août 2026

Networking equipment with ethernet cables connected in a server rack

Photo by Albert Stoynov on Unsplash

A rate limiter behind a load balancer needs the real client IP, and the obvious way to get it looks like this:

function getClientIp(req) {
  return req.headers['x-forwarded-for']?.split(',')[0] || req.socket.remoteAddress
}

app.use((req, res, next) => {
  const ip = getClientIp(req)
  if (isRateLimited(ip)) return res.status(429).send('Too many requests')
  next()
})

It works in local testing, it works in staging, and it works in production too, right up until someone notices that X-Forwarded-For is a request header, and request headers are things the client controls.

The header is only trustworthy if you can prove where it came from

When a request passes through a reverse proxy or load balancer, that proxy appends the connecting IP to X-Forwarded-For before forwarding the request onward. That's the mechanism the tutorials are describing, and it's correct as far as it goes. What it leaves out is that nothing stops a client from setting that same header on the original request, before it ever reaches your proxy. If your application code reads the first value in the comma-separated list and treats it as gospel, an attacker sends X-Forwarded-For: 1.2.3.4 and your rate limiter now believes every request came from 1.2.3.4, no matter how many requests actually arrived from the attacker's real address.

The practical impact isn't subtle. A rate limit that's supposed to cap abusive traffic per IP becomes trivially bypassable by rotating the header value on each request. An "internal only" endpoint gated on an IP allowlist, the kind of check that reads if (ALLOWED_IPS.includes(getClientIp(req))), can be walked straight past by anyone who knows which IP is on the list, since they only need to claim to be it. None of this requires any special access. It's a single header on an ordinary HTTP request.

Trust the hop, not the header

The fix isn't to stop using X-Forwarded-For, it's to only read the portion of it that your own infrastructure appended, and ignore anything an untrusted client prepended before that point. In Express this is what the trust proxy setting is for, and it needs to be a specific value, not true:

// Only trust X-Forwarded-For values appended by your own load balancer
app.set('trust proxy', ['127.0.0.1', '10.0.0.0/8'])

app.use((req, res, next) => {
  const ip = req.ip // resolved correctly, using only the trusted hop
  if (isRateLimited(ip)) return res.status(429).send('Too many requests')
  next()
})

With a specific trusted address or CIDR range configured, Express walks the X-Forwarded-For chain starting from the rightmost entry and stops once it's consumed as many hops as your trusted proxy count allows, discarding anything further left in the chain as attacker-supplied. Setting trust proxy to true defeats this entirely, since it tells Express to trust every hop in the chain, including the one the client wrote themselves.

If you're behind Cloudflare, AWS ALB, or a similar managed edge, the same principle applies with a provider-specific header instead: Cloudflare sends CF-Connecting-IP, which is only meaningful if you've also locked down direct access to the origin so requests can't skip Cloudflare entirely. A trusted header behind an untrusted network path is no better than the header alone.

Test it by pretending to be someone else

The fastest way to confirm the fix worked is to try to defeat it:

curl -H "X-Forwarded-For: 8.8.8.8" https://your-app.example.com/api/whoami

If the endpoint reports back 8.8.8.8 as the client IP when you didn't come anywhere near Google's network, the header is still being trusted unconditionally, regardless of what the trust proxy documentation says should be happening.

Why this keeps showing up in generated code

Every tutorial on reading a client's real IP behind a proxy says the same thing: read X-Forwarded-For. That's not wrong, it's incomplete, because the missing half of the sentence, "but only trust the hop count that matches your own proxy topology," depends on infrastructure details a code generation prompt like "add rate limiting by IP" doesn't include. The header-reading code and the security-relevant code look identical at the source level. The difference is entirely in a configuration value that has to match your actual deployment, which is exactly the kind of thing that's easy to skip when the naive version already appears to work.

Seaworthy flags direct reads of x-forwarded-for or x-real-ip headers used for rate limiting, IP allowlisting, or logging that aren't paired with an explicit trusted-proxy configuration, since an unpaired read means the header is fully attacker-controlled.

Cet article a été généré par IA et résume des sources publiquement disponibles.

Par les créateurs de Seaworthy

Seaworthy analyse votre dépôt pour détecter les problèmes abordés dans des articles comme celui-ci.

Failles de sécurité, secrets exposés et mauvaises configurations détectés avant le déploiement. Gratuit, aucun compte requis.

$ npx seaworthycode