Blocking a country's traffic with Cloudflare WAF (without blocking everyone)
One country was sending 96% of our traffic, and almost none of it was real users. Here's the WAF rule that stopped it, the expression mistakes that block everyone or no one, and how to check the rule does what you meant.
The symptom
One country sent over 60,000 requests in 24 hours, 96% of all traffic to corsproxy.dev, while every other country combined sent a few hundred. It came in spikes, not the steady daily curve real usage has. That split was the giveaway: this was a flood, not growth. Most of it was unauthenticated, so it reached the Worker before our per-key rate limiter had anything to count.
Why this isn't a job for your app's rate limiter
corsproxy.dev already rate-limits every account with a Cloudflare Durable Object, one atomic counter per account. That's the right tool for "this user is over their quota." It's the wrong tool for "someone is throwing anonymous volume at the edge," because by the time your Worker code runs, you've already paid to run it. You want to reject that traffic before it reaches your application.
That's what a WAF (Web Application Firewall) custom rule is for: it blocks at Cloudflare's edge, before your Worker runs.
The rule
Cloudflare → your zone → Security → WAF → Custom rules → Create rule. Match on ip.src.country, which takes a two-letter ISO 3166-1 country code. Using Germany as an example:
(ip.src.country eq "DE")
Blocking several countries:
(ip.src.country in {"DE" "US"})
Pick an action. Block returns a 403 for every matching request. Managed Challenge is gentler: real people in that country can still get through, and bots mostly can't. If you're not sure the traffic is all junk, start with the challenge. The rule applies to every hostname in the zone: landing page, app, and API.
Older rules and blog posts use ip.geoip.country for the same thing. If you're copying an expression from somewhere, check the field against Cloudflare's field reference.
Mistakes that block too much, or nothing
- "UK" isn't a country code. The United Kingdom is
GB.(ip.src.country eq "UK")is a valid expression that matches nothing, so the rule saves fine and silently does nothing. - A stray
notturns a blocklist into an allowlist.(not ip.src.country in {"US" "GB" "DE"})blocks every country except those three. That's right if you meant it, and a site-wide outage for everyone else if you didn't. neinstead ofeqdoes the same:(ip.src.country ne "DE")blocks everyone except Germany.- Testing from inside the country you blocked. If your own connection is in that country, every check you run looks like a global outage, because for you it is.
Checking it actually worked
Don't trust the dashboard toggle alone. Right after deploying:
- From outside the blocked country: confirm you still get a normal 200, not a block page. A VPN exit in another country works.
- From traffic data, not your own curl: look at request counts by country and status code for the last few minutes, in Security → Events or the GraphQL Analytics API (
httpRequestsAdaptiveGroups, grouped byclientCountryNameandedgeResponseStatus). The blocked country should show 403s; every other country should look like it did before.
What this doesn't solve
A country block stops volume from one region. It doesn't stop a flood spread across many countries, and it's a blunt tool if any of your real users live in the blocked country. We used it because the pattern was clear, a 96/4 split with none of the signs of real usage, and because it's one click to undo if that changes.