Blocking or Redirecting Traffic by Country at the Edge
This guide shows you how to allow, block, or redirect visitors based on their country or region directly at the CDN edge — before a request ever reaches your origin. After reading it you will be able to read the visitor’s country from a Cloudflare Worker or the CF-IPCountry header, enforce an allowlist or blocklist, redirect users to a localized path, return a compliant 451 or 403 for restricted regions, and ship the same decision as a no-code WAF or Transform rule. These patterns extend the latency work covered in Implementing Geo-Routing with Edge Functions for Latency Reduction and slot neatly into your broader Geo-Targeted Traffic Routing strategy.
Key objectives:
- Read the country code (
request.cf.countryorCF-IPCountry) and resolve an allow / block / redirect decision - Return a legally clear
451 Unavailable For Legal Reasonsor403 Forbiddenfor blocked regions - Send a
302redirect to a localized path (/de/,/fr/) without breaking caching - Reproduce the rule with zero code using a Cloudflare WAF custom rule or Transform Rule
Prerequisites and environment setup
The country signal only exists when Cloudflare proxies the hostname. Confirm the orange-cloud proxy is on for the record you intend to gate; a grey-cloud (DNS-only) record exposes neither request.cf.country nor the CF-IPCountry header because traffic never transits the edge network.
| Requirement | Why it matters |
|---|---|
| Proxied (orange-cloud) DNS record | Without the proxy, no geolocation header is injected |
Wrangler 3.x (npx wrangler --version) |
Used to deploy and tail the Worker |
| IP Geolocation enabled | On by default; the CF-IPCountry header toggle lives under Rules → Settings |
| A localized origin or path scheme | Needed for the redirect branch (e.g. /de/, /fr/) |
Country codes are ISO 3166-1 alpha-2 (DE, JP, BR). Cloudflare also emits XX when it cannot determine a location and T1 for Tor exit nodes — both must be handled explicitly so they do not fall through your allowlist by accident.
npx wrangler --version
# wrangler 3.78.0
# Confirm the record is proxied (expect Cloudflare anycast IPs, not your origin)
dig +short app.example.com
# 104.16.x.x
Step-by-step procedure
Step 1: Define the policy and decision function
Keep the country sets and the action mapping in one place so the Worker stays readable and the policy is auditable. Treat EU as a derived set of member states — the edge never hands you a continent code, only a country. This mirrors the country-set approach used in the geo-routing guide.
// policy.js
export const BLOCKED = new Set(['CU', 'IR', 'KP', 'SY']); // sanctioned regions
export const ALLOWED = null; // null = allow all not blocked; or new Set([...]) for strict allowlist
export const LOCALIZE = new Map([
['DE', '/de/'], ['AT', '/de/'], ['CH', '/de/'],
['FR', '/fr/'], ['BE', '/fr/'],
]);
export function decide(country) {
if (!country || country === 'XX') return { action: 'allow' }; // fail open on unknown
if (BLOCKED.has(country)) return { action: 'block' };
if (ALLOWED && !ALLOWED.has(country)) return { action: 'block' };
const locale = LOCALIZE.get(country);
if (locale) return { action: 'redirect', locale };
return { action: 'allow' };
}
Decide deliberately whether unknown (XX) traffic should fail open or fail closed. Compliance blocking usually fails closed (block the unknown); localization fails open (serve the default). The example fails open — flip the first guard to return { action: 'block' } for sanction enforcement.
Step 2: Implement the Worker
The Worker reads request.cf.country, runs the policy, and returns the correct response. A 451 carries a Link header pointing at the blocking authority, which is the documented convention for legally-mandated blocks; a 403 is the right choice for business or licensing restrictions.
import { decide } from './policy.js';
export default {
async fetch(request) {
const country = request.cf?.country || request.headers.get('CF-IPCountry') || 'XX';
const { action, locale } = decide(country);
if (action === 'block') {
return new Response('Not available in your region.', {
status: 451,
headers: {
'Content-Type': 'text/plain',
'Link': '<https://example.com/legal/geo-policy>; rel="blocked-by"',
'Cache-Control': 'no-store',
'X-Blocked-Country': country,
},
});
}
if (action === 'redirect') {
const url = new URL(request.url);
if (!url.pathname.startsWith(locale)) {
url.pathname = locale + url.pathname.replace(/^\//, '');
return Response.redirect(url.toString(), 302);
}
}
return fetch(request); // allow -> origin
},
};
The redirect branch guards against loops by skipping the redirect when the path is already prefixed. Side effect: every blocked response sets Cache-Control: no-store so a 451 is never cached against a shared cache key and leaked to an allowed visitor.
Step 3: Deploy and bind the route
Attach the Worker to the hostnames you want gated. Scope the route tightly so unrelated paths (health checks, webhooks) are not subject to geo policy.
npx wrangler deploy
# Uploaded geo-gate (1.2 sec)
# Published geo-gate
# app.example.com/* => geo-gate
Step 4 (alternative): No-code WAF rule
If you do not need a localized redirect, a Cloudflare WAF custom rule blocks by country with no deployment. In Security → WAF → Custom rules, create a rule with this expression and the Block action:
(ip.src.country in {"CU" "IR" "KP" "SY"})
For a strict allowlist (block everything except a set), invert the match:
(not ip.src.country in {"US" "CA" "GB" "DE" "FR"})
To redirect instead of block — for example forcing German visitors to /de/ — use a Transform Rule (Rules → Transform Rules → URL Rewrite) or a Redirect Rule with the expression ip.src.country eq "DE" and a dynamic target of concat("/de", http.request.uri.path). WAF rules and Transform Rules evaluate before the Worker, so layering them lets you offload coarse blocking to the WAF while the Worker handles nuanced localization. For broader attack filtering on the same surface, see Blocking Common Attacks with Cloudflare WAF Rules.
Verification
Spoofing your own country reliably is the hard part. Cloudflare geolocates the real client IP, so you cannot fake a country with a header on a live request. Test from real exit IPs (a VPN), or use --resolve against a staging hostname combined with a trusted CF-Connecting-IP override only available on internal test setups. The portable check is to read the country the edge actually assigned:
# Inspect the country Cloudflare assigned to your current IP
curl -sI https://app.example.com/cdn-cgi/trace | grep -i loc
# loc=NL
# A blocked region returns 451 with the policy link
curl -sI https://app.example.com/ -H 'Host: app.example.com'
# HTTP/2 451
# x-blocked-country: IR
# link: <https://example.com/legal/geo-policy>; rel="blocked-by"
# A localized country returns a 302 to the prefixed path
curl -sI https://app.example.com/pricing
# HTTP/2 302
# location: https://app.example.com/de/pricing
Tail the Worker in real time to watch decisions resolve as requests arrive:
npx wrangler tail geo-gate --format pretty
# GET https://app.example.com/ Ok country=IR action=block
# GET https://app.example.com/pricing Ok country=DE action=redirect
The /cdn-cgi/trace endpoint is the ground truth for what country the edge believes you are in — always compare your expectation against loc= before assuming the policy is wrong.
Troubleshooting
VPN and proxy false positives
A visitor on a corporate VPN or cloud egress will geolocate to the VPN exit, not their physical location. There is no header that recovers the true origin. For compliance blocks this is acceptable (you blocked the apparent location); for localization, add a manual override — honor a ?lang= query parameter or a persisted cookie that short-circuits the country lookup so users can self-correct.
const override = new URL(request.url).searchParams.get('lang');
const country = override
? override.toUpperCase()
: request.cf?.country || 'XX';
EU vs single-country granularity
The edge returns countries, never EU. Building “block the EU” from a stale member-state list silently drifts. Cloudflare exposes request.cf.continent (EU, AS, NA) and request.cf.isEUCountry === '1' — prefer those over a hand-maintained set when your policy is genuinely continent- or bloc-level.
if (request.cf?.isEUCountry === '1') { /* GDPR consent path */ }
Blocked or redirected responses getting cached per country
If you cache HTML at the edge, a 451 or a localized 302 can be served to the wrong audience under a shared cache key. Either set Cache-Control: no-store on every geo-decided response (as the Worker does), or add country to the cache key so each region gets its own cached variant. With a Cache Rule, add Country to the custom cache key; in a Worker, vary the key explicitly.
const cache = caches.default;
const key = new Request(`${request.url}|cc=${country}`, request);
XX or T1 slipping through
Unknown (XX) and Tor (T1) codes never match a country set, so a naive allowlist treats them as blocked and a naive blocklist treats them as allowed — usually the opposite of intent. Handle both explicitly at the top of decide() and choose fail-open or fail-closed on purpose.
Worker runs but country is always XX
This almost always means the hostname is grey-clouded (DNS-only) or the request is reaching the Worker over a path where the proxy is bypassed. Re-check the proxy status with dig +short (expect anycast IPs) and confirm the route pattern matches the gated hostname rather than a wildcard that also catches unproxied subdomains.
Frequently Asked Questions
Can a user fake their country with a header to bypass the block?
No. Cloudflare derives the country from the real client IP at the edge and overwrites any inbound CF-IPCountry header, so a forged header is ignored. Only changing the apparent source IP (a VPN or proxy) changes the result.
Should I return 403 or 451 for a geo-block?
Use 451 Unavailable For Legal Reasons with a Link: rel="blocked-by" header when the block is legally mandated (sanctions, licensing law), and 403 Forbidden for business or contractual restrictions. The 451 status is the documented signal for legally-compelled unavailability.
Why does the edge never return EU or another region code?
Geolocation resolves to a single ISO 3166-1 alpha-2 country. To act on a bloc, derive it from request.cf.continent or request.cf.isEUCountry rather than maintaining your own list of member states, which drifts as membership changes.
Will geo-blocking hurt my cache hit ratio?
Only if you cache geo-decided responses. Mark blocked and redirected responses no-store, or include country in the cache key so each region caches independently. Pass-through (allowed) traffic keeps its normal cache behavior.
Should unknown (XX) traffic be allowed or blocked?
Decide by intent: compliance blocking should fail closed (block unknowns) so sanctioned traffic cannot slip through a geolocation gap, while localization should fail open (serve the default locale) to avoid breaking legitimate visitors with unresolved IPs.
Related
- Implementing Geo-Routing with Edge Functions for Latency Reduction
- Blocking Common Attacks with Cloudflare WAF Rules
- Geo-Targeted Traffic Routing
Back to Geo-Targeted Traffic Routing