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.country or CF-IPCountry) and resolve an allow / block / redirect decision
  • Return a legally clear 451 Unavailable For Legal Reasons or 403 Forbidden for blocked regions
  • Send a 302 redirect to a localized path (/de/, /fr/) without breaking caching
  • Reproduce the rule with zero code using a Cloudflare WAF custom rule or Transform Rule
Country-to-action decision flow at the edge An incoming request is geolocated to an ISO country code, then routed to allow, redirect, or block based on allowlist, localization, and blocklist checks. Incoming request CF-IPCountry: DE Resolve country ISO 3166-1 alpha-2 In blocklist? return 451 / 403 Needs locale? 302 -> /de/ In allowlist? pass to origin Block Redirect Allow

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.

Back to Geo-Targeted Traffic Routing