.htaccess. Behind Cloudflare, that move rarely works. The bot traffic keeps coming, and some real visitors may start seeing Cloudflare error pages instead of the site. The IPs at the top of the log usually don't belong to the attacker. They belong to Cloudflare.
Why the IPs in your log aren't your attackers
%h, or Nginx logs as $remote_addr, is a Cloudflare edge IP. The same edge IP carries traffic for many different visitors, and one visitor's requests can arrive through several edge IPs over a short period.
You can confirm this in a few minutes. Take a few of the top IPs from your log and run whois on them. If the result shows Cloudflare as the network owner, or the address falls inside one of the ranges Cloudflare publishes on its website, you're looking at the proxy, not the client.
BASH
# Top 10 source IPs in the access log (path depends on your setup)
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -n 10
# Check who owns one of them
whois 203.0.113.10 | grep -iE 'orgname|netname|org-name'whois field names to vary between regional registries, which is why the grep matches a few variants.
What happens when you block them anyway
Where the real client IP lives
CF-Connecting-IPcontains the single client IP as Cloudflare saw it.X-Forwarded-Forcontains the client IP too, but it is a chain. Any proxy along the path can append to it, and the client can send its own value first.
CF-Connecting-IP is the simpler header to rely on because it holds one address.
There is one rule that matters more than which header you pick. Only trust that header when the connection itself comes from Cloudflare. Anyone who knows your origin IP can connect to it directly and send CF-Connecting-IP: 1.2.3.4 with any value they like. If your server believes that header from every source, an attacker can make their requests look like they come from any address, including yours. Every correct setup below pairs the header with a list of trusted proxy ranges for exactly this reason.
Restoring the real IP in Apache with mod_remoteip
mod_remoteip replaces the connection's client address with the value from a header, but only when the request arrives from a proxy you've listed as trusted. Once it's active, access logs, .htaccess rules like Require not ip, and PHP's REMOTE_ADDR all see the real visitor.
In practice, the setup looks like this. Where the file lives and how the module is enabled depends on the distro and control panel. cPanel, Plesk, and plain Debian or RHEL-based installs each handle it differently.
APACHE
# Load mod_remoteip first (a2enmod remoteip on Debian/Ubuntu;
# other setups load it through their own module config)
RemoteIPHeader CF-Connecting-IP
# One line per range from Cloudflare's published IPv4 and IPv6 lists.
# Only requests from these ranges will have the header honored.
RemoteIPTrustedProxy 173.245.48.0/20
# RemoteIPTrustedProxy ...next range...combined log format uses %h, and the module documentation recommends switching your LogFormat to %a so the log records the corrected client address. Without that change, the config can be working while the log still looks wrong, and that sends people in circles.
After editing, run apachectl configtest (or apache2ctl configtest) before reloading.
On LiteSpeed, the equivalent is a server-level setting that tells it to use the client IP from a header, with an option to only trust known proxies. The exact name and location depends on whether you run LiteSpeed Enterprise or OpenLiteSpeed and which version. Look in the server's general or security configuration rather than .htaccess.
Restoring it in Nginx with real_ip
nginx -V will show --with-http_realip_module if it's compiled in.
NGINX
# Inside the http block, or a file included from it
# One line per Cloudflare IPv4 and IPv6 range
set_real_ip_from 173.245.48.0/20;
# set_real_ip_from ...next range...;
real_ip_header CF-Connecting-IP;remote_addr becomes the visitor's IP for logging, allow/deny rules, rate limiting with limit_req, and whatever is passed upstream to PHP-FPM. The original edge address is still available as realip_remote_addr. That variable is worth adding to a custom log format while you're testing, so you can see both values side by side.
Run nginx -t before reloading. After the reload, reproduce a request from a device you control, find it in the log, and confirm the IP matches the public IP of that device rather than a Cloudflare range.
Cloudflare's ranges change from time to time. A list that was correct when you wrote the config can drift. If a new edge range isn't in your trusted list, requests from it will be logged with the edge IP again. Some admins fetch the published lists on a schedule and regenerate the include file. Others just review it periodically. Either works, as long as someone owns it.
Blocking in the right place
- At Cloudflare. Rules there act on the real visitor before traffic reaches your server, and they apply across every edge location. Depending on the plan, this can be WAF custom rules, IP access rules, rate limiting, or a challenge for suspicious paths like
wp-login.phporxmlrpc.php. If you use Fail2ban, look for an action that pushes bans to the Cloudflare API instead of the local firewall. - In the web server.
Require not ipin Apache ordenyin Nginx now sees the real IP once remoteip or realip is set up. The request still crosses Cloudflare and reaches your origin, so this doesn't save bandwidth or connection overhead. But it does stop the request before it reaches PHP, and that's often where the load comes from.
mod_remoteip or real_ip so you can see who is really there, and trust the header only from Cloudflare's published ranges. Then block abusive visitors at Cloudflare or in the web server, and use the origin firewall only to make sure traffic can't bypass Cloudflare.