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.
- 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)
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.
Suspect your own change
Let the value be the evidence
Targeted or a scan?
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
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
Sanitize IP headers at the boundary (short-term, immediate)
Set trusted proxies correctly (root cause)
http/https detection, so changes can cause redirect loops — verify on one environment first.Don't confuse 'valid IP' with 'real IP'
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.
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
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.
Read next
- Glossary: What is SQL injection / What is XSS / What is SSRF
- Where it matters: rate limiting and abuse control (which IP you count requests by depends on how you handle XFF)
- Basics: Security basics: .env and API keys
- The landscape: do Next.js and React have a lot of vulnerabilities? (what has been found in frameworks' built-in proxy features)