Show the cookie banner only to EU visitors? GDPR geo-targeting by IP, done carefully

Detecting EU visitors by IP to show a cookie banner only where it's needed is common. Whether it's safe depends on where your business is established, and the code has to fail the right way. What the law says, and how to build it.

On this page The first question: where is your business?

Plenty of sites show a consent banner only to visitors in Europe and a lighter experience everywhere else. Consent management platforms sell it as a feature called "geo-targeting". It works by looking up the visitor's IP address and checking whether the country is in a list.

The code is easy. The hard part is knowing when the approach is legally sound, which countries belong on the list, and what to do when the lookup can't tell. This guide covers all three, with code you can adapt.

Important

This is an engineering guide, not legal advice. Privacy law depends on your establishment, your audience and what your tags do. Have your setup reviewed by someone qualified for your jurisdictions.

The first question: where is your business?

The GDPR's territorial scope is in Article 3 of Regulation (EU) 2016/679. It has two main triggers, and they lead to very different answers.

Article 3(1): you are established in the EU. The GDPR applies to processing "in the context of the activities of an establishment of a controller or a processor in the Union, regardless of whether the processing takes place in the Union or not." If your company has an establishment in the EU, the GDPR covers your processing whatever country your visitor is in. Geo-targeting your GDPR obligations makes little sense in that case. You might still vary the banner design per country, but you can't use the visitor's location to switch the GDPR off.

Article 3(2): you are not established in the EU. The GDPR then applies to processing of personal data "of data subjects who are in the Union" when the processing relates to offering them goods or services, or to monitoring their behaviour in the Union. Two things follow:

  • What matters is that the person is in the Union, not their nationality. A US citizen browsing from Paris is in scope. A French citizen browsing from New York isn't covered by Article 3(2). The EDPB's Guidelines 3/2018 on territorial scope explain how to assess this. The location of the person in the Union is judged at the moment the relevant activity takes place.
  • Being reachable from Europe isn't enough by itself to count as "offering" goods or services there. Recital 23 says "the mere accessibility" of a website in the Union is insufficient. Using a language or currency of a member state, with the possibility of ordering in it, can show that intention.

Monitoring behaviour, through tracking and profiling for advertising, is the trigger most sites with analytics and ad tags need to think about. That's what most geo-targeted banners are about.

So IP geolocation fits best in the second case: a business outside the EU that wants to apply EU rules to people in the EU. Here, "is this visitor in the EU right now?" is a meaningful question. IP geolocation is a reasonable way to estimate the answer.

Cookies are a separate rule

Consent banners usually exist because of the ePrivacy rules, not only the GDPR. Article 5(3) of Directive 2002/58/EC requires consent before storing or accessing information on a user's device, unless it's strictly necessary for the service the user asked for. Each member state implements it in national law, and enforcement differs.

The UK has its own versions after Brexit: the UK GDPR and the Privacy and Electronic Communications Regulations (PECR). Switzerland has its revised Federal Act on Data Protection. Other places, including several US states, Brazil and Canada, have their own privacy laws with different consent models.

The practical consequence: "EU" is rarely the right list. Most teams want a "strict consent" region that includes at least the EU, the EEA and the UK, and often Switzerland.

Build the country list deliberately

Here's a starting point, with the reasoning in comments so a reviewer can follow it:

js
// EU member states (27). Note: the ISO code for Greece is GR (the EU itself uses "EL").
const EU = ['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT',
            'LU','MT','NL','PL','PT','RO','SK','SI','ES','SE'];
// EU outermost regions that have their own ISO 3166-1 codes but are part of the EU.
const EU_OUTERMOST = ['GF','GP','MQ','RE','YT','MF'];
// Åland is part of Finland and the EU.
const EU_OTHER = ['AX'];
// EEA members outside the EU: the GDPR applies through the EEA Agreement.
const EEA = ['IS','LI','NO'];
// Separate regimes with similar consent rules: UK GDPR + PECR, Swiss FADP.
const SIMILAR = ['GB','CH'];

export const STRICT_CONSENT = new Set([...EU, ...EU_OUTERMOST, ...EU_OTHER, ...EEA, ...SIMILAR]);

Check the list against your own legal advice. Some territories are edge cases: the Canary Islands share Spain's ES code, while the Faroe Islands (FO) and Greenland (GL) aren't part of the EU. Review the list at least once a year.

The most important design decision: when you don't know where the visitor is, behave as if they're in the strict region. The lookup can fail in several ways:

  • country_code is "No Data". The field is always present, and a missing value is the string "No Data", never null.
  • The request times out, or the network fails.
  • You've used your daily allowance and get a 429.
  • The visitor uses a VPN, so their apparent country isn't their real location. IPAPI.GR doesn't detect VPNs or proxies, so this case can't be detected, only tolerated.

Under-protecting a visitor who should have had a banner is the costly mistake. Showing an unnecessary banner is a minor annoyance. So unknown maps to strict.

The banner itself also has to work: no non-essential cookies or tags before consent, a reject option as easy as accept, and a way to change the choice later. Geo-targeting doesn't change any of that for the visitors who see the banner.

Node.js (Express)

Decide on the server and send the result to the page, so the API key never reaches the browser:

js
import express from 'express';
import { STRICT_CONSENT } from './consent-regions.js';

const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
const cache = new Map();   // ip -> { code, at }

async function countryOf(ip) {
  const hit = cache.get(ip);
  if (hit && Date.now() - hit.at < 3600 * 1000) return hit.code;
  try {
    const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
      { signal: AbortSignal.timeout(1200) });
    if (!res.ok) return null;
    const code = known((await res.json()).country_code);
    cache.set(ip, { code, at: Date.now() });
    return code;
  } catch {
    return null;
  }
}

const app = express();
app.set('trust proxy', 1);

app.use(async (req, res, next) => {
  const code = await countryOf(req.ip.replace(/^::ffff:/, ''));
  // Unknown country => strict mode. Never the other way round.
  res.locals.consentMode = !code || STRICT_CONSENT.has(code) ? 'strict' : 'notice';
  res.set('Vary', 'Cookie');          // and never cache this HTML in a shared cache keyed only on the URL
  next();
});

In the template, strict renders the full banner with tags blocked until consent. notice renders whatever your non-EU policy requires, which isn't necessarily nothing.

Be careful with full-page caching. If a CDN caches the HTML for the first visitor, everybody gets that visitor's banner mode. Either don't cache these pages in a shared cache, or make the banner decision in a separate uncached request, like the edge example below.

Python (Django middleware)

python
import requests
from django.conf import settings
from django.core.cache import cache

from .consent_regions import STRICT_CONSENT

session = requests.Session()


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


def country_of(ip):
    key = f"geo:{ip}"
    code = cache.get(key)
    if code is not None:
        return code or None          # "" means a cached "unknown"
    try:
        r = session.get(f"https://ipapi.gr/api/lookup/{ip}",
                        params={"key": settings.IPAPI_KEY}, timeout=1.2)
    except requests.RequestException:
        return None
    if r.status_code != 200:
        return None
    code = known(r.json().get("country_code"))
    cache.set(key, code or "", 3600)
    return code


class ConsentModeMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        code = country_of(request.META["REMOTE_ADDR"])
        request.consent_mode = "strict" if (code is None or code in STRICT_CONSENT) else "notice"
        return self.get_response(request)

Behind a reverse proxy, make sure REMOTE_ADDR holds the client address before this middleware runs.

PHP

php
<?php
require __DIR__ . '/consent_regions.php';   // defines STRICT_CONSENT as an array of codes

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

function consent_mode(string $ip): string {
    if (isset($_SESSION['consent_mode'])) return $_SESSION['consent_mode'];

    $ctx = stream_context_create(['http' => ['timeout' => 1.2, 'ignore_errors' => true]]);
    $url = 'https://ipapi.gr/api/lookup/' . rawurlencode($ip) . '?key=' . urlencode(getenv('IPAPI_KEY'));
    $body = @file_get_contents($url, false, $ctx);
    $status = isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m) ? (int) $m[1] : 0;

    $code = ($body !== false && $status === 200) ? known(json_decode($body, true)['country_code'] ?? null) : null;
    $mode = ($code === null || in_array($code, STRICT_CONSENT, true)) ? 'strict' : 'notice';
    if ($code !== null) $_SESSION['consent_mode'] = $mode;   // don't pin a failed lookup to the session
    return $mode;
}

session_start();
$consentMode = consent_mode($_SERVER['REMOTE_ADDR']);

Go

go
package consent

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

var client = &http.Client{Timeout: 1200 * time.Millisecond}

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

// Mode returns "strict" unless the visitor is positively located outside the strict region.
func Mode(ip string, strict map[string]bool) string {
    resp, err := client.Get("https://ipapi.gr/api/lookup/" + url.PathEscape(ip) + "?key=" + url.QueryEscape(os.Getenv("IPAPI_KEY")))
    if err != nil {
        return "strict"
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return "strict"
    }
    var g struct {
        CountryCode string `json:"country_code"`
    }
    if json.NewDecoder(resp.Body).Decode(&g) != nil {
        return "strict"
    }
    code := known(g.CountryCode)
    if code == "" || strict[code] {
        return "strict"
    }
    return "notice"
}

Wrap Mode in a per-IP cache before using it on every request, as in the other examples.

When your HTML is fully cached at a CDN, decide the mode in a small uncached endpoint and let the banner script ask for it. With a Cloudflare Worker:

js
// GET /consent-mode -> {"mode":"strict"} or {"mode":"notice"}
import { STRICT_CONSENT } from './consent-regions.js';
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"

export default {
  async fetch(request, env) {
    let code = null;
    try {
      const ip = request.headers.get('CF-Connecting-IP');
      const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${env.IPAPI_KEY}`,
        { signal: AbortSignal.timeout(1000) });
      if (res.ok) code = known((await res.json()).country_code);
    } catch { /* unknown */ }
    const mode = !code || STRICT_CONSENT.has(code) ? 'strict' : 'notice';
    return Response.json({ mode }, { headers: { 'Cache-Control': 'private, no-store' } });
  }
};

The banner script must default to strict until the answer arrives, and stay strict if the request fails. Don't load any non-essential tags while you wait.

If you only need the country, Workers also expose request.cf.country without an external call. Calling the API is worth it when you want one country source for your edge and backend, or other fields such as timezone.

Things geo-targeting doesn't fix

  • Your own EU establishment. See Article 3(1) above. The visitor's location doesn't take you out of scope.
  • Server-side data. Logs, analytics events and sign-up records are still personal data. A banner only governs what the browser stores and loads.
  • Data subject rights. People can still ask you for access or deletion, wherever you think they were when they visited.
  • Travellers and VPNs. You'll show a banner to some people who don't need it, and miss some who do. Failing to strict reduces the second group, but can't remove it.

Don't make the check itself a privacy problem

The geolocation lookup processes an IP address too, and the EU Court of Justice held in Breyer (C-582/14) that a dynamic IP address can be personal data. Keep the footprint small:

  • Use the result to choose a banner mode, then discard it. Don't store the IP with the result.
  • If you cache per IP, keep the cache in memory with a short lifetime.
  • Mention in your privacy notice that you use the visitor's approximate location to decide which notices apply.

FAQ

For a business with no EU establishment that applies EU rules to people in the EU, it's a common and defensible approach, as long as unknown locations get the strict treatment. For a business established in the EU, it doesn't remove GDPR obligations for visitors elsewhere. This isn't legal advice.

At minimum, consider the EU member states, the EEA countries (Iceland, Liechtenstein, Norway) and the UK. Many sites add Switzerland. Rules elsewhere change often, so treat any list as something to review regularly.

Can I use the browser's language or time zone instead?

They're hints, not locations. A browser set to German may be in Chicago. A time zone like Europe/Berlin is a better location hint than language, but it can be changed in settings. IP geolocation is usually the more reliable default, and none of these signals should be the only safeguard for something that matters legally.

How fast does the lookup need to be?

Fast enough not to delay your page. Set a short timeout, around a second, cache per visitor, and treat a timeout as unknown, which means strict.

Related: geo-blocking by country and the EU Geo-blocking Regulation · how accurate is IP geolocation?

Look up an IP address now

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