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.
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.
| Market | Expected on first load | After Reject | After Accept |
|---|---|---|---|
| EU member states (DE, FR, IE, NL, IT, PL...) | Banner visible, Reject on the first layer, no non-essential cookies, storage or beacons | Still none of them | Your tags fire |
| EEA outside the EU (NO, IS, LI) | Same as the EU row | Same | Same |
| United Kingdom | Whatever your UK policy says (UK GDPR and PECR, its own regime) | Per policy | Per policy |
| United States | The variant you chose: no banner, a notice, or an opt-out link | Not applicable | Not 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.
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.
| Decision | Choice | Why |
|---|---|---|
| Rotation | Sticky Sessions, port 11002 | The 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 |
| Countries | Your top EU markets, plus NO, GB and US | NO catches EU-only rules, GB checks your UK policy, US is the control |
| Browser locale | Fixed at `en-US` for every country | Keeps the IP as the only variable; run a second pass with local languages later if your banner text is translated |
| Concurrency | 3 countries at a time | Each browser opens many tunnels at once; see the cost section |
| Contexts | A fresh browser context per state | No 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.
Capturing one consent state through one exit
The third file, consent_capture.py, holds run_state. Each call opens a fresh context through one country's sticky exit and does four things in order. It confirms the exit country, loads the page, optionally clicks Reject or Accept, then collects cookies, localStorage and every request with a timestamp so you can split "before the click" from "after".
import time
from playwright.sync_api import TimeoutError as PWTimeout
from consent_config import ACCEPT, BANNER, REJECT, TARGET, TRACE_URL, proxy_for
from consent_rules import ALLOWED_COOKIES, ALLOWED_STORAGE
TCF_PING = """() => new Promise(resolve => {
if (typeof window.__tcfapi !== 'function') return resolve(null);
window.__tcfapi('ping', 2, r => resolve(r));
})"""
def exit_country(page):
page.goto(TRACE_URL, timeout=30_000)
pairs = [ln.split("=", 1) for ln in page.inner_text("body").splitlines() if "=" in ln]
return dict(pairs).get("loc", "?")
def run_state(browser, cc, state, shots="shots"):
ctx = browser.new_context(proxy=proxy_for(cc), locale="en-US")
hits = []
ctx.on("request", lambda r: hits.append((time.monotonic(), r.url)))
page = ctx.new_page()
try:
exit_cc = exit_country(page)
hits.clear()
page.goto(TARGET, wait_until="load", timeout=60_000)
try:
page.wait_for_selector(BANNER, state="visible", timeout=8_000)
banner = True
except PWTimeout:
banner = False
page.wait_for_timeout(3_000) # let late tags fire
tcf = page.evaluate(TCF_PING)
mark = time.monotonic()
if state != "before" and banner:
page.click(REJECT if state == "reject" else ACCEPT)
page.wait_for_timeout(4_000)
page.screenshot(path=f"{shots}/{cc}-{state}.png", full_page=False)
cookies = [c["name"] for c in ctx.cookies()
if not ALLOWED_COOKIES.match(c["name"])]
storage = [i["name"] for o in ctx.storage_state()["origins"]
for i in o.get("localStorage", [])
if not ALLOWED_STORAGE.match(i["name"])]
finally:
ctx.close()
return {
"cc": cc, "state": state, "exit": exit_cc, "banner": banner, "tcf": tcf,
"pre": [u for t, u in hits if t < mark],
"post": [u for t, u in hits if t >= mark],
"cookies": cookies, "storage": storage,
"hosts": len({u.split("/")[2] for _, u in hits if "://" in u}),
}
A few choices here are deliberate. The request listener is on the context, not the page; Playwright says the context event is "emitted when a request is issued from any pages created through this context", so a consent platform that opens its own frame or popup is still captured. ctx.cookies() is called with no URLs because, per the docs, "if no URLs are specified, this method returns all cookies", including third-party ones set in frames. The hosts count feeds the cost estimate later.
The exit check reads Cloudflare's /cdn-cgi/trace path on our own domain. It isn't part of Cloudflare's documented API, so treat it as a convenience, but it has a useful property: the loc= line is Cloudflare's own geolocation of the IP. If your banner decision is made at Cloudflare, you're checking the exit with the same database your site uses. When we requested it from our test machine on 30 October 2026 it answered loc=IN and colo=HKG, which matched the machine's real location. For any other way to confirm an exit, see how to test if your proxy is working.
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.
Reading Consent Mode and TCF signals
Two standard signals give you a second view of what the page thinks happened, independent of your own selectors.
Google consent mode. Google documents two modes. In basic mode, "no data is sent before a user consents - not even the default consent status". In advanced mode, tags load immediately with defaults set to denied and send "measurements without cookies" until consent arrives (Google consent mode overview). So on a site running advanced mode, the harness will flag Google beacons in the before state even though your configuration is doing exactly what it was set up to do. That's intentional: whether cookieless pings before consent fit your policy is a decision for your counsel, and the EDPB's pixel paragraph is the text they'll want to read.
Google also says "the gcs parameter is used to transmit the ad_storage and analytics_storage parameters", and that "the gcd parameter is always sent to Google services, regardless of whether consent mode is activated or not". The overview page doesn't publish a table of gcs values, so we don't decode them here. What you can test without a decoder is change: consent_params() pulls the raw values off each beacon, and the value in the accept state should differ from the one in before and reject. If it's identical in all three, your consent platform isn't calling gtag('consent', 'update', ...). Google's Tag Assistant shows the decoded state when you need to confirm by hand.
IAB TCF. If your consent platform implements the IAB Transparency and Consent Framework, it exposes __tcfapi(command, version, callback, parameter), and ping "invokes the callback immediately without any asynchronous logic" (IAB Tech Lab CMP API v2.2). The ping result carries gdprApplies (true, false or undefined), cmpStatus (stub, loaded or error) and displayStatus (visible, hidden or disabled). The harness stores it as tcf. Two cross-checks are worth adding to the verdict:
| Market | `gdprApplies` should be | `displayStatus` on first load | What a mismatch means |
|---|---|---|---|
| EU and EEA | `true` | `visible` | The platform's own geolocation disagrees with your CDN, or a region template is missing |
| US | `false` | `hidden` or `disabled` | The platform treats the US as a GDPR region; check its region settings |
| Any | not `undefined` after load | not stuck on `stub` | The platform script failed to load through this exit |
If displayStatus says visible while your own selector found nothing, your selector is stale, not your banner. That disagreement is the most common false alarm in this kind of test, and the TCF signal settles it without a screenshot review.
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:
| Finding | Can a country exit reproduce it? | Where the fix lives |
|---|---|---|
| No banner in Norway, banner in Germany | Yes | Add the EEA countries to the CDN rule or the `region` list |
| Banner shown to US visitors you meant to exempt | Yes | Consent platform region template, or the default without a `region` |
| Beacons before any choice in one country only | Yes | Tag manager triggers; a region-specific default is missing |
| Beacons after Reject | Yes | Tags not wired to the consent state |
| No tags after Accept | Yes | Consent update never reaches the tags |
| Banner variant flips between runs for one country | Usually | HTML 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 proxy | No | Not on your side |
| Whether a given cookie is "strictly necessary" | No | A legal judgement, recorded in your allowlist |
| Wording, colours or contrast of the buttons | No | Design 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.
| Item | Figure | What this job uses |
|---|---|---|
| Starter plan | $75/month, 100 threads, unlimited bandwidth | 9 countries x up to 3 states, twice a day |
| Free trial | 24 hours, no credit card | One full validation before you buy |
| Scraping API screenshot from one country | 15 credits | Only for surprising findings |
| Scraping API markup check from one country | 10 credits | Same |
| API signup | 1,000 free credits | About 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.
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
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.
ScrapeBox Proxies: Load, Test, and Harvest Google
Set up ScrapeBox proxies that keep harvesting: the IP:PORT:USER:PASS format, loading a rotating gateway, what the Google test checks, and safe connections.
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.
