CrowdSec Behind Cloudflare: Who Actually Sees the Attacker's IP, Hands-On
If you have been following the blocking conversation on this blog, you already know the first half: the Fail2ban vs CrowdSec post settled how both tools watch logs, write decisions, and hand them to bouncers, and the reverse proxy comparison showed where Caddy, Traefik, and Nginx Proxy Manager sit in front of everything. What neither post answered is the question that shows up the moment you put Cloudflare in front of your CrowdSec-protected origin: who actually sees the attacker's IP?
The short version up front: Cloudflare sees it, your origin does not, and everything downstream, CrowdSec included, only ever sees what your reverse proxy writes into the log. If you skip the trusted-proxy configuration, you end up banning Cloudflare's edge IPs instead of the attacker, or worse, you trust a spoofable header and let attackers poison your decisions. This post walks through trusted proxies, X-Forwarded-For spoofing, firewall-level versus app-level blocking, and the CrowdSec Cloudflare bouncer that moves the block to the edge.
The short version
- Cloudflare terminates the TCP connection, so your origin sees edge IPs, not attackers. The real client address rides in headers, and headers are only trustworthy from peers you explicitly trust.
- A firewall bouncer behind Cloudflare bans the wrong address. App-level bouncers see the real IP only after trusted proxies rewrite the log and remote_addr.
- The CrowdSec Cloudflare bouncer pushes decisions into Cloudflare, either as IP lists plus firewall rules or as a Worker plus KV store, so the block happens before traffic reaches your origin.
- Header spoofing is the attack that breaks everything. Trust X-Forwarded-For from the wrong place and attackers choose what CrowdSec bans.
- CrowdSec Security Engine
1.6.x current, 1.7 next - crowdsec-cloudflare-bouncer (classic)
Current from CrowdSec apt repo / Docker Hub - crowdsec-cloudflare-worker-bouncer
Current from CrowdSec apt repo; docs now recommend this one - Cloudflare IP ranges
Published at cloudflare.com/ips; ranges in this post checked 2026-08-28 - nginx realip module
Built into nginx - Traefik / Caddy
Traefik v3.x forwardedHeaders; Caddy 2.x trusted_proxies / remote_ip
Checked 2026-08-28 against cloudflare.com/ips, developers.cloudflare.com restoring-original-visitor-ips, docs.crowdsec.net (Cloudflare bouncer, nginx bouncer, WAF reverse proxy how-to), and the CrowdSec apt repos. Both CrowdSec and Cloudflare move fast; re-check before you rely on exact versions.
What Cloudflare actually does to your logs
When a domain is proxied through Cloudflare, the attacker never connects to your server. They connect to a Cloudflare edge, and Cloudflare opens a second connection to your origin from one of its own IPs. Your access log, your firewall, and CrowdSec all see the edge IP, which is why the first symptom of a misconfigured setup is decisions against addresses like 173.245.x.x, which belong to Cloudflare, not to the attacker.
Cloudflare publishes its ranges at cloudflare.com/ips. The list is stable enough to paste into config, but it does change, so treat it like a dependency you refresh on a schedule, not a one-time setup. Before any trusted-proxy work, this is what the origin actually sees:
$ tail -n 3 /var/log/nginx/access.log
173.245.48.1 - - [28/Aug/2026:10:00:01 +0000] "POST /wp-login.php HTTP/2.0" 401 223 "-" "python-requests/2.31.0"
173.245.48.9 - - [28/Aug/2026:10:00:03 +0000] "GET /xmlrpc.php HTTP/2.0" 404 162 "-" "curl/8.5.0"
# the attacker's real IP is not in this log at all
# every line carries a Cloudflare edge addressThe headers that carry the real IP
Cloudflare passes the original visitor address in HTTP headers. Two matter here. CF-Connecting-IP is set by Cloudflare to the client address and carries a single value, which is why Cloudflare's own docs recommend it when your server supports it. X-Forwarded-For is the standard forwarding header, and it can be a chain: the client appends, then every proxy appends. The last value is the closest hop to you.
Header | Who sets it | Safe to trust from | What it carries |
|---|---|---|---|
| Cloudflare edge | Cloudflare ranges only | Single client address; recommended by Cloudflare |
| Client, then every proxy appends | Only your trusted proxy ranges | Chain of addresses; last hop is the closest proxy |
| The connection itself | Always truthful, but it is the edge IP | What a firewall bouncer sees behind Cloudflare |
The spoofing trap: X-Forwarded-For is not a fact
Here is the failure that gets people. X-Forwarded-For is a client-controlled header. Nothing stops an attacker from sending X-Forwarded-For: 198.51.100.77 on every request. If your reverse proxy trusts that header unconditionally, you have given every attacker a free ban button aimed at any IP they choose, plus a way to dodge bans by rotating fake addresses. This is exactly why trusted-proxy configuration exists: you only honor forwarded headers from peers you named.
Hands-on: teach your reverse proxy who to trust
nginx: the realip module
nginx solves this with the realip module. You list every Cloudflare range with set_real_ip_from, tell nginx which header to trust with real_ip_header, and enable recursive resolution so the last trusted proxy wins. The full IPv4 and IPv6 range set, checked against cloudflare.com/ips:
# /etc/nginx/conf.d/cloudflare-real-ip.conf
# keep this file in sync with https://www.cloudflare.com/ips/
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;
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;
real_ip_recursive on;Traefik: forwardedHeaders
Traefik v3 handles the same problem in the entrypoint configuration. The forwardedHeaders.trustedIPs list is the allowlist; everything outside it is ignored when Traefik reads forwarding headers. Same ranges, same discipline:
# traefik dynamic config, entrypoints section
entryPoints:
websecure:
address: ":443"
forwardedHeaders:
trustedIPs:
- 173.245.48.0/20
- 103.21.244.0/22
- 104.16.0.0/13
- 104.24.0.0/14
- 172.64.0.0/13
- 162.158.0.0/15
- 198.41.128.0/17
# ... every IPv4 and IPv6 range from cloudflare.com/ipsCaddy: trusted_proxies or the Cloudflare matcher pattern
Caddy 2.5 added a native trusted_proxies option on the reverse_proxy directive. Cloudflare's own docs show the stricter pattern: a remote_ip matcher restricted to Cloudflare ranges, which both rewrites the header and rejects direct non-Cloudflare connections:
example.com {
@cloudflare {
remote_ip 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 \
2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 \
2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32
}
handle @cloudflare {
reverse_proxy 127.0.0.1:8080 {
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
}
}
handle {
respond "Access Denied" 403
}
}Make CrowdSec see the real IP
CrowdSec's nginx parser reads remote_addr from the access log line, and the nginx bouncer checks ngx.var.remote_addr on every request. Both inherit the realip rewrite: once nginx or your proxy has been taught the trusted ranges, the log and remote_addr contain the client address, and CrowdSec sees the attacker instead of the edge. There is nothing extra to configure on the CrowdSec side for the standard nginx setup, which is the point of doing the rewrite at the proxy.
# check what the parser extracts from a real log line
$ sudo cscli explain --file /var/log/nginx/access.log --type nginx | head -n 20
# every alert and decision should carry the attacker's address,
# never a 173.245.x.x or 104.x.x.x edge IP
$ sudo cscli alerts list -a | grep -A2 source_ip
$ sudo cscli decisions list -iWhere to block: firewall, app, or edge
With the real IP in the logs, the next question is which bouncer should act on the decision. The answer follows directly from what each layer can see:
- Firewall-level (crowdsec-firewall-bouncer). The netfilter or nftables bouncer sees the TCP source, which behind Cloudflare is an edge IP. Blocking there means banning Cloudflare ranges, which either fails to protect or takes down legitimate traffic sharing the same edge. It only makes sense for traffic that reaches you directly, such as SSH on a port you keep outside Cloudflare.
- App-level (crowdsec-nginx-bouncer, Caddy bouncer). These run inside the web server and check remote_addr after the realip rewrite, so they see the attacker. They answer with a 403 or a captcha for banned IPs at the origin, which works fine behind Cloudflare once trusted proxies are configured.
- Edge-level (Cloudflare bouncer). This reads decisions from CrowdSec's LAPI and pushes them into Cloudflare, where the block happens before traffic reaches your origin. It is the only option that keeps the attacker from consuming your bandwidth and your TLS terminator at all.
The mental model: the firewall bouncer protects the box, the app bouncer protects the response, and the Cloudflare bouncer protects the edge. Behind Cloudflare you want the edge and the app layers; the firewall layer is for the direct-access ports.
Hands-on: the Cloudflare bouncer
CrowdSec offers two Cloudflare bouncers. The classic crowdsec-cloudflare-bouncer syncs decisions into Cloudflare as IP lists plus firewall rules. The newer crowdsec-cloudflare-worker-bouncer deploys a Cloudflare Worker with a KV store, and the CrowdSec docs now recommend it for most setups. Both need a Cloudflare user API token with the exact permissions listed on the bouncer docs page, scoped to the accounts and zones you actually protect.
$ sudo apt install crowdsec-cloudflare-bouncer
# register a bouncer key with the local LAPI
$ sudo cscli bouncers add cloudflare-bouncer
Api key for 'cloudflare-bouncer': 8f8f8f8f...
# dry run: create the Cloudflare lists and firewall rules, then exit
$ sudo crowdsec-cloudflare-bouncer -s# /etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml
crowdsec_lapi_url: http://127.0.0.1:8080/
crowdsec_lapi_key: <lapi-key>
cloudflare_config:
accounts:
- id: <cloudflare-account-id>
token: <cloudflare-api-token>
ip_list_prefix: crowdsec
default_action: challenge
zones:
- zone_id: <zone-id>
actions:
- block
- challenge
update_frequency: 30s
crowdsec_update_frequency: 10sThe supported actions are managed_challenge, block, js_challenge, challenge. The bouncer also handles country and AS-scoped decisions, and the helper flag creates the lists and rules once so you can inspect them in the Cloudflare dashboard before the daemon takes over.
$ sudo apt install crowdsec-cloudflare-worker-bouncer
# generate the config from your Cloudflare account token and zone ids
$ sudo crowdsec-cloudflare-worker-bouncer -g <account-token>,<zone-ids> \
-o /etc/crowdsec/bouncers/crowdsec-cloudflare-worker-bouncer.yaml
# review the config, set the LAPI key, then deploy the worker
$ sudo crowdsec-cloudflare-worker-bouncer -S -c \
/etc/crowdsec/bouncers/crowdsec-cloudflare-worker-bouncer.yaml| Feature | crowdsec-cloudflare-bouncer (classic) | crowdsec-cloudflare-worker-bouncer |
|---|---|---|
| Where decisions land | Cloudflare IP lists + firewall rules | Cloudflare KV store |
| Blocking action | block, challenge, or js_challenge firewall rule | Ban (custom HTML) or Turnstile captcha |
| Decision scope | IP, country, and AS decisions | IP-level decisions in KV |
| Deployment | Daemon on the origin host | Cloudflare Worker deployed from the CLI or self-hosted installer |
| Docs status | Available, older approach | Recommended by current CrowdSec docs |
Where decisions land
- crowdsec-cloudflare-bouncer (classic)
- Cloudflare IP lists + firewall rules
- crowdsec-cloudflare-worker-bouncer
- Cloudflare KV store
Blocking action
- crowdsec-cloudflare-bouncer (classic)
- block, challenge, or js_challenge firewall rule
- crowdsec-cloudflare-worker-bouncer
- Ban (custom HTML) or Turnstile captcha
Decision scope
- crowdsec-cloudflare-bouncer (classic)
- IP, country, and AS decisions
- crowdsec-cloudflare-worker-bouncer
- IP-level decisions in KV
Deployment
- crowdsec-cloudflare-bouncer (classic)
- Daemon on the origin host
- crowdsec-cloudflare-worker-bouncer
- Cloudflare Worker deployed from the CLI or self-hosted installer
Docs status
- crowdsec-cloudflare-bouncer (classic)
- Available, older approach
- crowdsec-cloudflare-worker-bouncer
- Recommended by current CrowdSec docs
The limits that bite
- Firewall bans behind Cloudflare are wrong by default. The netfilter bouncer sees edge IPs, so a decision it enforces either bans Cloudflare ranges or does nothing useful. Keep it for direct-access ports only.
- A stale range list silently reverts the fix. When Cloudflare adds ranges and your set_real_ip_from or trustedIPs misses them, traffic from those ranges goes back to being attributed to edge IPs, and the whole chain breaks again without an error.
- The Cloudflare bouncer only protects proxied zones. Grey-cloud (unproxied) DNS records are outside its reach, so those zones still need an app-level or firewall bouncer.
- Header trust is binary. One wrong range in the trusted list makes the whole chain spoofable. Audit it as a security control, not a config detail.
- The worker bouncer has a billing surface. Workers requests and KV storage are metered, and the docs warn about KV charges, including for zones that should have been excluded from scope.
- CrowdSec still only sees what the log says. If an application behind the proxy writes its own logs with the edge IP, decisions derived from those sources inherit the same attribution problem.
Which should you pick?
- Cloudflare in front of a web origin: use the Cloudflare bouncer for the edge block, and an nginx or Caddy bouncer behind it for the 403 or captcha response. Configure trusted proxies first, because both layers depend on the rewrite.
- No proxy, or direct ports: the firewall bouncer is fine, because the TCP source is the attacker. Keep it scoped to the interfaces and ports that are actually exposed.
- Cloudflare plus direct access on the same box: run the Cloudflare bouncer for the proxied zones and the firewall bouncer for the direct ports, and make sure the trusted ranges cover only the proxy.
- You just want the logs to tell the truth: the trusted-proxy configuration alone fixes attribution. The bouncers decide where the block lands, but without the rewrite, none of them see the attacker.
Official sources
- Cloudflare IP ranges: https://www.cloudflare.com/ips/
- Restoring original visitor IPs (Cloudflare): https://developers.cloudflare.com/support/troubleshooting/restoring-visitor-ips/restoring-original-visitor-ips/
- CrowdSec Cloudflare bouncer docs: https://docs.crowdsec.net/u/bouncers/cloudflare/
- crowdsec-cloudflare-bouncer (GitHub): https://github.com/crowdsecurity/cs-cloudflare-bouncer
- crowdsec-cloudflare-worker-bouncer (GitHub): https://github.com/crowdsecurity/cs-cloudflare-worker-bouncer
- CrowdSec nginx bouncer: https://docs.crowdsec.net/u/bouncers/nginx/
- CrowdSec WAF reverse proxy how-to: https://docs.crowdsec.net/u/user_guides/waf_rp_howto/
- nginx realip module: https://nginx.org/en/docs/http/ngx_http_realip_module.html
- Our Fail2ban vs CrowdSec post: https://systhoughts.com/posts/fail2ban-vs-crowdsec
- Our reverse proxy comparison: https://systhoughts.com/posts/caddy-vs-traefik-vs-nginx-proxy-manager
Are you running CrowdSec behind Cloudflare? Which layer ends up doing the blocking for you, and did you ever catch a decision aimed at a Cloudflare edge instead of an attacker? Drop it in the comments.
Until next time, keep your systems thoughtful.

No comments yet