The X-Forwarded-For Header: Getting the Real Client IP

X-Forwarded-For tells you the original client IP behind proxies and load balancers. The semantics, the spoofing risk, and the safe patterns for trusting it.

The X-Forwarded-For Header: Getting the Real Client IP

Your application reads req.ip. It returns 10.0.0.5 for every request. You’re confused — your users aren’t on a private network. Then you realize: the IP your server sees is the load balancer’s, not the user’s. The user’s real IP is in a different field, and you need to read X-Forwarded-For to find it.

This post explains what X-Forwarded-For is, how it works through proxy chains, the security implications, and the right patterns for getting the real client IP in production.

The Problem

When your application sits behind a load balancer or reverse proxy, the TCP connection your server accepts is from the load balancer, not the user. Your req.ip (or equivalent) reflects the proxy’s IP.

For most logging, geolocation, and rate-limiting use cases, you want the user’s IP, not the load balancer’s. The proxy must tell you what it was.

The standard way: the X-Forwarded-For header.

How X-Forwarded-For Works

When a proxy forwards a request to a backend, it adds the client’s IP to the X-Forwarded-For header:

X-Forwarded-For: 203.0.113.5

If the request has already passed through other proxies, the header accumulates:

X-Forwarded-For: 203.0.113.5, 10.0.0.1, 10.0.0.2

Reading rules:

  • The leftmost IP is the original client.
  • Subsequent IPs are proxies in the chain.
  • The rightmost is the most recent proxy (the one that talked to your server).

Your backend sees this header and extracts the leftmost IP to get the original client.

The Trust Problem

The catch: X-Forwarded-For is a regular HTTP header. Anyone can set it. A user can send:

GET /api/whatever HTTP/1.1
Host: example.com
X-Forwarded-For: 1.2.3.4

If your application blindly trusts the leftmost X-Forwarded-For value, the user just claimed to be 1.2.3.4. This is the source of many IP-based bypass attacks: anti-fraud systems that read X-Forwarded-For without validation, geo-based access controls that trust client-supplied headers, rate limits that can be evaded by spoofing.

The rule: only trust X-Forwarded-For from proxies you control.

Safe Patterns

A few patterns that work in production:

Pattern 1: Trust only specific proxies

Configure your application with a list of trusted proxy IPs. When reading X-Forwarded-For, validate that the last proxy in the chain is one of yours:

function getClientIp(req: Request): string {
    const xff = req.headers['x-forwarded-for'] as string | undefined
    if (!xff) return req.socket.remoteAddress!
    
    const ips = xff.split(',').map(s => s.trim())
    const directProxy = req.socket.remoteAddress!
    
    if (!TRUSTED_PROXIES.has(directProxy)) {
        return directProxy  // Don't trust XFF from untrusted source
    }
    
    return ips[0]  // Trusted proxy chain; leftmost is real client
}

Pattern 2: Trust by proxy depth

You know how many proxies are in front of your app (e.g., 2: a CDN and a load balancer). Take the Nth-from-the-right IP in X-Forwarded-For:

const TRUSTED_PROXY_DEPTH = 2
const ips = (req.headers['x-forwarded-for'] as string ?? '').split(',').map(s => s.trim())
const clientIp = ips[Math.max(0, ips.length - TRUSTED_PROXY_DEPTH)]

This requires careful configuration but doesn’t need a per-IP allowlist.

Pattern 3: Use a more specific header

Some CDNs and proxies set additional headers that are easier to trust:

  • CF-Connecting-IP — Set by Cloudflare. Considered authoritative if you’ve ensured all traffic comes through Cloudflare.
  • True-Client-IP — Set by Akamai (and Cloudflare Enterprise).
  • X-Real-IP — Set by various nginx-based proxies.

These are single values (not chains) and are typically harder to spoof if you’ve configured your network correctly.

Pattern 4: Framework-built-in

Most frameworks have built-in handling if you tell them how many proxies are in front:

  • Express: app.set('trust proxy', 1) — trust the first proxy.
  • Express alt: app.set('trust proxy', ['127.0.0.1', '::1', 'loopback']) — trust specific IPs.
  • FastAPI/Starlette: Set forwarded_allow_ips in your Uvicorn config.
  • Laravel: Configure App\Http\Middleware\TrustProxies with your proxy IPs.
  • Rails: Set config.action_dispatch.trusted_proxies.
  • Django: SECURE_PROXY_SSL_HEADER and similar; or use a middleware package.

When configured correctly, these abstract the X-Forwarded-For handling for you. req.ip then returns the actual client IP, not the proxy’s.

The Two Common Mistakes

Mistake 1: Blind trust

const clientIp = req.headers['x-forwarded-for'].split(',')[0]

Anyone can set the header. This is the #1 IP-based bypass vulnerability. Don’t do this in production.

Mistake 2: No trust

const clientIp = req.socket.remoteAddress

Behind a proxy, this gives the proxy’s IP. Your logs and rate limiting are useless for IP-based reasoning because every request looks like it comes from the same handful of IPs.

The right answer is the middle: trust X-Forwarded-For only when it comes from your own proxies.

Forwarded Header (RFC 7239)

There’s a newer standard header: Forwarded. It’s more structured:

Forwarded: for=203.0.113.5;proto=https;by=192.0.2.42

It supports the same chaining and adds protocol/by information. In practice, X-Forwarded-For is still much more widely deployed and supported. New deployments can use Forwarded; existing infrastructure usually doesn’t.

Cloudflare-Specific

If you’re behind Cloudflare:

  • CF-Connecting-IP — The real client IP. Cloudflare’s preferred header.
  • X-Forwarded-For — Also set, follows standard chaining.
  • CF-IPCountry — The geolocated country (see detect country from IP).
  • CF-Ray — A unique ID for the request, useful for support.

For Cloudflare-fronted apps, CF-Connecting-IP is the cleanest source. Restrict your origin to only accept Cloudflare’s IPs to prevent direct-origin attacks that could spoof the header.

AWS-Specific

If you’re behind an ALB or NLB:

  • ALB sets X-Forwarded-For correctly. Standard chaining applies.
  • CloudFront sets X-Forwarded-For too.
  • CloudFront also sets CloudFront-Viewer-Country for the geolocated country.

Same principle: trust X-Forwarded-For only when the request comes through your ALB/CloudFront.

IPv4 and IPv6 Mixed

X-Forwarded-For can carry IPv4 and IPv6 mixed:

X-Forwarded-For: 2001:db8::5, 10.0.0.1

Make sure your IP-parsing code handles both. Many naive regexes only match IPv4. Use proper parsing libraries (Node’s net.isIP, Python’s ipaddress).

Per-Hop Headers and HTTP/2

In HTTP/1.1, headers are per-connection. In HTTP/2, headers are per-stream. X-Forwarded-For works identically in both — it’s an application-layer header, not a transport-layer one.

For HTTP/3 (QUIC), same story. The header semantics are HTTP-layer.

Privacy and Logging

X-Forwarded-For exposes IPs, which under GDPR is personal data. A few practical considerations:

  • Don’t log raw X-Forwarded-For chains if you don’t need them. Log the resolved client IP only.
  • Don’t store the full chain in databases; it can encode customer infrastructure that’s privacy-sensitive.
  • Forward the header carefully if your service is itself a proxy; you become responsible for the IPs in the chain.

A Complete Example

A safe Express middleware:

import type { Request, Response, NextFunction } from 'express'

const TRUSTED_PROXIES = new Set([
    '10.0.0.1',  // your load balancer
    '10.0.0.2',  // your second LB
])

export function realClientIp(req: Request, res: Response, next: NextFunction) {
    const directIp = req.socket.remoteAddress ?? ''
    
    if (!TRUSTED_PROXIES.has(directIp)) {
        ;(req as any).clientIp = directIp
        return next()
    }
    
    const xff = req.headers['x-forwarded-for']
    const xffString = Array.isArray(xff) ? xff[0] : (xff ?? '')
    const ips = xffString.split(',').map(s => s.trim()).filter(Boolean)
    
    ;(req as any).clientIp = ips[0] ?? directIp
    next()
}

Or for cleaner code, configure trust proxy properly and use req.ip:

app.set('trust proxy', ['10.0.0.1', '10.0.0.2'])
// Now req.ip returns the real client IP

TL;DR

  • X-Forwarded-For carries the original client IP through proxies.
  • The leftmost IP is the client; subsequent are proxies in the chain.
  • Trust only from proxies you control. Anyone can set the header.
  • Configure your framework’s trust proxy setting so req.ip returns the right thing.
  • For Cloudflare, prefer CF-Connecting-IP. For AWS, X-Forwarded-For from ALB is fine.
  • Don’t blindly trust user-supplied headers for rate limiting, geo blocking, or fraud detection.
  • Handle IPv4 and IPv6 mixed in the chain.
  • Treat IPs as personal data under GDPR; minimize logging.

The X-Forwarded-For header is one of those quietly-essential bits of HTTP plumbing. Once configured correctly, every IP-aware feature in your application — geolocation, rate limiting, logging, fraud detection — depends on it being right. For framework-specific examples, see IP geolocation in Node.js and IP geolocation in Python.

Get Started

Convert IPs into accurate location data in milliseconds.

Sign up today and get 1,000 free monthly stored conversions, and discover why developers trust us for fast, reliable, and affordable IP conversions.