WordPress Behind Cloudflare Was Logging Every Visitor as the Same IP — Here's How I Fixed It

· 11 min read

A client running a WooCommerce store messaged me after noticing something odd in their Wordfence activity log: every single login attempt — legitimate and malicious — was showing the same IP address. Not a single attacker's IP. A Cloudflare edge IP: 172.71.98.x.

Their fail2ban setup, which I'd configured months earlier using the same approach I described in my fail2ban brute force post, was doing nothing. Every attacker appeared to be the same "visitor" as every customer. Rate limiting was useless. Login lockout was useless. And their WooCommerce anti-fraud rules — which flag orders from IPs with multiple failed logins — were flagging everyone or no one.

The site had been behind Cloudflare for over a year. But the client had recently migrated from a managed hosting provider (which handled IP restoration automatically via Apache's mod_remoteip) to a VPS I set up with nginx. I'd configured everything — PHP-FPM, Redis, nginx FastCGI cache — but had missed one critical piece: telling nginx to trust Cloudflare's headers and extract the real visitor IP.

What Cloudflare Does to Your Visitor IPs

When a site is proxied through Cloudflare (the orange cloud is on), visitors never connect directly to your server. Every request hits a Cloudflare edge node first, which then forwards it to your origin. From nginx's perspective, the connecting IP is always one of Cloudflare's edge IPs — typically in the 172.64.0.0/13, 104.16.0.0/13, or 162.158.0.0/15 ranges.

Cloudflare does pass the real visitor IP, but in an HTTP header: CF-Connecting-IP. Unless your web server is explicitly configured to read that header and use it as the client IP, everything downstream — PHP, WordPress, fail2ban, access logs — sees a Cloudflare IP instead of the actual visitor.

This isn't a bug. It's how every reverse proxy works. But the consequences for WordPress security are severe if you don't handle it.

The Damage

I audited what had been broken during the weeks since migration:

fail2ban was blind. The jail I'd configured to watch /var/log/nginx/access.log for repeated POST /wp-login.php hits was matching Cloudflare IPs. It had already banned 172.71.98.0/24 twice — which briefly took the entire site offline for all visitors routed through that edge node. I'd seen the site go down but attributed it to an unrelated issue and unbanned the range. The real cause was fail2ban banning Cloudflare itself.

Wordfence rate limiting was disabled in practice. Wordfence was configured to use REMOTE_ADDR for IP detection. Since every request came from a handful of Cloudflare IPs, its brute force protection threshold was being hit by normal customer traffic, not attackers. The client had started seeing "You have been locked out" messages from customers trying to log into their accounts.

nginx access logs were forensically useless. Every line showed a Cloudflare IP. I couldn't tell which requests came from bots, which from customers, which from attackers. Post-incident investigation would have been impossible.

WooCommerce geolocation was wrong. The MaxMind GeoIP lookup was resolving Cloudflare edge locations instead of customer locations, which meant shipping estimates on the cart page were occasionally incorrect.

The Fix: nginx realip Module

The fix has two parts: configuring nginx to extract the real IP from Cloudflare's header, and making sure WordPress and its plugins use it.

Step 1: Create the Cloudflare IP Configuration

I created a dedicated config file for Cloudflare's IP ranges:

nano /etc/nginx/conf.d/cloudflare-realip.conf
# Cloudflare IPv4 ranges
set_real_ip_from 173.245.48.0/20;
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 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

# 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;

real_ip_header CF-Connecting-IP;

The set_real_ip_from directives tell nginx which source IPs to trust. The real_ip_header directive tells it which header contains the real visitor IP. This requires the ngx_http_realip_module, which is included in every standard nginx build — no recompilation needed.

Step 2: Test and Reload nginx

nginx -t && systemctl reload nginx

After the reload, I tailed the access log and asked the client to visit their site:

tail -f /var/log/nginx/access.log

Instead of 172.71.98.x, I saw their actual IP address. Every subsequent request from real visitors showed their real IPs too.

Step 3: Verify the Module Is Loaded

If nginx -t fails with an unknown directive error, the realip module isn't loaded. Check with:

nginx -V 2>&1 | grep -o 'with-http_realip_module'

On Ubuntu/Debian, the default nginx package includes it. On CentOS/RHEL, you may need nginx-plus or to install from the official nginx repo rather than the distro's.

Fixing Wordfence

With nginx now passing the correct IP to PHP via $_SERVER['REMOTE_ADDR'], Wordfence picked it up automatically. But I still needed to verify the configuration.

In Wordfence > All Options > General Wordfence Options, the "How does Wordfence get IPs" setting had three options:

  1. Use PHP's built-in REMOTE_ADDR — this now works correctly because nginx's realip module rewrites REMOTE_ADDR before PHP ever sees it
  2. Use the X-Forwarded-For HTTP header — works but less reliable; this header can be spoofed unless you validate the chain
  3. Use the Cloudflare CF-Connecting-IP header — bypasses nginx entirely and reads the header in PHP

I set it to option 1 (REMOTE_ADDR), since the nginx-level fix is the cleanest approach. It means every PHP application on the server — not just Wordfence — sees the correct IP without needing plugin-specific configuration.

I clicked "Test" next to the setting and confirmed Wordfence showed my real IP, not a Cloudflare edge IP.

Fixing fail2ban

With real IPs now in the nginx access log, fail2ban started working again immediately. But I added one safety measure — a whitelist of Cloudflare's IP ranges so that even if something goes wrong with the realip configuration, fail2ban won't accidentally ban Cloudflare IPs and take the site offline:

# /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

Then I restarted fail2ban:

systemctl restart fail2ban
fail2ban-client status wordpress-login

Within an hour, it had correctly identified and banned three IPs running brute force attacks against wp-login.php — actual attacker IPs this time, not Cloudflare infrastructure.

Automating IP Range Updates

Cloudflare's IP ranges change infrequently, but they do change. If Cloudflare adds a new range and your set_real_ip_from list doesn't include it, requests from that range will show the Cloudflare IP instead of the real visitor IP.

I wrote a script that pulls the current ranges from Cloudflare's API and rebuilds the nginx config:

#!/bin/bash
# /usr/local/bin/update-cloudflare-ips.sh

CF_CONF="/etc/nginx/conf.d/cloudflare-realip.conf"
TEMP_CONF=$(mktemp)

echo "# Cloudflare IP ranges — auto-updated $(date +%Y-%m-%d)" > "$TEMP_CONF"
echo "# Source: https://www.cloudflare.com/ips/" >> "$TEMP_CONF"
echo "" >> "$TEMP_CONF"

echo "# IPv4" >> "$TEMP_CONF"
curl -s https://www.cloudflare.com/ips-v4 | while read -r line; do
    [ -n "$line" ] && echo "set_real_ip_from $line;" >> "$TEMP_CONF"
done

echo "" >> "$TEMP_CONF"
echo "# IPv6" >> "$TEMP_CONF"
curl -s https://www.cloudflare.com/ips-v6 | while read -r line; do
    [ -n "$line" ] && echo "set_real_ip_from $line;" >> "$TEMP_CONF"
done

echo "" >> "$TEMP_CONF"
echo "real_ip_header CF-Connecting-IP;" >> "$TEMP_CONF"

if nginx -t -c /etc/nginx/nginx.conf 2>/dev/null; then
    mv "$TEMP_CONF" "$CF_CONF"
    systemctl reload nginx
else
    rm "$TEMP_CONF"
    echo "ERROR: nginx config test failed after update" >&2
    exit 1
fi

I run this weekly via cron:

0 3 * * 1 /usr/local/bin/update-cloudflare-ips.sh >> /var/log/cloudflare-ip-update.log 2>&1

What About Apache?

If you're running Apache instead of nginx, the equivalent solution uses 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

Enable it with:

a2enmod remoteip
a2enconf cloudflare-remoteip
systemctl restart apache2

Many managed hosting providers enable this by default — which is exactly why the issue didn't surface until my client moved to a self-managed VPS.

The wp-config.php Fallback

If you can't modify the web server configuration (shared hosting with no nginx/Apache access), you can handle it in WordPress directly. Add this to wp-config.php before the require_once ABSPATH . 'wp-settings.php' line:

if (isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
    $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_CF_CONNECTING_IP'];
}

This works but it's a second-best option. It only fixes WordPress — not nginx access logs, not fail2ban, not any other application on the server. The nginx/Apache approach is always better when you have server access.

How to Verify It's Working

After making any of these changes, verify end to end:

# Check nginx logs show real IPs (not 172.x or 104.x Cloudflare ranges)
tail -20 /var/log/nginx/access.log

# Check WordPress sees the right IP — create a temporary test file
echo '<?php echo "Your IP: " . $_SERVER["REMOTE_ADDR"]; ?>' > /var/www/html/ip-check.php
curl -s https://yoursite.com/ip-check.php
rm /var/www/html/ip-check.php

# Check fail2ban is logging real IPs
fail2ban-client status wordpress-login

Compare what curl ifconfig.me returns from your own machine against what shows up in the logs. They should match.

Lessons Learned

This is one of those issues that's invisible until it's catastrophic. The site appeared to work perfectly — pages loaded, orders processed, SSL was fine. But the entire security layer was blind. Every tool that relied on visitor IPs — fail2ban, Wordfence, rate limiting, geolocation, fraud detection — was operating on garbage data.

I now include the Cloudflare realip configuration in my standard server management setup checklist for every nginx server I provision. It takes five minutes to configure and it's one of those things you only forget once.

If you're running WordPress behind any reverse proxy — Cloudflare, a load balancer, Varnish, or another nginx instance — check your logs right now. If every visitor has the same IP or IPs from a small set of ranges, your security tools aren't protecting you.

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.

View Maintenance Plans | Get in Touch

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.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs