WordPress Behind Cloudflare or a Reverse Proxy — Why Every Visitor Had the Same IP Address
· 19 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.
How Bad It Can Get
This wasn't the first time I'd hit this. A few months earlier, a different client — another WooCommerce store — messaged me after noticing something odd in their Wordfence activity log: every single login attempt, legitimate and malicious, showed the same Cloudflare edge IP. Their site had been behind Cloudflare for over a year without issues, because their old managed host handled IP restoration automatically via Apache's mod_remoteip. When they migrated to a self-managed nginx VPS, that invisible safety net disappeared.
The audit of what had silently broken in the weeks after that migration is worth spelling out, because it shows how far the damage spreads:
fail2ban banned Cloudflare itself. The jail watching for repeated POST /wp-login.php hits was matching Cloudflare IPs and had banned 172.71.98.0/24 twice — which briefly took the entire site offline for every visitor routed through that edge node. At the time I attributed the outage to an unrelated issue and unbanned the range. The real cause was fail2ban blocking Cloudflare's own infrastructure.
Wordfence locked out customers instead of attackers. Since every request appeared to come from a handful of Cloudflare IPs, the brute force protection threshold was being hit by normal customer traffic. Customers trying to log into their accounts started seeing "You have been locked out" messages.
Anti-fraud rules were useless. The store's WooCommerce fraud checks — which flag orders from IPs with multiple failed logins — were flagging everyone or no one, because everyone shared the same handful of IPs.
The access logs were forensically useless. Every line showed a Cloudflare IP. Had there been a breach, telling bot traffic from customers from attackers would have been impossible.
Same root cause, two different routes into it: one site had just switched Cloudflare on, the other had lost its IP restoration during a hosting migration. Either way, everything downstream of REMOTE_ADDR was operating on garbage data.
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.
If you want to confirm the module is available before touching config:
nginx -V 2>&1 | grep -o 'with-http_realip_module'
On Ubuntu/Debian the default nginx package includes it. On CentOS/RHEL, install from the official nginx repo rather than the distro's if the directive comes back unknown.
I created a Cloudflare-specific config file:
# /etc/nginx/conf.d/cloudflare-real-ip.conf
# 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;
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 same config applies — just swap the trusted ranges and the header:
# 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.
On Apache, the equivalent is mod_remoteip:
# /etc/apache2/conf-available/cloudflare-remoteip.conf
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
RemoteIPTrustedProxy 103.21.244.0/22
# ... remaining Cloudflare ranges
a2enmod remoteip
a2enconf cloudflare-remoteip
systemctl restart apache2
Many managed hosts enable mod_remoteip by default — which is exactly why the issue often doesn't surface until a site moves to a self-managed VPS.
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] );
}
Two caveats. The X-Forwarded-For header can be forged by the client — if your proxy doesn't strip and re-add it, a malicious client can prepend a fake IP. And the wp-config approach only fixes WordPress: not nginx access logs, not fail2ban, not any other application on the server. The real_ip module approach is more robust because it only trusts headers from whitelisted source IPs, and it fixes everything at once. Use wp-config as a fallback, not a first choice.
Fixing Wordfence
With nginx rewriting REMOTE_ADDR before PHP ever sees it, Wordfence picked up the correct IPs automatically. But it's worth verifying the configuration, because Wordfence has its own IP detection setting that can override the fix.
In Wordfence > All Options > General Wordfence Options, "How does Wordfence get IPs" has three options:
- Use PHP's built-in REMOTE_ADDR — works correctly once the realip module is in place
- Use the X-Forwarded-For HTTP header — works but can be spoofed unless you validate the chain
- Use the Cloudflare CF-Connecting-IP header — bypasses nginx entirely and reads the header in PHP
I set it to option 1, since the nginx-level fix is the cleanest approach — every PHP application on the server sees the correct IP without plugin-specific configuration. Then I clicked "Test" next to the setting and confirmed Wordfence showed my real IP, not a Cloudflare edge IP.
WooCommerce Geolocation: The Extra Step
Even after fixing REMOTE_ADDR, the WooCommerce geolocation issue persisted for the first 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 were two more things to address.
First, a safety net. Remember the second client's site going offline because fail2ban banned a Cloudflare range? I now whitelist Cloudflare's ranges in every jail config, so even if the realip configuration breaks, fail2ban can never ban Cloudflare's infrastructure and take the site down:
# /etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
Second, the ban mechanism itself. 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. If they add a new range and your set_real_ip_from list doesn't include it, requests from that range silently revert to showing Cloudflare IPs. I set up a weekly script that pulls the latest ranges from Cloudflare's published lists and rebuilds the nginx config.
Two failure modes matter here. A failed or empty download must never produce a config with no trusted ranges — so the script uses curl -fsS (which fails on HTTP errors instead of returning an empty body) and refuses to continue if either list comes back empty. And nginx -t only validates what is actually installed — testing before the swap would validate the old config, not the new one — so the script installs the candidate, tests it, and rolls back to the previous file if the test fails:
#!/bin/bash
# /usr/local/bin/update-cloudflare-ips.sh
set -euo pipefail
CF_CONF="/etc/nginx/conf.d/cloudflare-real-ip.conf"
TEMP_CONF=$(mktemp)
BACKUP_CONF=$(mktemp)
trap 'rm -f "$TEMP_CONF" "$BACKUP_CONF"' EXIT
# -f makes curl exit non-zero on HTTP errors instead of saving an error page
IPV4=$(curl -fsS https://www.cloudflare.com/ips-v4)
IPV6=$(curl -fsS https://www.cloudflare.com/ips-v6)
if [ -z "$IPV4" ] || [ -z "$IPV6" ]; then
echo "ERROR: downloaded Cloudflare IP list is empty — keeping existing config" >&2
exit 1
fi
{
echo "# Cloudflare IP ranges — auto-updated $(date +%F)"
echo ""
echo "# IPv4"
while read -r line; do
[ -n "$line" ] && echo "set_real_ip_from $line;"
done <<< "$IPV4"
echo ""
echo "# IPv6"
while read -r line; do
[ -n "$line" ] && echo "set_real_ip_from $line;"
done <<< "$IPV6"
echo ""
echo "real_ip_header CF-Connecting-IP;"
} > "$TEMP_CONF"
# Install the candidate, validate the config nginx will actually load,
# and roll back if the new file breaks it
cp "$CF_CONF" "$BACKUP_CONF"
cp "$TEMP_CONF" "$CF_CONF"
if nginx -t 2>/dev/null; then
systemctl reload nginx
else
cp "$BACKUP_CONF" "$CF_CONF"
echo "ERROR: candidate Cloudflare config failed nginx -t — previous config restored" >&2
exit 1
fi
Scheduled via cron:
0 3 * * 1 /usr/local/bin/update-cloudflare-ips.sh >> /var/log/cloudflare-ip-update.log 2>&1
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. Confirm PHP sees the right IP — temporary test file
echo '<?php echo "Your IP: " . $_SERVER["REMOTE_ADDR"]; ?>' > /var/www/html/ip-check.php
curl -s https://store.example.com/ip-check.php
rm /var/www/html/ip-check.php
Compare what curl ifconfig.me returns from your own machine against what shows up in the logs — they should match.
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. In the worst case, your security tooling actively harms you: banning your own CDN, locking out your own customers.
Every site I manage that sits behind Cloudflare or a load balancer gets the real_ip config as part of the initial setup — it's on my standard server management provisioning checklist for every nginx server. 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.
