๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Use Cases

Test Your GDPR Cookie Banner From the EU

Test cookie consent banner EU behaviour from outside Europe: load your site via EU exits with Playwright and catch cookies and pixels that fire before consent.

S SparkProxy 2 26 min read
Share

If you build or market a site from outside Europe, you can't test cookie consent banner EU behaviour by opening your own homepage: your browser gets the variant for your country, so the banner your German visitors see, and the pixels that fire before they click anything, are invisible to you. This guide builds a Playwright harness that loads your site through exits in each EU country, records every cookie, every localStorage key and every network request in three states (before a choice, after Reject, after Accept), and checks that US visitors still get the variant you intended. You'll end with a scheduled job that fails loudly when a tag manager change starts firing a pixel in France before consent.

What a passing result looks like per country

Write the expected outcome down before you write any code. A test with no stated expectation just produces screenshots, and screenshots don't fail builds.

The legal baseline comes from Article 5(3) of the ePrivacy Directive. The EDPB summarises it as allowing "the storing of information, or the gaining of access to information already stored, in the terminal equipment of a subscriber or user" only on the basis of consent or strict necessity, and notes that it "does not exclusively apply to cookies, but also to 'similar technologies'" (EDPB Guidelines 2/2023, v2.0, adopted 7 October 2024). That last clause is why this harness watches network requests and localStorage, not only cookies.

The GDPR side also reaches you if you're outside Europe. The European Commission says the regulation covers a company established outside the EU that is "offering goods/services (paid or for free)" or "is monitoring the behaviour of individuals in the EU" (European Commission). This isn't legal advice; your counsel decides the policy. The harness checks whether the site does what the policy says.

MarketExpected on first loadAfter RejectAfter Accept
EU member states (DE, FR, IE, NL, IT, PL...)Banner visible, Reject on the first layer, no non-essential cookies, storage or beaconsStill none of themYour tags fire
EEA outside the EU (NO, IS, LI)Same as the EU rowSameSame
United KingdomWhatever your UK policy says (UK GDPR and PECR, its own regime)Per policyPer policy
United StatesThe variant you chose: no banner, a notice, or an opt-out linkNot applicableNot applicable

The Reject requirement isn't a style preference. The EDPB's Cookie Banner Taskforce report (18 January 2023) lists "no reject button on the first layer" as its first practice, and "a vast majority of authorities considered that the absence of refuse/reject/not consent options on any layer with a consent button" constitutes an infringement, and the members confirmed that "pre-ticked boxes to opt-in do not lead to valid consent" (EDPB taskforce report).

The Accept column matters too. A banner that blocks everything forever passes a naive "no trackers" test while quietly breaking your analytics for every European visitor who consents. Checking that tags do fire after Accept catches over-blocking, which is the regression marketing teams notice last.

How your stack decides who gets the banner

Something in your stack maps the visitor's IP to a country and picks a variant. It's usually one of three places, and knowing which one tells you what your test exit has to fool.

  • The consent platform's own geolocation. Most commercial consent platforms look up the visitor's location from their own servers and choose a banner template per region. You configure the regions in their dashboard.
  • Your CDN. On Cloudflare, "the CF-IPCountry header contains a two-character country code of the originating visitor's country", with XX for clients without country data and T1 for Tor (Cloudflare HTTP headers). Rules can also match the boolean field ip.src.is_in_european_union, documented as "Whether the request originates from a country in the European Union (EU)" (Cloudflare rules reference).
  • Your tag configuration. Google's consent mode lets you "set default consent states that apply to visitors from particular areas" with a region array of ISO 3166-2 codes, and "a gtag consent default command without a region parameter sets the default for all visitors not covered by another region-specific command" (Google consent mode setup).

Here's a bug the second and third options make easy. That Cloudflare field is about the EU, and Norway, Iceland and Liechtenstein aren't in the EU. They are in the EEA, and the same EDPB guidelines state that references to Member States "should be understood as references to 'EEA Member States'". A worker or rule that gates the banner on ip.src.is_in_european_union sends Norwegian visitors the US variant. The same goes for a hand-typed region list that someone built from a list of EU members:

// Defaults that look complete and aren't: no NO, IS, LI, and no GB.
gtag('consent', 'default', {
  ad_storage: 'denied',
  analytics_storage: 'denied',
  ad_user_data: 'denied',
  ad_personalization: 'denied',
  region: ['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'],
  wait_for_update: 500
});
gtag('consent', 'default', { ad_storage: 'granted', analytics_storage: 'granted' });

The second command grants everything for every region the first one didn't name. A Norway exit in your test matrix is the cheapest way to catch it, which is why the harness below includes one. Whatever the source, all three read the IP your request arrives from, so the test request has to originate in the country you're checking. Our post on what geo-targeting means in proxies covers how a country-targeted exit gets chosen.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Choosing exits, rotation and concurrency

The harness uses the SparkProxy gateway at gateway.sparkproxy.io. Port 11000 gives Random Rotation, a fresh exit per request. Port 11002 gives Sticky Sessions, which hold one exit for 5 minutes. The country goes in the username as a suffix, in the form the homepage example shows (user-country-us); copy the exact string from your dashboard.

DecisionChoiceWhy
RotationSticky Sessions, port 11002The exit check, the page load, the consent platform's geo lookup and every beacon must come from one IP. With Random Rotation, a page's subresources can leave from different exits, and a consent platform that resolves the region in a separate call may see a different address from the one your CDN saw
CountriesYour top EU markets, plus NO, GB and USNO catches EU-only rules, GB checks your UK policy, US is the control
Browser localeFixed at `en-US` for every countryKeeps the IP as the only variable; run a second pass with local languages later if your banner text is translated
Concurrency3 countries at a timeEach browser opens many tunnels at once; see the cost section
ContextsA fresh browser context per stateNo consent cookie survives from one state into the next

One caveat on the pool. SparkProxy's worldwide pool is datacenter IPs. That's fine for country-level banner decisions, which is what consent platforms and CDN rules use, but a bot-management rule may treat hosting ranges differently from home broadband. If a page behaves oddly only on your test exits, read our comparison of residential vs datacenter proxies before you conclude the banner is broken.

The Playwright harness: configuration and tracker rules

We compiled every Python block below and ran the verdict logic against synthetic captures, but we had no gateway credentials on the test machine, so no proxied run is reported here. The current Playwright Python release on PyPI on 30 October 2026 was 1.64.1, uploaded 22 October 2026, and it requires Python 3.10 or newer. Playwright documents that a proxy "can be either set globally for the entire browser, or for each browser context individually", and the harness uses the per-context form so each state gets a clean slate. If you haven't driven Playwright through a proxy before, our guide to web scraping with Playwright and proxies covers installation and the basics.

The first file, consent_config.py, holds everything site-specific. Replace the selectors with your banner's own, copied from DevTools:

import os

GATEWAY = "gateway.sparkproxy.io"
STICKY_PORT = 11002            # Sticky Sessions: one exit held for 5 minutes
USER = os.environ.get("SPARKPROXY_USER", "")
PASSWORD = os.environ.get("SPARKPROXY_PASS", "")

TARGET = "https://shop.sparkproxy.io/"
TRACE_URL = "https://www.sparkproxy.io/cdn-cgi/trace"

BANNER = "#consent-banner"
REJECT = "#consent-banner button[data-action='reject']"
ACCEPT = "#consent-banner button[data-action='accept']"

# What each market should get. NO is EEA but not EU; GB follows your UK policy.
EXPECT = {
    "DE": "opt-in", "FR": "opt-in", "IE": "opt-in", "NL": "opt-in",
    "IT": "opt-in", "PL": "opt-in", "NO": "opt-in", "GB": "opt-in",
    "US": "no-banner",
}


def proxy_for(cc):
    return {
        "server": f"http://{GATEWAY}:{STICKY_PORT}",
        "username": f"{USER}-country-{cc.lower()}",
        "password": PASSWORD,
    }

The second file, consent_rules.py, decides what counts as tracking. It separates beacons (requests that carry measurement data out) from loaders (the script files that will later send beacons). The EDPB guidelines treat tracking pixels as a "gaining of access" under Article 5(3), and add that "in the case of dynamically constructed tracking pixels, it is the distribution of the applicative logic (usually a JavaScript code) that constitutes the instruction". Whether loading a tag manager script before consent is acceptable on your site is a call for your counsel, so the harness reports loaders separately instead of deciding for you:

import re
from urllib.parse import urlparse, parse_qs

# A starter list. Extend it from your own tag inventory.
BEACONS = [
    re.compile(r"google-analytics\.com/(g/)?collect"),
    re.compile(r"\.doubleclick\.net/"),
    re.compile(r"googleadservices\.com/pagead/"),
    re.compile(r"facebook\.com/tr"),
    re.compile(r"bat\.bing\.com/action"),
    re.compile(r"px\.ads\.linkedin\.com/"),
    re.compile(r"analytics\.tiktok\.com/api/"),
]
LOADERS = [
    re.compile(r"googletagmanager\.com/(gtm\.js|gtag/js)"),
    re.compile(r"connect\.facebook\.net/.+/fbevents\.js"),
]

# Names your counsel has signed off as strictly necessary.
ALLOWED_COOKIES = re.compile(r"^(session_id|csrftoken|consent_state)$")
ALLOWED_STORAGE = re.compile(r"^(consent_state)$")


def classify(url):
    if any(p.search(url) for p in BEACONS):
        return "beacon"
    if any(p.search(url) for p in LOADERS):
        return "loader"
    return None


def consent_params(url):
    """Google's consent-mode fields from a beacon URL, kept raw."""
    q = parse_qs(urlparse(url).query)
    return {k: q[k][0] for k in ("gcs", "gcd") if k in q}

The allowlists are the part people get wrong. The taskforce report found controllers labelling cookies "essential" or "strictly necessary" when they weren't, and said the assessment is hard to standardise. Keep the allowlist short, put it under code review, and make every addition cite a reason.

Turning captures into verdicts

Raw captures are noisy. The verdict function in consent_verdict.py turns each one into a short list of problems that a person can act on, and an empty list means pass:

from consent_rules import classify


def verdict(expect, s):
    if s["exit"] != s["cc"]:
        return [f"EXIT MISMATCH: asked {s['cc']}, got {s['exit']}"]
    pre_beacons = [u for u in s["pre"] if classify(u) == "beacon"]
    post_beacons = [u for u in s["post"] if classify(u) == "beacon"]
    problems = []
    if expect == "opt-in":
        if not s["banner"]:
            problems.append("banner not shown")
        if pre_beacons:
            problems.append(f"{len(pre_beacons)} beacon(s) before any choice")
        if s["state"] in ("before", "reject"):
            if s["cookies"]:
                problems.append("cookies: " + ", ".join(sorted(set(s["cookies"]))))
            if s["storage"]:
                problems.append("localStorage: " + ", ".join(sorted(set(s["storage"]))))
        if s["state"] == "reject" and post_beacons:
            problems.append(f"{len(post_beacons)} beacon(s) after Reject")
        if s["state"] == "accept" and not post_beacons:
            problems.append("no tags fired after Accept")
    elif s["banner"]:
        problems.append("banner shown where none was intended")
    return problems

The driver, run_consent.py, runs each country in its own process, because Playwright's sync API shouldn't be shared across threads. US runs only the before state, since there's nothing to click:

import json
import sys
from concurrent.futures import ProcessPoolExecutor

from playwright.sync_api import Error as PWError
from playwright.sync_api import sync_playwright

from consent_config import EXPECT
from consent_capture import run_state
from consent_verdict import verdict

PROXY_ERRORS = ("ERR_PROXY_CONNECTION_FAILED", "ERR_TUNNEL_CONNECTION_FAILED")


def run_country(cc):
    states = ("before",) if EXPECT[cc] == "no-banner" else ("before", "reject", "accept")
    rows = []
    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        for state in states:
            for attempt in (1, 2):
                try:
                    s = run_state(browser, cc, state)
                    s["problems"] = verdict(EXPECT[cc], s)
                    break
                except PWError as e:
                    gap = any(code in str(e) for code in PROXY_ERRORS)
                    s = {"cc": cc, "state": state,
                         "problems": [("GAP " if gap else "ERROR ") + str(e).splitlines()[0]]}
                    if not gap:
                        break
            rows.append(s)
        browser.close()
    return rows


if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=3) as pool:
        results = [row for rows in pool.map(run_country, EXPECT) for row in rows]
    for r in results:
        print(f"{r['cc']:3} {r['state']:7} {'PASS' if not r['problems'] else '; '.join(r['problems'])}")
    json.dump(results, open("consent-run.json", "w"), indent=1)
    sys.exit(1 if any(r["problems"] and not r["problems"][0].startswith("GAP")
                      for r in results) else 0)

Chromium reports a dead proxy as net::ERR_PROXY_CONNECTION_FAILED and a refused tunnel as net::ERR_TUNNEL_CONNECTION_FAILED. Those are retried once and then marked GAP, not failures, so a flaky exit never shows up as a compliance bug. The exit code is non-zero only for real findings, which is what lets CI block a deploy.

Where a red result belongs

A country exit makes your consent platform, CDN and tags believe the visitor is in that country. That covers geography and nothing else. Use this table to decide where a red result goes:

FindingCan a country exit reproduce it?Where the fix lives
No banner in Norway, banner in GermanyYesAdd the EEA countries to the CDN rule or the `region` list
Banner shown to US visitors you meant to exemptYesConsent platform region template, or the default without a `region`
Beacons before any choice in one country onlyYesTag manager triggers; a region-specific default is missing
Beacons after RejectYesTags not wired to the consent state
No tags after AcceptYesConsent update never reaches the tags
Banner variant flips between runs for one countryUsuallyHTML containing the banner decision cached at the CDN without the country in the cache key
A cookie set by a browser extension or a corporate proxyNoNot on your side
Whether a given cookie is "strictly necessary"NoA legal judgement, recorded in your allowlist
Wording, colours or contrast of the buttonsNoDesign review; the taskforce report treats this case by case

The caching row deserves a sentence of its own. If your server renders the banner decision into the HTML and your CDN caches that HTML, the first visitor after a purge decides what everyone at that edge sees for a while. The harness shows this as a country whose verdict changes between consecutive runs. The fix is to vary the cache on country or move the decision to the client; a proxy only proves it's happening.

Broader geo QA, such as language, currency and hreflang per market, is a separate job; our guide to proxies for localization testing covers it, and this harness deliberately checks consent only.

A second opinion from the Scraping API

When a finding is surprising, render the page once more with a different engine before you open a ticket. The SparkProxy Scraping API renders in headless Chromium from a chosen country. Per its documentation, render_js defaults to true, country_code adds 5 credits, a screenshot adds 5, and rendering through the rotating pool costs 5, so a German screenshot costs 15 credits.

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: $SPARKPROXY_API_KEY" \
  --data-urlencode "url=https://shop.sparkproxy.io/" \
  -d render_js=true -d country_code=DE -d format=screenshot -d wait=3 \
  --output de-banner.jpg

To check markup instead of pixels, ask for the JSON envelope with json_response=true. The envelope's body field is Base64 and has to be decoded before you search it. Don't use format=json for this; format only converts page content and never returns the envelope.

import base64
import os

import requests

API = "https://scrape.sparkproxy.io/api/v1"


def banner_in_markup(url, cc, marker='id="consent-banner"'):
    r = requests.get(API, timeout=200,
                     headers={"X-API-Key": os.environ["SPARKPROXY_API_KEY"]},
                     params={"url": url, "country_code": cc, "render_js": "true",
                             "wait": "3", "json_response": "true"})
    r.raise_for_status()
    env = r.json()
    html = base64.b64decode(env.get("body") or "").decode("utf-8", errors="replace")
    return {"cc": cc, "status": env.get("status_code"),
            "credits": env.get("credits_used"), "banner": marker in html}


for cc in ("DE", "NO", "US"):
    print(banner_in_markup("https://shop.sparkproxy.io/", cc))

That call costs 10 credits per country (5 for rendering, 5 for the country). New accounts get 1,000 free credits. When we called the API from our test machine on 30 October 2026 it returned HTTP 521, so this post contains no live API result; the parameters and costs above come from the documentation read that day.

Running it on every tag publish, and what it costs

Consent regressions arrive with ordinary deploys: a new pixel added in the tag manager, a consent platform template edited, a CDN rule rewritten. Run the harness after every tag manager publish and on a schedule. Append each run to a JSON Lines file so you have dated evidence of what each country saw, and alert only on changes:

import json
import time
from pathlib import Path

LOG = Path("consent-history.jsonl")


def record(results):
    """Append this run and return rows whose problems changed since the last run."""
    last = {}
    if LOG.exists():
        for line in LOG.read_text(encoding="utf-8").splitlines():
            row = json.loads(line)
            last[(row["cc"], row["state"])] = row.get("problems", [])
    stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    changed = []
    with LOG.open("a", encoding="utf-8") as f:
        for r in results:
            slim = {k: r.get(k) for k in ("cc", "state", "exit", "banner", "tcf",
                                          "cookies", "storage", "problems", "hosts")}
            slim["ts"] = stamp
            f.write(json.dumps(slim) + "\n")
            if last.get((r["cc"], r["state"])) != r.get("problems"):
                changed.append(slim)
    return changed

Keep the screenshots for any failing row next to the log. If a regulator or a client ever asks what your banner did in France last Tuesday, a timestamped capture from a French IP is a better answer than a recollection.

A twice-daily cron entry is enough for most sites:

30 7,19 * * * cd /opt/consent-check && /usr/bin/python3 run_consent.py >> logs/consent.log 2>&1

For uptime-style alerting on top of this, proxies for uptime and synthetic monitoring covers alert thresholds. Two rules from our side: treat GAP rows as missing data, never as passes, and require a finding to appear in two consecutive runs before paging anyone, because the caching problem above can make one run look different from the next.

Cost. Prices below are in US dollars, exclude VAT/GST, and were read from sparkproxy.io on 30 October 2026. Proxy plans are packs billed by threads with unlimited bandwidth, starting from $75/month (Starter, 100 threads), so screenshots and full page loads add nothing to the bill. Term discounts are 5% for 3 months, 10% for 6 and 15% for 12, and there's a 24-hour free trial with no credit card, which is enough to run the whole matrix several times.

ItemFigureWhat this job uses
Starter plan$75/month, 100 threads, unlimited bandwidth9 countries x up to 3 states, twice a day
Free trial24 hours, no credit cardOne full validation before you buy
Scraping API screenshot from one country15 creditsOnly for surprising findings
Scraping API markup check from one country10 creditsSame
API signup1,000 free creditsAbout 66 screenshots

The number to watch is concurrency, not traffic. A real browser opens a separate tunnel for each host it talks to, and a tag-heavy page can talk to dozens of hosts at once. Check how your plan counts threads in the dashboard, then look at the hosts field the harness records: multiply the highest value by your max_workers and keep that under your limit. For handling cookies and sessions in scrapers more generally, see our guide to handling cookies and sessions in web scraping.

Frequently asked questions

FAQ

Load the site through a proxy exit in each EU country and record what the browser sees and sends. A Playwright script with a per-context proxy can capture the banner, every cookie and every network request per country, which a VPN that switches one country at a time can't do in a single run.

Your banner is probably gated on EU membership rather than the EEA. Cloudflare's ip.src.is_in_european_union field and hand-typed EU country lists both leave out Norway, Iceland and Liechtenstein, which are EEA countries.

No. The EDPB's Guidelines 2/2023 say Article 5(3) of the ePrivacy Directive covers similar technologies, including tracking pixels and URL tracking, not only cookies. Check network beacons and localStorage as well as the cookie jar.

Sticky Sessions. The page, the consent platform's geo lookup and every beacon should leave from one IP, otherwise different requests can resolve to different countries and you'll test a mix that no real visitor sees.

Capture Google's beacon requests and log the raw gcs and gcd parameters per state. Google documents that gcs carries ad_storage and analytics_storage, so its value should change after Accept; Tag Assistant shows the decoded state.

No. A proxy test proves what your site does for a visitor in each country: banner shown, buttons present, trackers held back. Whether a cookie is strictly necessary, or whether the banner design is fair, is a legal judgement your counsel makes.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

Written by the SparkProxy Technical Team, the engineers who run SparkProxy's proxy network (1M+ premium IPs in 80+ countries, HTTP/HTTPS and SOCKS5, IP whitelist and user:pass authentication, 1 Gbps ports) and the SparkProxy Scraping API. Legal references come from the EDPB's Guidelines 2/2023 (version 2.0) and the Cookie Banner Taskforce report, and the European Commission's GDPR scope page; technical behaviour comes from Google's consent mode documentation, the IAB Tech Lab CMP API v2.2, Cloudflare's developer documentation and Playwright's docs, all read on 30 October 2026. Measurements described as ours come from our own requests on that date. Corrections: support@sparkproxy.io.

Keep reading

Related articles

FoxyProxy Setup With Username and Password

FoxyProxy setup for per-site routing: saved username and password, patterns that match, why Chrome rejects SOCKS5 auth, and how to stop the login popup.

SparkProxyยทUse Cases

Competitors Bidding on My Brand Name? Track Them

Competitors bidding on my brand name? Schedule brand SERP captures per country, parse advertiser domains, save screenshot evidence and alert on new bidders.

SparkProxyยทUse Cases