Guides

fail2ban behind Cloudflare

Cloudflare causes two separate fail2ban problems: your logs show Cloudflare's addresses instead of the visitor's, and a local firewall ban can't stop traffic that arrives through Cloudflare. This guide fixes both.

Updated 7 min readChecked against fail2ban 1.1.1

To use fail2ban behind Cloudflare, first make your web server log the visitor's address from the CF-Connecting-IP header (nginx: set_real_ip_from for Cloudflare's ranges plus real_ip_header CF-Connecting-IP; Apache: mod_remoteip). Then ban at Cloudflare's edge with fail2ban's cloudflare-token action and a Cloudflare API token, because a local firewall ban only blocks Cloudflare's servers — the attacker keeps reaching your site through Cloudflare.

/etc/fail2ban/action.d/cloudflare-token.local
[Init]
cfzone  = <your zone ID>
cftoken = <your API token>
/etc/fail2ban/jail.local
[nginx-http-auth]
enabled = true
backend = auto
action  = %(action_)s
          cloudflare-token

The two problems are independent. Fix the first one even if you never ban at the edge, or fail2ban will ban Cloudflare.

Why Cloudflare breaks a normal fail2ban setup#

With the orange cloud (proxied DNS) on, visitors connect to a Cloudflare data center, and Cloudflare opens a new connection to your server. Your server therefore sees:

  • Cloudflare's IP as the TCP peer. nginx's $remote_addr, Apache's %h and every log line contain a Cloudflare address. fail2ban counts failures for that address and bans it — cutting off everyone who happens to be routed through that data center.
  • No direct packets from the attacker. Even if fail2ban knows the attacker's real address, a firewall rule for it on your server never matches: the attacker's packets end at Cloudflare.

Cloudflare passes the real client in the CF-Connecting-IP header (and in X-Forwarded-For). It does not send X-Real-IP.

Problem 1: log the real visitor IP#

nginx#

Trust the header only from Cloudflare's published ranges. Generate the list from Cloudflare's own files instead of copying it from a blog post:

bash
{
  for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
    echo "set_real_ip_from $ip;"
  done
  echo "real_ip_header CF-Connecting-IP;"
} | sudo tee /etc/nginx/conf.d/cloudflare-realip.conf

sudo nginx -t && sudo systemctl reload nginx

The file contains one set_real_ip_from line per Cloudflare IPv4 and IPv6 range, followed by real_ip_header CF-Connecting-IP;.

Cloudflare changes the list occasionally; rerun the script from a monthly cron job. After the reload, the access log's first field and client: in the error log show visitors, and every stock nginx filter works as usual — see the nginx guide.

Apache#

bash
{
  echo "RemoteIPHeader CF-Connecting-IP"
  for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
    echo "RemoteIPTrustedProxy $ip"
  done
} | sudo tee /etc/apache2/conf-available/cloudflare-remoteip.conf

sudo a2enmod remoteip && sudo a2enconf cloudflare-remoteip && sudo systemctl reload apache2

Use RemoteIPTrustedProxy (not RemoteIPInternalProxy) for Cloudflare's public ranges, and replace %h with %a in your LogFormat lines so the access log records the client. On RHEL the file goes in /etc/httpd/conf.d/.

Verify#

bash
sudo tail -n 5 /var/log/nginx/access.log

If the addresses still belong to Cloudflare, the header isn't being trusted. The general version of this problem — any proxy, load balancer or Docker network — is covered in fail2ban bans the proxy IP. For Nginx Proxy Manager, Traefik and Caddy, see their guides; each has its own setting.

Problem 2: ban at Cloudflare's edge#

fail2ban ships two Cloudflare actions:

Action Auth Creates rules at Use
cloudflare-token API token (Authorization: Bearer) One zone (/zones/<cfzone>/firewall/access_rules/rules) Recommended
cloudflare Account e-mail + Global API Key All zones of your user (/user/firewall/access_rules/rules) Legacy; the Global API Key can do anything on your account

Both create Cloudflare IP Access Rules with mode block and the note Fail2Ban <jail>, and delete the rule again when the ban expires. They need curl on the server.

Create the API token#

In the Cloudflare dashboard go to My Profile → API Tokens → Create Token → Create Custom Token and give it:

  • Permissions: Zone → Firewall Services → Edit
  • Zone Resources: Include → Specific zone → your domain

Copy the token, and copy the Zone ID from the domain's Overview page. The token can't do anything outside that zone's firewall settings, which is why the action's own comments recommend this permission alone.

Configure the action#

Keep secrets out of jail.local by putting them in a .local file for the action, readable only by root:

/etc/fail2ban/action.d/cloudflare-token.local
[Init]
cfzone  = 0123456789abcdef0123456789abcdef
cftoken = YOUR_API_TOKEN
bash
sudo chmod 640 /etc/fail2ban/action.d/cloudflare-token.local

Then add the action to the jails that protect proxied sites:

/etc/fail2ban/jail.local
[nginx-http-auth]
enabled  = true
backend  = auto
maxretry = 5
bantime  = 1h
action   = %(action_)s
           cloudflare-token

[nginx-404]
enabled  = true
backend  = auto
filter   = nginx-4xx[codes="404"]
logpath  = /var/log/nginx/access.log
maxretry = 20
action   = cloudflare-token

%(action_)s keeps the normal firewall ban, which still matters for anyone hitting your origin IP directly; cloudflare-token adds the edge ban. The nginx-4xx filter is the custom 404 filter from the nginx guide. A jail with only action = cloudflare-token bans at Cloudflare only. Options you can pass in brackets:

  • cfmode — block (default). The Cloudflare API also accepts challenge, js_challenge and managed_challenge, e.g. cloudflare-token[cfmode=managed_challenge].
  • cftarget — ip for IPv4 and ip6 for IPv6; the action switches automatically.
  • notes — the rule comment (Fail2Ban <name> by default), which the unban step uses to find the rule.

Zone vs account rules#

cloudflare-token writes zone-level rules, so a ban protects one domain. To ban across every zone in your account, override the API URL and use an account token (permission Account → Account Firewall Access Rules → Edit):

/etc/fail2ban/action.d/cloudflare-token.local
[Definition]
_cf_api_url = https://api.cloudflare.com/client/v4/accounts/<cfaccount>/firewall/access_rules/rules

[Init]
cfaccount = 0123456789abcdef0123456789abcdef
cftoken   = YOUR_ACCOUNT_API_TOKEN

We checked with fail2ban-client -d that the ban command then posts to the /accounts/… endpoint. Cloudflare allows 50,000 IP Access Rules per account on all plans; with long bans and the recidive jail on a busy site, keep an eye on the count. Cloudflare now recommends custom rules with IP lists over IP Access Rules for new setups, but IP Access Rules remain supported and are what both fail2ban actions use.

Test the edge ban#

bash
sudo fail2ban-client reload
sudo fail2ban-client set nginx-http-auth banip 192.0.2.15

The address should appear under the zone's IP Access Rules in the Cloudflare dashboard with the note Fail2Ban nginx-http-auth. Remove it with sudo fail2ban-client set nginx-http-auth unbanip 192.0.2.15 and the rule disappears. If nothing shows up, raise fail2ban's log level and look for curl errors in /var/log/fail2ban.log; an authentication error from the API means the token lacks the Firewall Services permission or access to that zone.

Cloudflare Tunnel#

With Cloudflare Tunnel, cloudflared opens an outbound connection to Cloudflare and forwards requests to your service locally. Every request reaches your web server from cloudflared — 127.0.0.1 or the cloudflared container's address — and there's no inbound port to firewall. Consequences:

  • Local bans can't work at all. Banning 127.0.0.1 would break your site; banning the visitor's IP locally matches nothing. Use action = cloudflare-token only, without %(action_)s.
  • Real IP: Cloudflare still adds CF-Connecting-IP. Trust it from where cloudflared connects: set_real_ip_from 127.0.0.1; (or the Docker network, e.g. 172.18.0.0/16) with real_ip_header CF-Connecting-IP;.
  • Keep 127.0.0.1/8 and ::1 in ignoreip, so a misconfigured real-IP setup never makes fail2ban ban the tunnel.

Whitelist Cloudflare in ignoreip#

If real-IP restoration ever fails, fail2ban starts banning Cloudflare edge addresses. As a safety net, put Cloudflare's ranges on the ignore list. fail2ban 1.1.1 can read them from a file:

bash
{ curl -s https://www.cloudflare.com/ips-v4; echo; curl -s https://www.cloudflare.com/ips-v6; } | sudo tee /etc/fail2ban/cloudflare-ips.txt
/etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 file:/etc/fail2ban/cloudflare-ips.txt

On versions before 1.1.1, list the ranges directly in ignoreip. This is a fallback, not a fix: with the safety net in place, a broken real-IP config means fail2ban bans nobody. More on whitelisting with ignoreip.

Finally, consider firewalling your origin so it only accepts HTTP(S) from Cloudflare's ranges. Then attackers can't bypass Cloudflare by connecting to your server's IP directly, and the edge ban covers everything.

FAQ#

Why does fail2ban not block attackers behind Cloudflare?#

Because the attacker never connects to your server; Cloudflare does. A local firewall rule for the attacker's IP matches no packets, and a rule for the Cloudflare IP in your logs blocks Cloudflare instead. Restore the real IP with CF-Connecting-IP and ban at Cloudflare's edge with the cloudflare-token action.

Which permissions does the Cloudflare API token for fail2ban need?#

For the stock cloudflare-token action, create a custom token with Zone → Firewall Services → Edit, limited to the zone you protect. For account-wide rules via the /accounts/ endpoint, the token needs Account → Account Firewall Access Rules → Edit. Store the token in action.d/cloudflare-token.local with mode 640.

What is the difference between the cloudflare and cloudflare-token actions?#

cloudflare-token authenticates with a scoped API token and creates zone-level IP Access Rules. The older cloudflare action uses your account e-mail and Global API Key, which has full access to your account, and creates user-level rules for all zones. Prefer cloudflare-token.

Does fail2ban work with Cloudflare Tunnel?#

Yes, but only with edge bans. All requests arrive from cloudflared on localhost, so local firewall bans are useless. Trust CF-Connecting-IP from 127.0.0.1 in your web server, keep localhost in ignoreip, and use action = cloudflare-token in the jails.

Should I whitelist Cloudflare IPs in fail2ban?#

As a safety net, yes: add Cloudflare's ranges to ignoreip (fail2ban 1.1.1 accepts file:/path). It prevents fail2ban from banning Cloudflare edge servers if real-IP restoration breaks. It doesn't replace the real-IP configuration, which is what lets fail2ban see and ban actual attackers.

Spotted something wrong or out of date? Tell us — every page is checked against the current fail2ban release.