Security Guides

What is X-Forwarded-For (XFF) spoofing: trusted-proxy misconfiguration and defense

X-Forwarded-For (XFF) is a header clients can forge, and a 'trust all proxies' setting lets the fake value reach your app. A case study and the proxy fix.

Published 2026-06-08 Updated 2026-10-04 11 min read
On this page

X-Forwarded-For (XFF) is the header that tells your app "the real visitor IP is this" when traffic passes through a proxy. The catch: the client can forge that value freely. Here's a common indie-dev case — "we got hit by an XFF spoofing scan" — turned into a lesson, anonymized, with no reproducible attack steps.

Case file
Type
X-Forwarded-For spoofing + injection probing (automated scan)
Impact
None (stopped by defense-in-depth)
How it surfaced
Attack requests 500'd; a flood of exception emails
Safeguards that worked
① placeholders (bound values) ② DB charset validation
The misconfiguration left
"trust all proxies" — with no proxy actually in front
Response
Sanitize IP headers at the boundary (short-term) → fix trusted-proxy config (root cause)
0
Real impact (leak/tamper)
2
Safeguards that worked
all
Proxies trusted (wildcard)
boundary
The main fix

First, split it: "attack" or "my own mistake"?

Don't jump to "attack!" on seeing errors. If you just changed config, weigh "maybe my change did this" with equal seriousness.

1

Suspect your own change

Hypothesis: a recent config change made users re-login repeatedly. But that can't explain the malformed byte sequences in the stored value → rejected.
2

Let the value be the evidence

What appeared were multiply-URL-encoded quotes and overlong (redundant) byte sequences — not values real users or proxies produce → confirmed malicious payload.
3

Targeted or a scan?

Less a targeted hit, more likely an automated scanner sweeping the whole internet indiscriminately.

This site's view: when working back from symptoms, test each hypothesis against the data

"The thing I just touched looks suspicious" is a fine starting point. But if the evidence (here, the value itself) refutes it, let it go. Form several hypotheses and rule them out one by one with data, not hunches; you will misidentify the cause far less often.

What the attack was: a spoofed XFF carrying injection probes

The bot puts a probe string into XFF "instead of an IP," with only a normal-looking IP at the end. It is checking one thing:

"Does this app trust the XFF value and mix it raw into a DB query, or print it straight into HTML?"

If the app is sloppy, SQL injection or XSS succeeds via XFF. It abuses "the header that carries an IP" as a place to send arbitrary strings.

Why there was no damage: two safeguards

Safeguard ① bound values (placeholders)

The IP value was passed via a placeholder as data, not string-concatenated. Input can't be interpreted as a command, so SQLi can't form by design. This is one of the main benefits of using a framework.

Safeguard ② DB charset validation

The malformed bytes (overlong UTF-8 etc.) are invalid sequences the column's charset can't store. The DB rejected the write and threw — the attack failed and was detected.

All that happened: the attacker's own requests 500'd and a flood of exception emails went out. No leak, no tampering, no unauthorized login. A textbook case of defense-in-depth working as intended.

The real problem: trusting all proxies

If we stopped here, there would be no problem, but one thing can't be ignored. Why did the spoofed XFF reach IP handling at all?

✗ Trust all proxies (wildcard)

Client's fake XFF → adopted as the "real IP" → logs, sessions and IP checks get the fake value

✓ Trust nothing (default)

Fake XFF ignored → real socket IP is used → spoofing has no effect

With trusted proxies set to a wildcard, anyone's spoofed XFF passes as the 'real IP.' With no proxy in front, 'trust nothing' is correct.

This setup had no reverse proxy or CDN — the web server was directly exposed. So XFF never needed trusting, yet it was set to "trust all." "Just allow everything" is a common development-time setting that stays in production.

Easy to miss: XFF spoofing isn't only about injection

Anything that trusts the client IP ends up using the fake value: rate-limit bypass, IP allowlist bypass, ban evasion, forged audit logs. Even with strong injection defenses, this is a separate problem. Inventory "where do I trust the IP?" and check the basis of that trust (= your proxy config).

Fix: sanitize at the boundary → set trust correctly

1

Sanitize IP headers at the boundary (short-term, immediate)

Before trusted-proxy logic runs, split XFF, validate each part as "is this a valid IP?", keep only valid IPs; if none, drop the header entirely. Real users' real IPs pass, so no impact; probe strings are discarded.
2

Set trusted proxies correctly (root cause)

Drop the wildcard. No proxy in front → trust nothing. Proxy trust also affects http/https detection, so changes can cause redirect loops — verify on one environment first.
3

Don't confuse 'valid IP' with 'real IP'

Sanitizing only fixes the format. A well-formed IP may still be a forged value. Trust in the real IP comes only from the proxy config in step 2.

Verify: after fixing, replay the attack to confirm

Turn "I think I fixed it" into "it definitely stopped." ① apply to one environment only → ② send the same malformed header yourself → ③ confirm error counts don't rise → ④ confirm normal access still works → ⑤ roll out everywhere. Staged rollout prevents accidents.

Follow-up: after the IP header was blocked, the attack moved to User-Agent

Tens of minutes after adding sanitization, the same kind of error returned — on a different column this time: user_agent, not ip_address. Blocked on the IP header, the attacker moved the same payload to the User-Agent header. That called for a different approach.

Inputs: XFF · User-Agent · Referer · … (endless)
↓ but the ones actually stored are
Sink: only ip_address & user_agent in the sessions table
↓ so the inputs to sanitize are
just the headers feeding those two (IP headers + User-Agent)
Blocking dangerous headers one by one just moves the attack to another header. Work back from where untrusted input ultimately gets stored (the sink), and the inputs you need to handle become a short, fixed list.

The right question isn't "which header is dangerous?" but "which DB column does untrusted input ultimately land in?" If the only sinks are ip_address and user_agent, sanitizing the inputs that feed them (IP headers + User-Agent, plus Referer to be safe) stops session-derived errors by design ; moving the payload to other, non-stored headers causes no error. Legitimate User-Agents are ASCII/valid UTF-8, so stripping only invalid bytes has no impact.

This site's view: work back from the sink instead of blocking inputs one by one

Blocking headers one at a time doesn't work; an attacker can always use another header. Reason backward from the sink (the stored column / output) and enumerate every input that reaches it, all at once. As this case showed when the attack moved from the IP header to User-Agent: when you patch one header, handle the other inputs that reach the same sink at the same time. And again — we replayed the second attack and confirmed it stopped before rolling out.

Then again: defending just before the value is saved

Just after handling User-Agent too, the same ip_address column error came back. The entry sanitization was deployed, and replaying the same malformed header in a self-test didn't reproduce it — yet the error kept recurring in production. This is the key point of the case.

The reason: the framework's "resolve the client IP" logic is complex internally — multiple header notations (X-Forwarded-For, Forwarded, custom headers), combinations with proxy-trust settings, parse order and caching. Somewhere in those internal paths, a malicious value came back as the "client IP" via a route we hadn't sanitized. Checking at the entry point can't catch everything.

✗ Defend at the entry

Sanitize request headers → the malicious client IP comes back via another internal path → bypassed

✓ Defend at the exit

Check right before the DB write → the one place every value passes through → no bypass

An entry filter (checking incoming values) can be bypassed by internal resolution paths. The exit (right before the DB write) is the one place every value passes through, so nothing gets past it.

So we changed approach and defended at the exit (just before the DB write). Subclass the session-storage class and override only the value-producing parts (concept): the stored IP becomes null unless it's a valid IP; the stored User-Agent has invalid bytes stripped and length capped. However the client-IP resolution behaves internally, the check always runs right before the write, so nothing gets past it. Sending the malicious header through both attack routes dozens of times each, error counts didn't rise and legitimate sessions kept working.

This site's view: the final check belongs at the exit (point of use)

The basic security rule is "don't trust input; always validate/escape at the point of use (the exit)." Entry checks help you reject early, but information the framework resolves/derives internally (like the client IP) can get past the entry check and appear again. Validating/escaping right before a dangerous sink (DB, HTML, a command) is the one place every value passes through, so that is where the final check belongs. Do entry and exit as two separate things.

Lesson: it is safer when bad input is rejected with a visible error

Here the attack failed and was made visible as a flood of error notifications. That is not a bad outcome: being rejected with a visible error is safer than being let through unnoticed. Don't dismiss error notifications as noise; use them to notice anomalies. Don't stop at "we blocked the impact"; fix "why it reached the internals." That's this site's stance.

FAQ

QCan I trust X-Forwarded-For?
A

Not as-is. XFF is a header the client can set freely — an attacker can forge a real-looking IP or an injection-probe string. The only trustworthy XFF is one added by a proxy YOU placed in front and explicitly trust.

QWhat if I don't use a reverse proxy or CDN?
A

Trust no XFF at all (leave the framework default). If there is no proxy in front but you 'trust all proxies', anyone can pass your app a fake IP. No proxy in the path → trust nothing is the correct setting.

QIs sanitizing the IP header enough?
A

It stops the injection probes and the flood of errors, but it is only a short-term fix. A 'valid-looking IP' is not a 'real IP'. Trust in the real client IP must come from the trusted-proxy configuration, not from sanitizing.

QI fixed the IP header but the same error came back. Why?
A

The same payload was moved to another header (e.g. the User-Agent header) and reached the same stored column. If you patch headers one by one, the attacker just moves to another one. Reason backward from 'which DB column / output does untrusted input ultimately reach?' and sanitize all the inputs that feed it (IP headers + User-Agent, etc.) together.