Geo-blocking by country: how to block or allow a website by IP, and what EU law says

Blocking a country by IP is a few lines of code. Doing it so that it holds up legally, doesn't wreck your search visibility and fails safely when a lookup goes wrong takes more thought. Code in five runtimes, plus the EU rules you need to know.

On this page First decide: block, restrict, or suggest?

There are good reasons to restrict a website by country. A licence only covers some territories. A product can't legally be sold somewhere. Sanctions rules apply to your business. Or you simply can't support customers in a region yet.

The mechanics are simple: look up the country of the visitor's IP address and decide what to serve. This guide covers that part in Node.js, Python, PHP, Go and an edge worker. It also covers the parts that cause trouble later: the EU rules that forbid many kinds of geo-blocking, what search engines expect, VPNs, and what to do when the lookup fails.

Important

This article explains how the rules work in practice. It isn't legal advice. If a block is legally required, or legally risky, check it with a lawyer who knows your market.

First decide: block, restrict, or suggest?

"Geo-blocking" covers three different things, and they have different consequences:

ApproachWhat the visitor seesTypical reason
BlockAn explanation page, no contentSanctions, a product that can't be offered, a licence that excludes the country
RestrictThe site, minus some items or featuresLicensed content per territory, products that can't ship there
SuggestThe site, plus a banner pointing to a local versionDifferent stores, languages or currencies per country

If your goal is "send people to the right country store", you don't want blocking at all. Use a suggestion banner, as described in redirect visitors by country without hurting SEO. The rest of this article is about real blocks and restrictions.

What EU law says about geo-blocking

If you sell to customers in the European Union, read Regulation (EU) 2018/302, the Geo-blocking Regulation, before you block anyone. It has applied since 3 December 2018 and it is aimed at exactly the code you're about to write.

The key provisions:

  • Article 3(1): a trader may not, "through the use of technological measures or otherwise, block or limit a customer's access" to its online interface for reasons related to the customer's nationality, place of residence or place of establishment. An "online interface" includes websites and apps (Article 2(16)).
  • Article 3(2): you may not redirect a customer to a different version of your site for those reasons "unless the customer has explicitly consented to such redirection". Even with consent, the original version must stay easily accessible.
  • Article 4: you may not apply different general conditions of access to goods or services, such as refusing to sell, for those reasons, in the situations the article lists. Article 4(2) confirms you can still have different offers, including net prices, for different territories, as long as you don't discriminate in access to them.
  • Recital 6 names "the IP address used when accessing an online interface" as one of the location criteria covered. IP-based blocking is squarely in scope.

There are important limits:

  • Legal requirements override it. Article 3(3) says the prohibitions don't apply where blocking, limiting access or redirecting is necessary to comply with a legal requirement in EU or national law. A product that is illegal to sell in one member state can be blocked there. You should be able to explain which rule requires it.
  • Some sectors are excluded. Article 1(3) excludes the activities listed in Article 2(2) of the Services Directive, which include audiovisual services (recital 8 names them), financial services, transport, healthcare and gambling.
  • Copyright is unaffected. Article 1(5) leaves copyright rules untouched, and Article 4(1)(b) leaves out electronically supplied services whose main feature is access to copyright-protected works, such as e-books, music and streaming. Territorial licensing has its own rules, covered in geo-restricting content for licensing regions.
  • It is about customers from EU member states. The Regulation doesn't stop you from blocking visitors located outside the EU. It also doesn't oblige you to deliver everywhere. Under Article 4(1)(a), you may ask a customer to collect goods, or arrange delivery themselves, in a member state you do deliver to.

In practice: if you're in the EU's single market and want to block another member state for commercial reasons, you probably can't. Blocking for a legal requirement, or blocking countries outside the EU, is a different matter.

Sanctions are the opposite problem

Sanctions and export-control rules can require you to keep people in certain jurisdictions out. Regulators expect companies to use the location data they already have. In its December 2020 settlement with BitGo, the US Treasury's Office of Foreign Assets Control (OFAC) pointed to IP address data the company had collected but didn't use to identify users in sanctioned jurisdictions. Remediation included IP address blocking.

IP blocking is a reasonable part of such a control, but not all of it. VPNs defeat it, so compliance programmes usually combine it with screening of names, addresses and payment details. That's a question for your compliance team.

The basic check

Every implementation follows the same five steps:

  1. Get the real client IP. Behind a proxy or CDN, that takes care. See finding the real client IP.
  2. Look up the country: GET https://ipapi.gr/api/lookup/{ip}?key=YOUR_API_KEY.
  3. Treat "No Data" as unknown. The country fields are always present, and a missing value is the string "No Data", never null.
  4. Decide what an unknown country means for this rule: allow or block.
  5. Cache the result per IP, so you don't repeat the lookup on every request.

The response contains more than you need for this:

json
{
  "ip": "203.0.113.7",
  "country": "Germany",
  "country_code": "DE",
  "city": "Berlin",
  "asn": "64500",
  "timezone": "Europe/Berlin"
}

That response is trimmed. The full field list is in the API reference. Use country_code for the decision and country for display.

Fail open or fail closed?

The lookup can fail: a timeout, a network error, a 429 when you've used your daily allowance, or "No Data" for the country. Decide per rule what happens then:

  • Fail open (allow) when the block is a business preference. A visitor shouldn't lose access because a lookup timed out.
  • Fail closed (block or ask) when the block is a legal requirement. Then "unknown" can't mean "allowed". Show a page that asks the visitor to confirm their country, or holds the action until another check passes.

All the examples below take a failClosed setting, so the choice is explicit.

Express (Node.js)

js
import express from 'express';

const BLOCKED = new Set(['XX', 'YY']);     // ISO 3166-1 alpha-2 codes for your rule
const FAIL_CLOSED = false;                  // true only if the block is legally required
const TTL_MS = 6 * 3600 * 1000;
const cache = new Map();                    // ip -> { code, at }

const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"

async function countryOf(ip) {
  const hit = cache.get(ip);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.code;
  try {
    const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
      { signal: AbortSignal.timeout(1500) });
    if (!res.ok) return null;               // 429, 5xx…: unknown, and don't cache it
    const code = known((await res.json()).country_code);
    cache.set(ip, { code, at: Date.now() });
    return code;
  } catch {
    return null;                            // timeout or network error: unknown
  }
}

const app = express();
app.set('trust proxy', 1);                  // match your proxy setup exactly

app.use(async (req, res, next) => {
  const code = await countryOf(req.ip.replace(/^::ffff:/, ''));
  const blocked = code ? BLOCKED.has(code) : FAIL_CLOSED;
  if (!blocked) return next();
  res.set('Cache-Control', 'no-store');
  res.status(451).render('unavailable-in-region', { country: code });
});

Two details matter here. Failed lookups aren't cached, so a temporary error doesn't stick to a visitor for hours. And Cache-Control: no-store stops a shared cache from serving the block page to visitors from other countries.

Python (Flask)

python
import os
import time

import requests
from flask import Flask, abort, request
from werkzeug.middleware.proxy_fix import ProxyFix

BLOCKED = {"XX", "YY"}
FAIL_CLOSED = False
TTL = 6 * 3600
_cache: dict[str, tuple[str | None, float]] = {}
session = requests.Session()

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)  # exactly the number of proxies you run


def known(v):
    return None if v in (None, "", "No Data") else v  # missing fields are "No Data"


def country_of(ip: str) -> str | None:
    hit = _cache.get(ip)
    if hit and time.time() - hit[1] < TTL:
        return hit[0]
    try:
        r = session.get(f"https://ipapi.gr/api/lookup/{ip}",
                        params={"key": os.environ["IPAPI_KEY"]}, timeout=1.5)
    except requests.RequestException:
        return None
    if r.status_code != 200:
        return None
    code = known(r.json().get("country_code"))
    _cache[ip] = (code, time.time())
    return code


@app.before_request
def geo_gate():
    code = country_of(request.remote_addr)
    blocked = code in BLOCKED if code else FAIL_CLOSED
    if blocked:
        abort(451)

Register an error handler for 451 that renders your explanation page.

PHP

php
<?php
const BLOCKED = ['XX', 'YY'];
const FAIL_CLOSED = false;

function known($v) {
    return ($v === null || $v === '' || $v === 'No Data') ? null : $v; // missing fields are "No Data"
}

function country_of(string $ip): ?string {
    $cacheKey = 'geo:' . $ip;
    if (function_exists('apcu_fetch')) {
        $hit = apcu_fetch($cacheKey, $ok);
        if ($ok) return $hit ?: null;
    }
    $ch = curl_init('https://ipapi.gr/api/lookup/' . rawurlencode($ip)
        . '?key=' . urlencode(getenv('IPAPI_KEY')));
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 2, CURLOPT_CONNECTTIMEOUT => 1]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($body === false || $status !== 200) return null;   // unknown, not cached

    $code = known(json_decode($body, true)['country_code'] ?? null);
    if (function_exists('apcu_store')) apcu_store($cacheKey, $code ?? '', 21600);
    return $code;
}

$code = country_of($_SERVER['REMOTE_ADDR']);
$blocked = $code !== null ? in_array($code, BLOCKED, true) : FAIL_CLOSED;
if ($blocked) {
    http_response_code(451);
    header('Cache-Control: no-store');
    require __DIR__ . '/unavailable-in-region.php';
    exit;
}

Go (net/http middleware)

go
package geo

import (
    "encoding/json"
    "net"
    "net/http"
    "net/url"
    "os"
    "sync"
    "time"
)

var (
    blocked    = map[string]bool{"XX": true, "YY": true}
    failClosed = false
    client     = &http.Client{Timeout: 1500 * time.Millisecond}
    mu         sync.Mutex
    cache      = map[string]entry{}
)

type entry struct {
    code string
    at   time.Time
}

func known(s string) string {
    if s == "No Data" { // missing fields are "No Data"
        return ""
    }
    return s
}

func countryOf(ip string) string {
    mu.Lock()
    if e, ok := cache[ip]; ok && time.Since(e.at) < 6*time.Hour {
        mu.Unlock()
        return e.code
    }
    mu.Unlock()

    resp, err := client.Get("https://ipapi.gr/api/lookup/" + url.PathEscape(ip) + "?key=" + url.QueryEscape(os.Getenv("IPAPI_KEY")))
    if err != nil {
        return ""
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return ""
    }
    var body struct {
        CountryCode string `json:"country_code"`
    }
    if json.NewDecoder(resp.Body).Decode(&body) != nil {
        return ""
    }
    code := known(body.CountryCode)
    mu.Lock()
    cache[ip] = entry{code, time.Now()}
    mu.Unlock()
    return code
}

// Gate wraps a handler. RemoteAddr must already be the client address (set it in your proxy layer).
func Gate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip, _, err := net.SplitHostPort(r.RemoteAddr)
        if err != nil {
            ip = r.RemoteAddr
        }
        code := countryOf(ip)
        isBlocked := failClosed
        if code != "" {
            isBlocked = blocked[code]
        }
        if isBlocked {
            w.Header().Set("Cache-Control", "no-store")
            http.Error(w, "This service is not available in your region.", http.StatusUnavailableForLegalReasons)
            return
        }
        next.ServeHTTP(w, r)
    })
}

The struct only decodes country_code. If you also decode latitude and longitude, remember they can be the string "No Data" instead of a number, so a plain float64 field will fail to decode. Use json.RawMessage or any for those.

The map grows without limit. In a long-running service, use a size-bounded cache or clear old entries on a timer.

At the edge (Cloudflare Workers and similar)

Blocking at the edge stops a request before it reaches your origin. Cloudflare Workers already expose the visitor's country as request.cf.country. That's enough for a plain country block. Calling the API from the worker is useful when you also want the ASN, organization or the bot flags on paid plans, or want the same country source as your backend:

js
// Cloudflare Worker. Store the key as a secret: wrangler secret put IPAPI_KEY
const BLOCKED = new Set(['XX', 'YY']);
const FAIL_CLOSED = false;
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"

async function countryOf(ip, env, ctx) {
  const cacheKey = new Request(`https://geo-cache.invalid/${encodeURIComponent(ip)}`);
  const cached = await caches.default.match(cacheKey);
  if (cached) return known(await cached.text());
  try {
    const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${env.IPAPI_KEY}`,
      { signal: AbortSignal.timeout(1500) });
    if (!res.ok) return null;
    const code = known((await res.json()).country_code);
    ctx.waitUntil(caches.default.put(cacheKey, new Response(code ?? 'No Data', {
      headers: { 'Cache-Control': 'max-age=21600' }
    })));
    return code;
  } catch {
    return null;
  }
}

export default {
  async fetch(request, env, ctx) {
    const ip = request.headers.get('CF-Connecting-IP');
    const code = ip ? await countryOf(ip, env, ctx) : null;
    const blocked = code ? BLOCKED.has(code) : FAIL_CLOSED;
    if (blocked) {
      return new Response('This service is not available in your region.', {
        status: 451, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }
      });
    }
    return fetch(request);
  }
};

The cache key uses a made-up hostname and never contains your API key. CF-Connecting-IP is set by Cloudflare and can be trusted inside a Worker. Don't trust it on an origin that is also reachable directly.

Use status 451, and explain

RFC 7725 defines 451 Unavailable For Legal Reasons for access denied because of a legal demand. Use it for legally required blocks. For a business decision, 403 is the more honest code. Either way:

  • Show a human page. Say that the service isn't available in the visitor's region, and give a contact address in case they think the location is wrong.
  • Don't redirect silently. A redirect to the home page or another country site hides what happened. In the EU it can also break Article 3(2) of the Geo-blocking Regulation.
  • Never use 301. Browsers and search engines cache permanent redirects.
  • Send Cache-Control: no-store on block responses, so a CDN doesn't show them to everyone.

Search engines and geo-blocking

Search crawlers come from a few places. Google says that "the default IP addresses of the Googlebot crawler appear to be based in the USA", and that it also crawls from addresses outside the US. Its guidance on locale-adaptive pages is explicit about geo-restricted content: treat Googlebot "like you would treat any other user from that country". If you block US visitors, "your server should block Googlebot if it appears to be coming from the USA".

So:

  • If you block a country where crawlers don't usually come from, search is mostly unaffected.
  • If you block the US, expect those pages to drop out of Google. Making an exception for Googlebot would show it content that your users in that country can't see. That's the opposite of Google's guidance.
  • For partial restrictions (one product unavailable in a few countries), keep the page live and replace the buy button with a notice. The page stays indexable and the restriction still applies.

What you should do is make sure fake crawlers don't get special treatment. A user agent that claims to be Googlebot proves nothing. On any paid plan, risk.is_crawler tells you whether an address belongs to a known search or other crawler, and a user agent alone never sets it. To check crawlers yourself, see verify Googlebot and Bingbot.

VPNs, travellers and other limits

IP geolocation tells you where a connection appears to come from, not where the person is or which country they belong to:

  • VPNs and proxies put the visitor wherever the exit server is. IPAPI.GR doesn't detect VPNs, proxies or Tor. If your rule must hold up against determined users, IP blocking alone isn't enough.
  • Travellers appear in the country they're visiting. A customer from an allowed country on holiday in a blocked one will be blocked, and the reverse.
  • Corporate networks, satellite and some mobile carriers can route traffic through another country.
  • Country results are usually reliable, but not always. Read how accurate IP geolocation is before you rely on it.

For high-stakes rules, combine the IP check with something the person gives you, such as a billing address, a payment card's country or an ID check, and log which signal decided.

Testing your block

Before you ship:

  1. Unit-test the decision function with a known code, an allowed code, null (failed lookup) and "No Data".
  2. Check the real client IP. Log req.ip (or its equivalent) next to the X-Forwarded-For header on staging. If it's your load balancer's address, every visitor gets the same country.
  3. Check from outside. Request the site through a VPN exit or a server in a blocked and an allowed country and confirm the status codes:
bash
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/
  1. Check that a CDN doesn't cache the block page. Request the page from an allowed country right after a blocked one.

Privacy

An IP address can be personal data in the EU, and so can the location derived from it. For a country check you rarely need to store either. Keep the decision (allowed / blocked and the country code) if you need an audit trail, and drop the raw IP when you no longer need it. Mention the check in your privacy notice.

FAQ

It depends on why and against whom. Blocking customers from other member states for commercial reasons is generally prohibited by Regulation (EU) 2018/302. Blocking to meet a legal requirement, blocking in excluded sectors such as audiovisual services, and blocking countries outside the EU aren't covered by that prohibition. This isn't legal advice. Check your specific case.

Should I block at the CDN, the web server or in the app?

Block as early as you can for whole-site rules (CDN or edge). Use app-level checks for partial restrictions, such as a product, a feature or a checkout step, because the app knows what's being requested.

What happens when my daily lookup limit runs out?

The API returns 429 with a resetTime, and limits reset at midnight UTC. The code above treats that as an unknown country and applies your fail-open or fail-closed setting. Caching per IP keeps you far from the limit for normal traffic.

Can I block by continent?

There's no continent field. Build a set of country codes for the region you need, which also makes the rule explicit and reviewable.

Related: geo-restricting content for licensing regions · show the cookie banner to EU visitors only.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.