Cookie Compliance Scanner: Find Tracking Before Consent

Use a cookie compliance scanner to find tracking that fires before consent: how to test a clean load, read the results, and fix GTM, pixels and embeds.

BugViso

15 min read

A cookie compliance scanner loads your site the way a first-time visitor does, with no stored consent and no clicks, and lists every cookie written and every tracking request sent before the visitor answers your banner. That pre-consent snapshot is what matters. A banner only collects a choice. It doesn't stop analytics and ad tags that were wired to fire on page load.

The gap is common. In our study of websites tracking before consent (9 October 2026), 33.3% of European-domain homepages that showed a consent banner had already set a first-party analytics or advertising cookie before any click. Across 370 homepages, Google Analytics' _ga cookie was already present on 39.5%.

This guide shows how to run a clean pre-consent test by hand and with a script, how to read the results, and how to fix the six setups that cause most leaks. It's technical guidance, not legal advice. Whether a given cookie needs consent depends on its purpose and on your jurisdiction.


Why "We Have a Banner" Isn't Compliance

Under Article 5(3) of the EU ePrivacy Directive, which the UK mirrors in PECR, storing or reading non-essential information on a user's device needs prior consent. The UK regulator's guidance on cookies and similar technologies and the EDPB guidelines on consent both make the same point: consent has to come before the cookie, and a pre-ticked or implied choice doesn't count.

A consent management platform (CMP) shows the banner and records the answer. It doesn't automatically stop the tags already in your page. That only happens when every tag is connected to the CMP's consent state. Three things commonly break that connection:

  1. Tags that fire on "All Pages" in a tag manager, outside any consent trigger.
  2. Hard-coded snippets in the theme (a Meta Pixel in header.php, a Clarity script pasted into the CMS) that the tag manager never sees.
  3. Embeds (YouTube, Maps, social widgets) that set their own cookies the moment the iframe loads.

How to Test a Clean Page Load by Hand

You can do a reliable manual check in five minutes with Chrome DevTools.

  1. Open a fresh profile. A Guest window or a new Chrome profile is better than Incognito, because Incognito shares state across its tabs and may block third-party cookies by default, which hides some leaks.
  2. Open DevTools before loading the page (Network tab, with Preserve log and Disable cache ticked), then load your homepage.
  3. Don't touch the banner. Wait 5–10 seconds for tag managers to run.
  4. Application → Storage → Cookies: select each origin listed and write down every cookie.
  5. Network tab: filter by collect, analytics, facebook, pixel, clarity or doubleclick to see tracking requests.
  6. Repeat on two or three key templates: a product page, a blog post and a landing page.

What you're looking for:

You see before any click…What it usually meansSeverity
_ga, _ga_<ID> (first-party)GA4 fired without a consent conditionHigh
_fbp (first-party)Meta Pixel loaded unconditionallyHigh
_gcl_au (first-party)Google Ads conversion linker firedHigh
IDE, test_cookie on .doubleclick.netGoogle ad tags or an ad slot loadedHigh
_clck, _clsk, _uetsid, _uetvidMicrosoft Clarity or Bing UETHigh
YSC, VISITOR_INFO1_LIVE on .youtube.comA YouTube embed on the pageMedium
A request to googletagmanager.com but no cookiesGTM loaded, which is fine if tags inside wait for consentInfo
Session, CSRF, cart or load-balancer cookiesStrictly necessary functionalityOK

These are the shares of homepages in our 370-site study (174 EU-domain plus 196 global) with each cookie present on the bare, un-consented load:

Source (cookie signature)HomepagesShare
Google Analytics (_ga)14639.5%
Google ad cookie (IDE / test_cookie on doubleclick.net)8121.9%
Google Ads conversion linker (_gcl_au)6016.2%
Meta Pixel (_fbp)5414.6%
Microsoft Clarity / Bing UET (_clck, _uet*)3810.3%
LinkedIn Insight (li_*, lidc, bcookie)359.5%
Criteo (cto_*)236.2%
YouTube embed (YSC, VISITOR_INFO1_LIVE)154.1%
TikTok Pixel (_ttp)123.2%
HubSpot (hubspotutk, __hs*)113.0%
Hotjar (_hj*)113.0%

The list is dominated by marketing tags that a tag manager or a theme injects unconditionally. Of the 146 sites writing _ga before consent, only 27 had set a Google Consent Mode default at all.


The manual check doesn't scale to many templates or many client sites. This script opens a fresh Chromium profile, loads the page, never clicks anything, waits, then classifies every cookie and lists tracker hosts. It's the same procedure we used for the study.

python
#!/usr/bin/env python3
"""preconsent_scan.py: list cookies and tracker requests a page triggers BEFORE any consent click.

Usage:
    pip install playwright && playwright install chromium
    python3 preconsent_scan.py https://example.com [--wait 6] [--tz Europe/Berlin] [--json out.json]

Exit code 1 when tracking cookies or tracker requests fired pre-consent. Not legal advice.
"""
import argparse, asyncio, json, sys, time
from urllib.parse import urlsplit

from playwright.async_api import async_playwright

TRACKER_HOSTS = (
    "google-analytics.com", "googletagmanager.com", "doubleclick.net", "googlesyndication.com",
    "googleadservices.com", "facebook.net", "facebook.com", "connect.facebook.net", "hotjar.com",
    "clarity.ms", "bing.com", "linkedin.com", "licdn.com", "ads-twitter.com", "tiktok.com",
    "pinterest.com", "snapchat.com", "criteo.com", "criteo.net", "taboola.com", "outbrain.com",
    "adroll.com", "quantserve.com", "scorecardresearch.com", "hubspot.com", "hs-analytics.net",
    "hs-scripts.com", "segment.com", "segment.io", "mixpanel.com", "amplitude.com", "fullstory.com",
)
CMP_HOSTS = ("cookiebot.com", "onetrust.com", "cookielaw.org", "usercentrics.eu", "didomi.io",
             "trustarc.com", "iubenda.com", "termly.io", "cookieyes.com", "osano.com", "consensu.org",
             "quantcast.com", "axeptio.eu", "complianz.io", "civicuk.com")
ANALYTICS = ("_ga", "_gid", "_gat", "__utm", "_hjSession", "_hjid", "_clck", "_clsk", "ajs_", "amplitude_id",
             "mp_", "_pk_id", "_pk_ses", "__hstc", "__hssc", "hubspotutk", "_uetsid", "_uetvid", "_mkto_trk")
ADVERTISING = ("_fbp", "_fbc", "_gcl_", "_ttp", "li_fat_id", "_pin_unauth", "cto_bundle", "_scid", "_rdt_uuid")
# Short, generic names that only mean "ad tracker" when an ad network sets them on its own domain.
AD_NETWORK_NAMES = {"IDE", "test_cookie", "MUID", "fr", "bcookie", "lidc", "personalization_id", "NID"}


def site(host):
    parts = (host or "").lower().lstrip(".").split(".")
    return ".".join(parts[-3:] if len(parts) > 2 and parts[-2] in ("co", "com", "org", "gov", "ac") else parts[-2:])


def match(host, domains):
    h = (host or "").lower().lstrip(".")
    return next((d for d in domains if h == d or h.endswith("." + d)), None)


def classify(name, domain, third_party):
    if name.startswith(ANALYTICS):
        return "analytics"
    if name.startswith(ADVERTISING) or (third_party and name in AD_NETWORK_NAMES):
        return "advertising"
    if third_party and match(domain, TRACKER_HOSTS):
        return "tracking (3rd-party host)"
    return "3rd-party, unknown: check" if third_party else "1st-party, unknown: check"


async def scan(url, wait, tz):
    async with async_playwright() as pw:
        browser = await pw.chromium.launch()
        ctx = await browser.new_context(locale="en-US", timezone_id=tz) if tz else await browser.new_context(locale="en-US")
        page = await ctx.new_page()
        hosts = set()
        page.on("request", lambda r: hosts.add(urlsplit(r.url).hostname or ""))
        await page.goto(url, wait_until="load", timeout=45000)
        await page.wait_for_timeout(wait * 1000)          # let tag managers and CMPs run
        gcm = await page.evaluate("""() => { const dl = window.dataLayer || []; let d = null;
            for (const e of dl) { if (e && e.length !== undefined && e[0] === 'consent' && e[1] === 'default') d = Object.assign(d || {}, e[2]); }
            return d; }""")
        cookies = await ctx.cookies()
        await browser.close()
    me = site(urlsplit(url).hostname)
    rows = []
    for c in cookies:
        third = site(c["domain"]) != me
        rows.append({"name": c["name"], "domain": c["domain"], "party": "3rd" if third else "1st",
                     "category": classify(c["name"], c["domain"], third),
                     "days": round((c["expires"] - time.time()) / 86400) if c["expires"] > 0 else "session"})
    trackers = sorted({match(h, TRACKER_HOSTS) for h in hosts if match(h, TRACKER_HOSTS)})
    cmps = sorted({match(h, CMP_HOSTS) for h in hosts if match(h, CMP_HOSTS)})
    return {"url": url, "cookies": rows, "tracker_hosts": trackers, "consent_platforms": cmps, "consent_mode_default": gcm}


def report(r):
    bad = [c for c in r["cookies"] if c["category"] in ("analytics", "advertising", "tracking (3rd-party host)")]
    print(f"\n{r['url']}")
    print(f"  consent platform seen : {', '.join(r['consent_platforms']) or 'none detected'}")
    print(f"  Consent Mode default  : {json.dumps(r['consent_mode_default']) if r['consent_mode_default'] else 'not set'}")
    print(f"  cookies before consent: {len(r['cookies'])} total, {len(bad)} tracking")
    for c in sorted(r["cookies"], key=lambda c: (c["category"], c["name"])):
        flag = "✗" if c in bad else " "
        print(f"   {flag} {c['name'][:32]:32s} {c['party']}  {c['category']:30s} {c['domain'][:28]:28s} {c['days']}")
    print(f"  tracker hosts contacted: {', '.join(r['tracker_hosts']) or 'none'}")
    return bool(bad or r["tracker_hosts"])


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("url")
    ap.add_argument("--wait", type=int, default=6, help="seconds to wait after load (default 6)")
    ap.add_argument("--tz", help="browser timezone, e.g. Europe/Berlin (some banners are region-targeted)")
    ap.add_argument("--json", help="also write the raw result to this file")
    a = ap.parse_args()
    result = asyncio.run(scan(a.url, a.wait, a.tz))
    if a.json:
        json.dump(result, open(a.json, "w"), indent=2)
    sys.exit(1 if report(result) else 0)

An excerpt of real output for a large news homepage (scanned 9 October 2026, site name replaced, 34 other cookies omitted):

text
https://news-site.example/
  consent platform seen : none detected
  Consent Mode default  : not set
  cookies before consent: 38 total, 2 tracking
     DotMetrics.DeviceKey             3rd  3rd-party, unknown: check      .dotmetrics.net              365
     _chartbeat2                      3rd  3rd-party, unknown: check      .news-site-group.example     395
   ✗ UID                              3rd  tracking (3rd-party host)      .scorecardresearch.com       390
   ✗ XID                              3rd  tracking (3rd-party host)      .scorecardresearch.com       390
  tracker hosts contacted: doubleclick.net, googlesyndication.com, scorecardresearch.com

Two lessons from that output. First, signature lists are never complete: audience-measurement cookies like the DotMetrics ones aren't on the list, which is why the script labels them "unknown: check" instead of "necessary". Second, the expiry column matters. A 395-day identifier is a tracking identifier whatever its name.

💡 Test from the right region. Many CMPs geolocate and show the banner only to EU/UK visitors. A scan from elsewhere may show a site that tracks freely because, for that region, it's configured to. Run the scanner from a machine or VPN exit in the region you're testing, and pass --tz Europe/Berlin (or your market's zone) for scripts that check the timezone.


Fixing the Six Common Causes

1. Tag manager tags firing on "All Pages"

In Google Tag Manager, every analytics or marketing tag needs a consent condition, set either through Consent Settings ("Require additional consent for tag to fire") or a CMP trigger. With Consent Mode v2, set the default before GTM loads:

html
<!-- ✅ Consent Mode v2 default: deny storage until the visitor chooses (before the GTM snippet) -->
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('consent', 'default', {
    ad_storage: 'denied', analytics_storage: 'denied',
    ad_user_data: 'denied', ad_personalization: 'denied',
    wait_for_update: 500
  });
</script>
<!-- GTM snippet follows -->

Your CMP then calls gtag('consent', 'update', {...}) when the visitor accepts. Google's Consent Mode documentation covers the CMP integrations. With storage denied, Google tags can still load but won't write _ga or ad cookies. That's the state our classifier, and BugViso's, treats as compliant.

2. Hard-coded pixels in the theme

Search the theme and CMS for snippets that bypass the tag manager:

bash
# Find hard-coded tracking snippets in a theme or build output
grep -rnE "fbq\(|gtag\(|clarity\.ms|hotjar|_linkedin_partner_id|ttq\.load|snap\.licdn" ./wp-content/themes ./dist 2>/dev/null

Move each one into the tag manager behind a consent trigger, or use your CMP's script-blocking syntax:

html
<!-- ❌ Runs immediately -->
<script src="https://connect.facebook.net/en_US/fbevents.js"></script>

<!-- ✅ Held until the "marketing" category is accepted (Cookiebot syntax shown; other CMPs use a similar pattern) -->
<script type="text/plain" data-cookieconsent="marketing" src="https://connect.facebook.net/en_US/fbevents.js"></script>

3. Embedded video and maps

A YouTube iframe sets YSC and VISITOR_INFO1_LIVE as soon as it loads. Use the privacy-enhanced domain plus a click-to-load placeholder:

html
<!-- ✅ Thumbnail placeholder; the iframe (youtube-nocookie.com) is created only after a click -->
<button class="yt-facade" data-id="VIDEO_ID" aria-label="Play video: Product tour">
  <img src="/thumbs/VIDEO_ID.jpg" alt="" width="640" height="360">
</button>
<script>
document.querySelectorAll('.yt-facade').forEach(b => b.addEventListener('click', () => {
  const f = document.createElement('iframe');
  f.src = `https://www.youtube-nocookie.com/embed/${b.dataset.id}?autoplay=1`;
  f.allow = 'autoplay; encrypted-media'; f.width = 640; f.height = 360; f.title = b.getAttribute('aria-label');
  b.replaceWith(f);
}));
</script>

The same facade also helps performance, because the YouTube player's JavaScript stays off the page until someone wants it.

4. The CMP loads after the tags

If the CMP script loads with async after GTM, tags can fire in the gap. Load the CMP first and synchronously (it's small), set the Consent Mode default before GTM, and only then load GTM.

5. Ad slots and affiliate scripts

Ad tags (googlesyndication.com, header bidders) set identifiers on load. Most ad stacks support a consent string (TCF v2.2) or a non-personalised mode. Configure them to wait for the CMP's signal instead of loading on DOMContentLoaded.

6. Cookies from previous visits

If you test in your normal browser, you've probably accepted before. Always test in a fresh profile, or clear site data first (Application → Storage → Clear site data).


Verify the Fix: A Re-Test Protocol

  1. Run the scanner on every key template before and after the change and diff the JSON outputs.
  2. Confirm zero analytics/advertising cookies before consent, and that tracker requests are either absent or Google requests under denied Consent Mode (no cookies).
  3. Click Accept in a manual session and confirm tags do fire after consent. A fix that silently breaks analytics won't survive the next marketing review.
  4. Click Reject and confirm nothing non-essential appears, including after navigating to a second page.
  5. Schedule the check. Tags get added every month, and each new one is a chance to regress.

BugViso's Privacy & Compliance audit runs inside every scan on the bare, un-consented page load. The engine's interaction simulation deliberately avoids cookie and consent elements, so it never clicks "Accept". The audit then reports:

  • Every cookie present, with its name, domain, first- or third-party status, category (analytics, advertising, functional, essential), persistence and Secure/SameSite flags
  • Tracking cookies set before consent, an error-level finding, with a stronger message when a consent platform is on the page but tracking fired anyway
  • Tracker hosts contacted (45 known analytics and ad domains), a warning-level finding
  • Consent platform detection from 15+ CMP signatures, and Google Consent Mode v2 state, so Google tags running with storage denied aren't counted as leaks
  • A privacy score (−6 per tracking cookie, −4 per tracker host, −15 extra when a CMP is present but bypassed)

The report's fix text includes the Consent Mode v2 default snippet shown above. With a weekly or monthly scheduled scan, a newly added tag that fires before consent shows up in the next report instead of in a regulator's letter.

You can run a free BugViso scan to list your pre-consent cookies. For the wider checklist, see GDPR website compliance for developers. The security and privacy audit page lists the tracker and CMP signatures.


  • Geo-targeting. A scanner sees the region it runs from. Scan from your visitors' region.
  • Interaction-triggered tags. Tags that fire on scroll, click or a second pageview are invisible to a load-only scan. Test those journeys by hand.
  • Purpose isn't machine-readable. A scanner can tell you a cookie exists and who set it. Only you can document why, and whether an exemption applies.
  • Server-side tracking (server-side tag managers, conversion APIs) doesn't appear in the browser at all. Audit it on the server.

FAQ

A tool that loads your pages without consent and lists the cookies set and tracking requests made, so you can see what fires before a visitor chooses. Good scanners classify cookies by purpose and detect your consent platform.

GTM itself sets no cookies. The tags inside it need consent conditions. With Consent Mode v2 defaulting to denied, Google tags can load without writing cookies until consent is given.

Are strictly necessary cookies exempt?

Yes. Cookies essential to a service the user asked for (session, cart, security, load balancing) don't need consent under ePrivacy rules, though you should still document them in your cookie policy.

After any tag, plugin or theme change, and on a schedule (monthly at minimum). Marketing teams add tags constantly, and each one can bypass consent.

No tool does. A scanner shows what happens technically. Compliance also covers your consent wording, records, lawful basis and policies, so treat the scan as evidence for that work, not a substitute for legal advice.


Conclusion

The only reliable test of a consent setup is a clean page load with no click: if analytics cookies exist before the visitor answers, the banner is decoration. Run that check on every template, after every tag change, and on a schedule with a BugViso scan that reports exactly which cookies fired before consent.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.