WordPress Behind a Reverse Proxy — Why Every Visitor Had the Same IP Address
· 12 min read
A client moved their WooCommerce store behind Cloudflare on a Tuesday. By Thursday, three things had broken and nobody had connected the dots.
First, fail2ban had stopped blocking brute force attacks on wp-login.php. The jails I'd configured were still active — fail2ban-client status showed them running — but no IPs had been banned in two days. That was unusual for a site that normally caught 200-300 offenders daily.
Second, the store's currency switcher was defaulting every visitor to USD regardless of where they were browsing from. The client noticed when a regular UK customer complained about seeing dollar prices.
Third, the WordPress security plugin (Wordfence) was sending alerts about "suspicious login activity from 172.71.x.x" — a Cloudflare edge IP, not a real visitor.
All three symptoms had the same root cause: the server was seeing Cloudflare's IP address instead of the real visitor's IP on every single request.
Confirming the Problem
I SSH'd into the server and checked the nginx access log:
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
14,291 172.71.98.42
8,847 172.71.114.18
5,203 172.71.98.35
3,891 162.158.90.71
2,104 162.158.88.22
847 162.158.90.104
312 172.71.114.55
198 162.158.88.40
94 162.158.90.22
51 172.71.98.61
Every single IP was a Cloudflare edge address. Not one real visitor IP in the entire log. Over 36,000 requests and the server genuinely believed they all came from a handful of Cloudflare nodes.
I confirmed by placing a test order and checking what WooCommerce recorded as the customer IP:
wp db query "SELECT order_id, ip_address FROM wp_wc_order_addresses WHERE address_type='billing' ORDER BY order_id DESC LIMIT 5;" --path=/var/www/html
+----------+----------------+
| order_id | ip_address |
+----------+----------------+
| 14823 | 172.71.98.42 |
| 14822 | 172.71.98.42 |
| 14821 | 162.158.90.71 |
| 14820 | 172.71.114.18 |
| 14819 | 172.71.98.42 |
+----------+----------------+
Every order had a Cloudflare IP logged against it. The real customer IPs were gone — invisible to WordPress, WooCommerce, fail2ban, and every security plugin.
Why This Happens
When a request flows through a reverse proxy or CDN, the proxy connects to your server on behalf of the visitor. From nginx's perspective, the TCP connection comes from the proxy, not the visitor. So $remote_addr — the variable nginx writes into access logs, passes to PHP via REMOTE_ADDR, and uses for rate limiting — contains the proxy's IP.
The real visitor IP gets stuffed into an HTTP header instead. Cloudflare uses CF-Connecting-IP. Generic reverse proxies typically use X-Forwarded-For or X-Real-IP. But unless you explicitly tell nginx to read that header and use it as the client address, it's ignored.
This isn't a Cloudflare bug. It's the expected behaviour of any reverse proxy — HAProxy, Varnish, a load balancer, even another nginx instance acting as a front-end proxy. The problem is that nobody configured the origin server to trust the proxy and extract the real IP.
The Nginx Fix: real_ip Module
The ngx_http_realip_module (compiled into nginx by default on most distributions) does exactly what's needed. It reads a specified header and replaces $remote_addr with its value, but only when the request comes from a trusted source.
I created a Cloudflare-specific config file:
cat > /etc/nginx/conf.d/cloudflare-real-ip.conf << 'EOF'
# Cloudflare IPv4 ranges
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
# Cloudflare IPv6 ranges
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
# Use the CF-Connecting-IP header
real_ip_header CF-Connecting-IP;
EOF
The set_real_ip_from directives tell nginx which source IPs to trust. Without this whitelist, any client could forge a CF-Connecting-IP header and spoof their IP address. The real_ip_header directive tells nginx which header contains the real visitor IP.
I tested the config and reloaded:
nginx -t && systemctl reload nginx
Immediately, the access log started showing real IPs:
tail -5 /var/log/nginx/access.log
203.0.113.42 - - [05/Aug/2026:10:23:17 +0000] "GET /shop/ HTTP/2.0" 200 ...
198.51.100.7 - - [05/Aug/2026:10:23:18 +0000] "POST /wp-admin/admin-ajax.php HTTP/2.0" 200 ...
192.0.2.15 - - [05/Aug/2026:10:23:19 +0000] "GET /product/widget-pro/ HTTP/2.0" 200 ...
Real visitor IPs, not Cloudflare nodes.
Non-Cloudflare Reverse Proxies
If you're behind a load balancer, HAProxy, Varnish, or another nginx instance rather than Cloudflare, the configuration is similar but uses X-Forwarded-For or X-Real-IP:
# Trust your load balancer's IP
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 192.168.0.0/16;
# X-Forwarded-For contains a comma-separated chain of IPs
# real_ip_recursive on; picks the last non-trusted IP from the chain
real_ip_header X-Forwarded-For;
real_ip_recursive on;
The real_ip_recursive on setting is important when there are multiple proxies in the chain. Without it, nginx takes the last IP in the X-Forwarded-For header (the nearest proxy). With it, nginx walks backwards through the chain, skipping trusted addresses until it finds the first untrusted one — the actual client.
The WordPress Layer: wp-config.php
The nginx fix handles logging, rate limiting, and fail2ban. But WordPress also reads REMOTE_ADDR directly for features like comment author tracking, login security, and plugin IP checks. If you're on a hosting panel where you can't modify nginx config (shared hosting behind a load balancer, for example), you can fix it at the WordPress level.
Add this to wp-config.php before the /* That's all, stop editing! */ line:
if ( isset( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
$_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_CF_CONNECTING_IP'];
}
For a generic reverse proxy using X-Forwarded-For:
if ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$ips = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
$_SERVER['REMOTE_ADDR'] = trim( $ips[0] );
}
A security note: the X-Forwarded-For header can be forged by the client. If your proxy doesn't strip and re-add this header, a malicious client can prepend a fake IP. The nginx real_ip module approach is more robust because it only trusts headers from whitelisted source IPs.
WooCommerce Geolocation: The Extra Step
Even after fixing REMOTE_ADDR, the WooCommerce geolocation issue persisted for this client. The currency switcher was still defaulting to USD. The reason: WooCommerce has its own geolocation pipeline that doesn't always use REMOTE_ADDR.
WooCommerce's MaxMind GeoIP lookup happens via WC_Geolocation::geolocate_ip(), which checks multiple headers in this order: HTTP_CF_CONNECTING_IP, HTTP_CF_IPCOUNTRY, HTTP_X_REAL_IP, HTTP_X_FORWARDED_FOR, then REMOTE_ADDR. But some caching setups break this chain.
The client's site had WooCommerce's "Default customer location" set to "Geolocate". With a page cache in front of it, the geolocation was happening on the first uncached request and then that result was served to everyone. Switching to "Geolocate (with page caching support)" in WooCommerce > Settings > General fixed it. This mode appends a ?v= parameter that varies by detected country, ensuring each country gets its own cached version.
WooCommerce > Settings > General > Default customer location > Geolocate (with page caching support)
Fixing Fail2Ban
With nginx now logging real IPs, fail2ban started working again immediately — the jails were matching against the correct IPs in the access log. But there was one more thing to address.
When fail2ban bans an IP, it typically adds an iptables rule to drop traffic from that IP. Behind Cloudflare, that's pointless — the banned IP never connects directly to the server. The traffic comes through Cloudflare's network.
I switched fail2ban's action to use the Cloudflare API instead, which creates a firewall rule on Cloudflare's edge:
# /etc/fail2ban/action.d/cloudflare.conf
[Definition]
actionban = curl -s -X POST \
"https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules" \
-H "Authorization: Bearer <cf-api-token>" \
-H "Content-Type: application/json" \
--data '{"mode":"block","configuration":{"target":"ip","value":"<ip>"},"notes":"fail2ban: <name>"}'
actionunban = curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules/$(curl -s \
"https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules?configuration.value=<ip>" \
-H "Authorization: Bearer <cf-api-token>" | jq -r '.result[0].id')"
Then in the jail config:
# /etc/fail2ban/jail.local
[wordpress-login]
enabled = true
filter = wordpress-login
logpath = /var/log/nginx/access.log
maxretry = 5
findtime = 300
bantime = 3600
action = cloudflare[cf-api-token=your_token_here]
Now fail2ban catches the real IP from the nginx log (which shows the true visitor IP thanks to the real_ip module), and bans it on Cloudflare's edge — meaning the banned IP gets blocked before it even reaches the server.
Keeping the IP Ranges Current
Cloudflare updates their IP ranges periodically. I set up a cron job to pull the latest ranges and rebuild the nginx config:
#!/bin/bash
# /etc/cron.weekly/update-cloudflare-ips
CF_CONF="/etc/nginx/conf.d/cloudflare-real-ip.conf"
echo "# Cloudflare IP ranges — auto-updated $(date +%F)" > "$CF_CONF"
echo "" >> "$CF_CONF"
for ip in $(curl -sL https://www.cloudflare.com/ips-v4); do
echo "set_real_ip_from $ip;" >> "$CF_CONF"
done
for ip in $(curl -sL https://www.cloudflare.com/ips-v6); do
echo "set_real_ip_from $ip;" >> "$CF_CONF"
done
echo "" >> "$CF_CONF"
echo "real_ip_header CF-Connecting-IP;" >> "$CF_CONF"
nginx -t && systemctl reload nginx
This runs weekly and silently keeps the whitelist in sync with Cloudflare's published ranges.
Verifying Everything Works
After deploying all the fixes, I ran a verification checklist:
# 1. Confirm nginx logs show real IPs (not 172.x or 162.x Cloudflare ranges)
tail -20 /var/log/nginx/access.log | awk '{print $1}' | sort -u
# 2. Check fail2ban is banning real IPs
fail2ban-client status wordpress-login
# 3. Verify WooCommerce records real customer IPs on new orders
wp db query "SELECT order_id, ip_address FROM wp_wc_order_addresses WHERE address_type='billing' ORDER BY order_id DESC LIMIT 5;" --path=/var/www/html
# 4. Test geolocation by checking from a known location
curl -sI "https://store.example.com/" -H "CF-Connecting-IP: 81.2.69.142" | grep -i "country\|currency"
Within an hour, fail2ban had banned its first real IP in three days. The currency switcher was showing GBP for UK visitors and EUR for European ones. WooCommerce orders were logging actual customer IPs instead of Cloudflare nodes.
The Takeaway
This is one of those issues that breaks silently. Nothing throws an error. The site loads fine. Orders go through. It's only when you look at the data — the logs, the recorded IPs, the geolocation accuracy, the fail2ban ban list — that you realise everything downstream of REMOTE_ADDR has been wrong since the day the proxy went live.
Every site I manage that sits behind Cloudflare or a load balancer gets the real_ip config as part of the initial setup. It takes five minutes and prevents a cascade of subtle failures that can go unnoticed for weeks.
If you're running a WooCommerce store behind any kind of reverse proxy and you're not sure whether real IPs are being logged correctly, check your access log. If every IP starts with 172.64, 172.71, 162.158, or 104.16 — you've got this problem.
Stop Firefighting. Start Maintaining.
I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.
