Klaro and CookieConsent have no geolocation. Both leave “who sees the banner” to you, and the Consent Mode default has to agree with that decision. The rule that keeps the two from drifting apart: resolve the visitor’s country once, at your edge, and feed that single value to both the banner and the gtag default. Google’s own region parameter resolves the country on Google’s side, by a method the documentation does not describe, so if it decides the default while your CMP decides the banner, the two can disagree and nothing on the page will tell you. Treat an unknown country as in scope.
Sites that serve visitors on both sides of the Atlantic usually want two behaviours: an opt-in banner with everything denied by default where that is required, and no banner with tags running where it is not. Hosted CMPs sell this as “geo-targeting”. A self-hosted library does not have it, and the two most-used ones say so plainly: Klaro’s annotated config has no location option at all, and the CookieConsent docs show a per-country mode as a “concept” with the country hard-coded to "IT".
Which visitors legally need the opt-in flow is a question about where you are established and whom you target, not only about where an IP resolves, and this post does not answer it. What it covers is the plumbing once that decision has been made, and the one way that plumbing quietly goes wrong.
Where the country comes from
Do not call a third-party geolocation API from the browser. It is a request to another party before consent, it is slow, and it makes the banner decision asynchronous. Every major edge already attaches the country to the request before it reaches your code, as of the documentation read on 3 September 2026:
- Cloudflare:
CF-IPCountry, a two-character country code, added by the “Add visitor location headers” Managed Transform. Two special values:XXfor clients without country data andT1for Tor. - Vercel:
x-vercel-ip-country(ISO 3166-1 alpha-2) andx-vercel-ip-country-region, the region portion of the ISO 3166-2 code, up to three characters. - CloudFront:
CloudFront-Viewer-CountryandCloudFront-Viewer-Country-Region, added via an origin request policy, or via a cache policy if you need the response cached per country.
That last clause is the trap with all three. If your HTML is cached at the edge, whatever country you baked into it is cached too, and a visitor in Frankfurt can receive the page rendered for the previous visitor in Denver. Either add the country to the cache key, or keep the country out of the HTML entirely and serve it from a tiny uncached endpoint. The second is simpler and is what the code below does.
One lookup, two consumers
The design rule is that the banner and the Consent Mode default must be decided by the same value. Serve a small blocking script from the edge with Cache-Control: no-store, placed in <head> before gtag or the CMP, that does both jobs:
// /consent-boot.js, generated at the edge per request. Framework-neutral.
// EU-27 + Iceland, Liechtenstein, Norway (EEA), plus the UK and Switzerland,
// which Google's EU user consent policy also covers. Note ISO uses GR, not EL.
const IN_SCOPE = new Set([
'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',
'IS','LI','NO', 'GB','CH',
// EU outermost regions carry their own ISO codes: add or omit deliberately.
'GF','GP','MQ','RE','YT'
]);
export default function handle(request) {
const country = request.headers.get('cf-ipcountry') // Cloudflare
|| request.headers.get('x-vercel-ip-country') // Vercel
|| request.headers.get('cloudfront-viewer-country') // CloudFront
|| 'XX';
// Unknown, Tor, or anything not a plain two-letter code: treat as in scope.
const optIn = !/^[A-Z]{2}$/.test(country) || IN_SCOPE.has(country);
const js = `
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
window.__consentScope = ${JSON.stringify(optIn ? 'opt-in' : 'opt-out')};
gtag('consent', 'default', {
ad_storage: ${optIn ? "'denied'" : "'granted'"},
ad_user_data: ${optIn ? "'denied'" : "'granted'"},
ad_personalization: ${optIn ? "'denied'" : "'granted'"},
analytics_storage: ${optIn ? "'denied'" : "'granted'"},
wait_for_update: 500
});`;
return new Response(js, {
headers: { 'content-type': 'application/javascript',
'cache-control': 'no-store' }
});
}
Everything downstream reads window.__consentScope. The default consent command carries no region key, and that is deliberate. It is explained below.
Klaro
Set noAutoLoad: true and Klaro will not render anything on its own. For in-scope visitors, call klaro.show(). For everyone else, do not render, and mark the services optOut. The distinction matters, because of how applyConsents() in the consent manager decides whether a service runs:
// src/consent-manager.js, applyConsents(), master as of 2026-09-03
const optOut = (service.optOut !== undefined ? service.optOut : (this.config.optOut || false))
const confirmed = this.confirmed || optOut || dryRun || interactive
const consent = (this.getConsent(service.name) && confirmed) || required
A service with default: true but no optOut still does not load until the visitor has confirmed, because confirmed is false on a first visit. optOut: true is what makes it load immediately, which is exactly the out-of-scope behaviour. The annotated config recommends leaving it false, and for an in-scope visitor that is right. So the config is built per visitor:
const optIn = window.__consentScope === 'opt-in';
window.klaroConfig = {
noAutoLoad: true,
default: !optIn,
services: [
{ name: 'ga4', purposes: ['analytics'], optOut: !optIn,
callback: (consent) => gtag('consent', 'update', {
analytics_storage: consent ? 'granted' : 'denied' }) },
{ name: 'ads', purposes: ['marketing'], optOut: !optIn,
callback: (consent) => gtag('consent', 'update', {
ad_storage: consent ? 'granted' : 'denied',
ad_user_data: consent ? 'granted' : 'denied',
ad_personalization: consent ? 'granted' : 'denied' }) }
]
};
// Constructing the manager runs loadConsents() and applyConsents().
const manager = klaro.getManager(window.klaroConfig);
if (optIn && !manager.confirmed) klaro.show(window.klaroConfig);
For the out-of-scope visitor, services load on construction and the callbacks fire with consent === true, which sends an update that matches the granted default already set. For the in-scope visitor, the callbacks fire with false until the visitor decides, and the ordering rules for Klaro and Consent Mode apply unchanged.
CookieConsent v3
CookieConsent has the two switches built in. autoShow decides whether the modal appears, and mode decides whether category scripts run before a choice: in 'opt-out' mode, scripts in categories with enabled: true “will run automatically”, which the docs flag as generally not GDPR compliant, and which is the point for a visitor the flow does not apply to.
const optIn = window.__consentScope === 'opt-in';
CookieConsent.run({
mode: optIn ? 'opt-in' : 'opt-out',
autoShow: optIn,
categories: {
necessary: { enabled: true, readOnly: true },
analytics: { enabled: !optIn },
ads: { enabled: !optIn }
},
onConsent: updateGtagConsent,
onChange: updateGtagConsent
});
The updateGtagConsent function is the one from the CookieConsent and Consent Mode v2 wiring. Note that onConsent fires on every page load once a cookie exists, which is the behaviour you want: a visitor who first saw the site from Lisbon and later opens it from a hotel in Chicago keeps the choice they made, because the stored cookie wins over the scope for that session.
Why the default carries no region parameter
gtag supports this form, and it looks like it does the same job:
gtag('consent', 'default', {
'analytics_storage': 'denied',
'region': ['ES', 'US-AK']
});
Google’s documentation says that a command without a region applies to everyone not covered by one, and that where a region and a subregion both match, the more specific one takes effect. What it does not say is how Google works out which region the visitor is in. The troubleshooting page’s first step for regional problems is to override your location in Chrome DevTools, and the Tag Assistant consent tab shows “On-page Default” and “On-page Update” but has no column for which region matched.
That leaves you with two independent resolvers of the same question, and two ways for them to disagree:
- Your edge says out of scope, Google says Spain. No banner is shown, the region-scoped denied default applies, and no
updateever follows. Every Google tag on the site runs denied for that visitor, permanently, with the interface looking correct. - Your edge says Spain, Google says out of scope. The banner is shown, but the unscoped granted default is already in force before the click. Cookies are set on the first hit, which is the pre-consent failure the banner exists to prevent.
Neither case is visible from the page, and neither shows up in Tag Assistant as an error. Removing the region key removes the second resolver. If you keep it as a belt-and-braces fallback, be explicit that you are accepting the first failure mode, silent loss of measurement, whenever the two lookups differ.
Testing it
- The edge headers cannot be spoofed from the browser on any of the three platforms, so give the boot endpoint a non-production override, for example
?cmhq_country=DE, and refuse it in production. - Load the page fresh for an in-scope country with Tag Assistant recording. Before touching the banner, every signal except
security_storagemust read denied, and there must be noupdateyet. - Repeat for an out-of-scope country. No banner, default granted, and the CMP’s callbacks send a matching
update. - Repeat with the country forced to
XX. It must behave exactly like step 2. - Check the raw response of the boot script twice from different networks and confirm the country changes. If it does not, the CDN is caching it.
Out of scope is not “no rules”
The opt-out branch above grants everything, which is only the right shape for jurisdictions where opt-out is the model. Several US states require honouring an opt-out preference signal, and California’s rules are their own thing; the state-by-state cookie rules post covers which ones. The scope check is a two-way switch here for clarity. In practice, the out-of-scope branch usually grows a Global Privacy Control check and a state list of its own, and the same design rule applies: read the signal once, at the edge or on first paint, and feed both the CMP and gtag from it.
Whichever branch a visitor lands in, the first hit is what matters. Check what your Consent Mode default actually reads on a first visit with CookieInspector, before anyone has clicked anything.