Guide · geo blocking

Block or redirect visitors by country

Four ways to block, redirect or adapt content by country, from a Cloudflare rule without code to an API call in your own app, with configs we tested.

Updated October 29, 2026 · 8 min read

Geo blocking means allowing, blocking or redirecting visitors based on the country of their IP address. You can do it at four levels: at your CDN (a Cloudflare rule, no code), in your web server (nginx with a GeoIP2 database or the CDN’s country header), in your app’s edge code (Next.js proxy) or in any backend with an IP geolocation API call. This guide shows each one with configs and code we ran, and the mistakes that block more than you meant: search engine crawlers, payment webhooks and travelers.

Choose where to block

WhereCountry fromGood forLimits
Cloudflare custom ruleCloudflare’s geolocationSites behind Cloudflare; no code, blocks before your serverThe number of rules depends on your Cloudflare plan
nginxCF-IPCountry header or a local GeoIP2 databaseWhole sites, fast, no extra requestsDatabase needs regular updates; country only
Next.js proxyx-vercel-ip-country or cf-ipcountryApps on Vercel or behind CloudflareHeader only exists on those platforms
IP geolocation APIAn API call, cached per IPAny stack and host; country plus city, ISP and VPN flagsOne request per new IP (cache it)

Before you block

1. Cloudflare: a custom rule

If your site runs through Cloudflare, every request carries the visitor’s country. Go to Security → Security rules → Create rule → Custom rules, choose Edit expression and enter:

(ip.src.country in {"RU" "KP" "IR"})

Set the action to Block (or Managed Challenge) and deploy. To allow only some countries and block the rest:

(not ip.src.country in {"DE" "AT" "CH"} and not cf.client.bot)

The second part, not cf.client.bot, keeps Cloudflare’s verified bots (search engine crawlers, monitoring services) out of the block. ip.src.country is the current field name; the older ip.geoip.country still works but is deprecated. Custom rules are available on every Cloudflare plan; country blocks in IP Access rules are Enterprise-only, and Cloudflare recommends custom rules instead.

Read the country in your app. With Network → IP Geolocation switched on, Cloudflare adds CF-IPCountry: DE to every request it sends to your origin (XX when the country is unknown, T1 for Tor). The Add visitor location headers managed transform adds city, region and coordinates too. In a Cloudflare Worker, the country is request.cf.country.

2. nginx

Behind Cloudflare: use its header

When Cloudflare is in front, nginx can block on CF-IPCountry without a database. Only do this if your origin accepts connections from Cloudflare alone (an allow/deny list of the Cloudflare IP ranges, which that page offers as nginx config, or a firewall): anyone who reaches your server directly can send any CF-IPCountry they like.

# http {} context
map $http_cf_ipcountry $blocked_country {
    default 0;
    RU      1;
    KP      1;
    IR      1;
}

# the visitor's address for logs and rate limits (all Cloudflare ranges, from /ip-ranges/cloudflare/)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 2400:cb00::/32;
real_ip_header CF-Connecting-IP;
# server {} context
error_page 451 /unavailable.html;

location = /unavailable.html {
    root /var/www/errors;
    internal;
}

location / {
    if ($blocked_country) {
        return 451;
    }
    # your proxy_pass, root or try_files
}

Put the check inside location / rather than directly in server {}: the error page is an internal redirect, and a server-level check would block the error page itself. A return inside if is one of the few uses of if in a location that the nginx wiki’s “If is evil” page calls safe.

Without a CDN: the GeoIP2 module

Without a CDN, nginx looks the address up in a local database with the third-party ngx_http_geoip2_module. Install it from your distribution’s packages (Debian: libnginx-mod-http-geoip2) or build it as a dynamic module, and download MaxMind’s free GeoLite country database (GeoLite2-Country.mmdb, free account and license key required).

# main context (outside http {}), if the module is dynamic and not loaded by your package
load_module modules/ngx_http_geoip2_module.so;

http {
    geoip2 /etc/nginx/GeoLite2-Country.mmdb {
        auto_reload 24h;
        $geoip2_country_code country iso_code;
    }

    # behind a load balancer: take the visitor's address from X-Forwarded-For first
    set_real_ip_from 10.0.0.0/8;   # your load balancer
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    map $geoip2_country_code $blocked_country {
        default 1;   # block everything ...
        DE      0;   # ... except these countries
        AT      0;
        CH      0;
        ""      0;   # no country in the database: let through
    }
}

Use the same location / block as above. To hand the country to your application instead of blocking, add proxy_set_header X-Country $geoip2_country_code; next to your proxy_pass.

How we tested: nginx -t with nginx 1.31.8 (Homebrew) passed both configurations, and a running nginx returned 451 with the error page for CF-IPCountry: RU and 200 for DE. Homebrew’s nginx has no GeoIP2 module, so for the second configuration we set $geoip2_country_code from a test header and ran everything else as printed: AT got 200, US 451 and an empty country 200. The geoip2 {} block follows the module’s README, and mmdblookup confirmed that the path country iso_code returns the ISO code in MaxMind’s test database. MaxMind updates the GeoLite country and city databases twice a week (Tuesday and Friday); run geoipupdate from cron so auto_reload picks up the new file.

3. Next.js: proxy (formerly middleware)

Next.js 16 renamed middleware.ts to proxy.ts and the exported function to proxy; the API is otherwise the same. On Vercel the country is in x-vercel-ip-country, behind Cloudflare in cf-ipcountry:

// proxy.ts – Next.js 16 (in Next.js 15 and earlier: middleware.ts with a function named middleware)
import { NextResponse, type NextRequest } from "next/server";

const BLOCKED = new Set(["RU", "KP", "IR"]);

export function proxy(request: NextRequest) {
  // Vercel sets x-vercel-ip-country; behind Cloudflare it is cf-ipcountry (with IP Geolocation on)
  const country = request.headers.get("x-vercel-ip-country") ?? request.headers.get("cf-ipcountry");

  if (country && BLOCKED.has(country)) {
    return new NextResponse("This service is not available in your country.", { status: 451 });
  }

  // Suggest, don't force: pass the country to the app, which can show a banner
  const headers = new Headers(request.headers);
  headers.set("x-country", country ?? "");
  return NextResponse.next({ request: { headers } });
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)"],
};

A server component reads the country with (await headers()).get("x-country") and can offer the local version. Read only the header your platform sets: on any other host, a visitor can send x-vercel-ip-country themselves. We type-checked the file with TypeScript 7 in strict mode and ran proxy() with real NextRequest objects from Next.js 16.4: RU and IR got 451, DE passed through with x-country: DE.

4. Any stack: an IP geolocation API

If no CDN adds the country, or you need more than the country, look the visitor’s address up yourself. This Express middleware asks the ipbase API for the country and the is_anonymous flag (VPN, proxy, Tor or relay), caches the answer for a day and fails open, so an API problem never takes your site down:

// geoblock.mjs – Express middleware, Node.js 18 or later
const BLOCKED = new Set(["RU", "KP", "IR"]);
const DAY_MS = 24 * 60 * 60 * 1000;
const cache = new Map(); // ip -> { at, country, anonymous }

async function lookup(ip) {
  const hit = cache.get(ip);
  if (hit && Date.now() - hit.at < DAY_MS) return hit;
  const params = new URLSearchParams({ ip, fields: "location.country.alpha2,security.is_anonymous" });
  const res = await fetch(`https://api.ipbase.com/v2/info?${params}`, {
    headers: { apikey: process.env.IPBASE_KEY },
    signal: AbortSignal.timeout(3000),
  });
  if (!res.ok) throw new Error(`ipbase ${res.status}`);
  const { data } = await res.json();
  const entry = {
    at: Date.now(),
    country: data.location?.country?.alpha2 ?? null,
    anonymous: data.security?.is_anonymous ?? null, // null without security data in the plan
  };
  cache.set(ip, entry);
  return entry;
}

export async function geoBlock(req, res, next) {
  try {
    const { country, anonymous } = await lookup(req.ip.replace(/^::ffff:/, ""));
    req.country = country;
    if (BLOCKED.has(country)) {
      return res.status(451).send("This service is not available in your country.");
    }
    if (anonymous === true && req.path.startsWith("/checkout")) {
      return res.status(403).send("Please turn off your VPN or proxy to check out.");
    }
  } catch (err) {
    console.error("geo lookup failed:", err.message); // fail open
  }
  next();
}
// app.mjs
import express from "express";
import { geoBlock } from "./geoblock.mjs";

const app = express();
app.set("trust proxy", 1); // one proxy in front: req.ip is the visitor
app.use(geoBlock);

The country check works with a free key (1,000 requests a month). The VPN check needs security data, which starts with the Medium plan; with a free key is_anonymous is null and the checkout rule never fires. We ran the middleware with Express 5 against a local stub of the API: a Russian address got 451, an Austrian one 200, a VPN address 403 on /checkout with a paid key and 200 with a free key, and an invalid key let the request through with the error logged. The IP geolocation tutorial has the same lookup in Python and PHP and explains how to find the real client IP behind proxies.

Test your rules

Frequently asked questions

What is geo blocking?

Geo blocking means allowing, blocking or redirecting requests based on the country of the visitor's IP address. The country comes from an IP geolocation database or API, or from a header your CDN adds, such as Cloudflare's CF-IPCountry.

Is blocking by country reliable?

It stops casual traffic, but VPN and proxy users can appear to be in another country. Combine country rules with VPN detection when it matters, for example for licensing or sanctions compliance.

Which HTTP status code should a geo block return?

451 Unavailable For Legal Reasons (RFC 7725) when the content is blocked for legal reasons, such as licensing or sanctions; 403 Forbidden for other blocks. Return a short page that explains the block and how to contact you.

Should I redirect visitors by country?

Suggest rather than force: show a banner like 'Go to our UK site?' and remember the choice. Forced redirects frustrate travelers and can keep search engines from crawling every version.

Can I geo block on Cloudflare's free plan?

Yes, with a custom rule on the ip.src.country field; custom rules are available on all Cloudflare plans. Country blocks in IP Access rules are Enterprise-only, and Cloudflare recommends custom rules instead.

IP geolocation API

Get this data in your app

ipbase returns location, ISP, ASN and time zone for any IPv4 or IPv6 address, up to 1,000 addresses per POST request. Security flags (VPN, proxy, Tor, data center) are on the Medium plan and above.

Read the documentation

1,000 free requests every month. No credit card required.

GET https://api.ipbase.com/v2/info?ip=1.1.1.1

{
  "data": {
    "ip": "1.1.1.1",
    "type": "v4",
    "connection": {
      "asn": 13335,
      "organization": "APNIC and Cloudflare DNS Resolver project",
      "isp": "Cloudflare, Inc",
      "range": "1.1.1.0/24"
    },
    "location": {
      "country": { "alpha2": "AU", "name": "Australia" },
      "zip": "4000"
    },
    "security": { "is_vpn": null, "is_tor": null, "threat_score": null }
  }
}

Keep reading

Get your free IP geolocation API key

Start with 1,000 free requests a month