Cookyay

Free, self-hosted cookie consent — zero-dependency banner library.

Not legal advice. Cookyay helps implement consent UX patterns described by GDPR and CCPA. Whether your specific deployment is compliant is a legal question outside the scope of this library.

Live demo

This page dogfoods Cookyay itself — the banner you see (or have already dismissed) is the real library loaded from jsDelivr. Click Cookie settings in the bottom-left to re-open the preferences modal.

Cookyay is active on this page. If you haven't interacted yet, the consent banner will appear. Use the "Cookie settings" link injected into the bottom-left corner to re-open preferences at any time.

Quickstart — working banner in <15 minutes

Cookyay uses a two-part install. Both parts are required and their order is critical — getting this wrong is the #1 breakage point.

⚠ Load-order breakage warning The inline bootstrap snippet (Part 1) MUST appear as the very first <script> in <head>, before any Google Analytics, GTM, or other analytics snippets. If those scripts load first, Google Consent Mode v2 defaults are never registered — a silent GDPR violation that is invisible in the browser.

Part 1 — Inline bootstrap snippet (synchronous, <1 KB)

Copy this verbatim as the first script in <head>. It fires Consent Mode v2 defaults (all denied), detects the GPC signal, and arms the script intercept so blocked scripts stay inert until consent is given.

The verbatim snippet above is the compiled build of packages/cookyay/src/bootstrap.ts (dist/bootstrap.js). It reads the cookyay_consent cookie so returning visitors have their stored consent grants restored to Consent Mode before any paint. If you need to inject it programmatically from a build tool, read dist/bootstrap.js from the npm package — not INLINE_SNIPPET_JS, which is a simpler all-denied-only snippet that does not read the consent cookie.

<head>
  <!-- PART 1: Cookyay bootstrap — MUST be first in <head> -->
  <script>"use strict";(()=>{function o(){return{ad_storage:"denied",analytics_storage:"denied",ad_user_data:"denied",ad_personalization:"denied",functionality_storage:"denied",personalization_storage:"denied",security_storage:"denied",wait_for_update:500}}function i(a){let t=document.cookie.match(/(?:^|;\s*)cookyay_consent=([^;]+)/);if(t)try{let n=JSON.parse(decodeURIComponent(t[1]));if(n?.sv!==1||!n?.c||typeof n.c!="object")return;let e=n.c;e.n&&(a.functionality_storage="granted",a.security_storage="granted"),e.f&&(a.functionality_storage="granted",a.personalization_storage="granted"),e.a&&(a.analytics_storage="granted"),e.m&&(a.ad_storage="granted",a.ad_user_data="granted",a.ad_personalization="granted")}catch{}}function r(){window.__COOKYAY||(window.__COOKYAY={q:[],gpc:!1}),window.__COOKYAY.gpc=!!navigator.globalPrivacyControl,window.dataLayer||(window.dataLayer=[]),typeof window.gtag!="function"&&(window.gtag=function(){window.dataLayer.push(arguments)});let a=o();i(a),window.gtag("consent","default",a)}r();})();</script>

  <!-- Your GA4 / GTM snippet goes HERE, after the bootstrap -->
  <!-- <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"></script> -->

  <!-- PART 2: Cookyay UI bundle — deferred, exact version + SRI -->
  <script
    src="https://cdn.jsdelivr.net/npm/cookyay@0.1.2/dist/index.iife.js"
    integrity="sha384-Q0WzaNKbd9Subp+xcy3lB6Km0asRWKdpAiiZJ9IZ1e8EjjAiTG/hUY/Efpj0LmkJ"
    crossorigin="anonymous"
    defer
  ></script>
</head>
Why two parts? Part 1 (the bootstrap) is synchronous and tiny (<1 KB). It must block HTML parsing for a moment to fire Consent Mode defaults before any Google tag has a chance to fire. Part 2 (the UI bundle) is deferred — it loads the banner UI without blocking page paint.

Initialise Cookyay

Add this after the closing </body> tag, or in a deferred script:

<script defer>
  document.addEventListener('DOMContentLoaded', function () {
    Cookyay.init({
      policyVersion: '2025-01-01',   // bump when your cookie usage changes
      categories: {
        necessary: {},               // always on — no toggle shown
        functional: {
          services: [{ name: 'Intercom', cookies: ['intercom-*'] }],
        },
        analytics: {
          services: [{ name: 'Google Analytics 4', cookies: ['_ga', '_gid'] }],
        },
        marketing: {
          services: [{ name: 'Meta Pixel', cookies: ['_fbp'] }],
        },
      },
    })
  })
</script>

Declare scripts to block

Replace type="text/javascript" with type="text/plain" and add data-category="<category>". The bootstrap intercepts these on page load and prevents them from executing until the visitor consents.

<!-- Blocked until analytics consent is given -->
<script
  type="text/plain"
  data-category="analytics"
  src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"
></script>

<!-- Blocked iframe — swap data-src for src -->
<iframe
  data-src="https://www.youtube.com/embed/dQw4w9WgXcQ"
  data-category="marketing"
  width="560" height="315"
  title="YouTube video"
  allowfullscreen
></iframe>
⚠ Remove <noscript> fallback tags Many third-party snippets (Google Analytics, Meta Pixel) include <noscript><img src="..."></noscript> fallback tags. These bypass script blocking entirely and fire pixels even when JavaScript is off or blocked. Remove all such tags — they are not GDPR-safe.

That's it. You now have:

To also block known third-party scripts/iframes automatically at runtime (without hand-declaring each one in HTML), add autoBlock: true to your Cookyay.init() call. See Runtime auto-block for details and scope limits.

Install / CDN

npm / pnpm

npm install cookyay
# or
pnpm add cookyay

Then import and use as ESM:

import { init, getConsent, onConsent } from 'cookyay'

// To embed the bootstrap snippet server-side, read dist/bootstrap.js from the package
// (not INLINE_SNIPPET_JS — that is a simpler all-denied-only snippet without cookie read)
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const bootstrapSnippet = readFileSync(
  resolve(import.meta.dirname, 'node_modules/cookyay/dist/bootstrap.js'), 'utf8'
)

jsDelivr CDN (IIFE)

Pin to an exact version with an SRI hash. A floating @latest tag bypasses integrity checks and is a supply-chain risk.

<script
  src="https://cdn.jsdelivr.net/npm/cookyay@0.1.2/dist/index.iife.js"
  integrity="sha384-Q0WzaNKbd9Subp+xcy3lB6Km0asRWKdpAiiZJ9IZ1e8EjjAiTG/hUY/Efpj0LmkJ"
  crossorigin="anonymous"
  defer
></script>

Get the SRI hash for a different version from the jsDelivr integrity API:
https://data.jsdelivr.com/v1/packages/npm/cookyay@VERSION/integrity

jsDelivr /+esm (module)

Convenient for prototyping. Note: jsDelivr does not provide stable SRI for the /+esm endpoint — use the IIFE path for production.

<script type="module">
  import Cookyay from 'https://cdn.jsdelivr.net/npm/cookyay@0.1/+esm'
  Cookyay.init({ policyVersion: '2025-01-01', categories: { necessary: {} } })
</script>

Self-hosted SRI

If you copy artifacts to your own CDN, regenerate SRI hashes after each update:

openssl dgst -sha384 -binary dist/index.iife.js | openssl base64 -A | sed 's/^/sha384-/'

Declarative script blocking

Cookyay uses a declarative blocking model. You annotate each script or iframe that should be blocked, and Cookyay handles the rest. Auto-detection is deferred to v2.

Scripts (<script>)

Change type="text/javascript"type="text/plain" and add data-category. Works for both inline scripts and src scripts.

<!-- External script, blocked until analytics consent -->
<script type="text/plain" data-category="analytics"
        src="/scripts/ga4.js"></script>

<!-- Inline script, blocked until marketing consent -->
<script type="text/plain" data-category="marketing">
  fbq('init', '123456')
  fbq('track', 'PageView')
</script>

Iframes (<iframe>)

Replace src with data-src and add data-category. Cookyay swaps data-srcsrc on consent, causing the iframe to load.

<iframe
  data-src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
  data-category="marketing"
  width="560" height="315"
  title="YouTube embed"
  allowfullscreen
></iframe>
YouTube note youtube-nocookie.com still writes localStorage and sends data to Google. Full iframe blocking via data-src swap is required for GDPR compliance.

Category values

Value Description
necessary Always unblocked — do not use for analytics/marketing
functional Live chat, session replay, user preferences
analytics Page analytics (GA4, Plausible, etc.)
marketing Ad tracking, retargeting pixels, video embeds

Re-execution on consent

When consent is granted, Cookyay clones the blocked <script> element and re-inserts it — mutating type does not re-execute scripts in any browser. The re-inserted script runs with full DOM access (since document.readyState is 'complete' at that point).

Config reference

Pass a single config object to Cookyay.init(config).

Key Type Default Description
policyVersion string required Bump when your cookie usage changes materially. Stored visitors whose saved version differs will see the banner again. Example: '2025-01-01'.
categories Record<CategoryId, CategoryConfig> {} Per-category label and services declarations. See CategoryConfig below.
strings Partial<StringTable> English defaults Override any user-visible or ARIA string. See String overrides.
theme ThemeOptions none Visual overrides: primaryColor, backgroundColor, textColor, borderRadius, fontFamily, zIndex.
modal boolean false true renders the first-layer banner as a focus-trapped modal (blocks interaction). Default is a non-blocking dialog. Note: consent walls are prohibited under GDPR unless you offer a genuine no-tracking alternative.
cookie CookieOptions none domain (restrict the consent cookie to a specific domain), expiryDays (default: 365).
debug boolean false Enable verbose console.log output at init time. Useful for diagnosing misconfiguration. Turn off in production.
autoBlock boolean false Enable runtime auto-block (v5+). When true, the banner intercepts known third-party <script>, <iframe>, and (v6+) <img> beacon pixel insertions, and (v7+) fetch()/sendBeacon() calls to curated tracking endpoints, holding them until the visitor grants consent — without requiring HTML changes. Declared rules always win. Google tags (GTM/GA4) are excluded (Consent Mode v2 instead). XMLHttpRequest and document.write remain deferred. Pre-consent sendBeacon calls at page unload are dropped, not deferred. See Runtime auto-block for full coverage details, honest limits, and the hard install requirement.
autoOpenLink boolean true Auto-injects a persistent "Cookie settings" link in the bottom-left corner so visitors can change their preferences at any time. Set false to suppress and wire your own opener via data-cookyay-open or Cookyay.openPreferences().
clearOnWithdraw (revoked: CategoryId[]) => void none Callback fired when consent is withdrawn for one or more categories. Use it to expire your own first-party cookies. Third-party script state cannot be cleared programmatically — a page reload is required.

CategoryConfig

{
  label?: string          // override the category label in the preferences modal
  services?: Array<{
    name: string          // shown in the preferences modal
    cookies?: string[]    // cookie names this service may write
    localStorage?: string[] // localStorage keys
  }>
}

Programmatic API

// Read current consent
Cookyay.getConsent()
// → { necessary: true, functional: false, analytics: false, marketing: false }

// Subscribe to consent changes for a category
const unsubscribe = Cookyay.onConsent('analytics', (granted) => {
  if (granted) loadAnalytics()
})
unsubscribe() // remove listener

// Open the preferences modal programmatically
Cookyay.openPreferences()

// Custom element opener (no JS required)
<button data-cookyay-open>Cookie settings</button>

Custom events

Cookyay dispatches events on document for zero-coupling integrations (GTM triggers, other scripts):

document.addEventListener('cookyay:consent', (e) => {
  const { schemaVersion, policyVersion, timestamp, categories } = e.detail
  // schemaVersion: number — schema version for future-proofing
  // policyVersion: string — matches the policyVersion from your Cookyay.init() config
  // timestamp: ISO-8601 string — when consent was recorded
  // categories: { necessary, functional, analytics, marketing } — per-category booleans
  if (categories.analytics) initMyAnalytics()
})

document.addEventListener('cookyay:change', (e) => {
  // fired when consent changes after initial grant — same four-field detail as cookyay:consent
  const { schemaVersion, policyVersion, timestamp, categories } = e.detail
  if (!categories.marketing) tearDownMarketingPixels()
})

Console warnings on misconfiguration

Cookyay emits console.warn (not console.error) for common mistakes so they're visible during development without breaking CI:

String overrides / i18n

Cookyay v1 ships with English defaults. Every user-visible and ARIA string is overridable via the strings config key. This is the intended localisation mechanism — supply translated strings for your locale.

Cookyay.init({
  policyVersion: '2025-01-01',
  strings: {
    // Banner first layer
    bannerTitle: 'Nous utilisons des cookies',
    bannerDescription: 'Ce site utilise des cookies pour améliorer votre expérience.',
    acceptAllLabel: 'Tout accepter',
    rejectAllLabel: 'Tout refuser',
    managePreferencesLabel: 'Gérer mes préférences',

    // Preferences modal
    preferencesTitle: 'Préférences de cookies',
    savePreferencesLabel: 'Enregistrer',
    closeLabel: 'Fermer',

    // GPC toast
    gpcNoticeText: 'Votre préférence de confidentialité (Global Privacy Control) a été détectée et appliquée.',

    // Withdrawal prompt
    withdrawalPromptText: 'Vos préférences ont été enregistrées. Les scripts déjà exécutés continueront jusqu\'au rechargement de la page.',
    reloadLabel: 'Recharger',

    // Persistent re-open link
    reopenLabel: 'Paramètres des cookies',

    // ARIA labels (accessibility — do not omit these for translated sites)
    'aria-banner': 'Bannière de consentement aux cookies',
    'aria-preferences-modal': 'Préférences de cookies',
    'aria-close': 'Fermer',
    'aria-accept-all': 'Accepter tous les cookies',
    'aria-reject-all': 'Refuser tous les cookies',
    'aria-manage-preferences': 'Gérer les préférences de cookies',
    'aria-save-preferences': 'Enregistrer les préférences de cookies',
    'aria-category-toggle': 'Activer/désactiver les cookies {label}',
  },
})

You only need to supply the keys you want to override. Omitted keys fall back to their English defaults. The '{label}' placeholder in aria-category-toggle is replaced with the category label at render time.

Full string table (English defaults)

Key Default value
bannerTitle We use cookies
bannerDescription This site uses cookies and similar technologies to improve your experience.
acceptAllLabel Accept all
rejectAllLabel Reject all
managePreferencesLabel Manage preferences
preferencesTitle Cookie preferences
savePreferencesLabel Save preferences
closeLabel Close
gpcNoticeText Your privacy preference (Global Privacy Control) was detected and applied.
withdrawalPromptText Your preferences have been saved. Scripts that already ran this session keep running until you reload the page.
reloadLabel Reload page
reopenLabel Cookie settings
aria-banner Cookie consent banner
aria-preferences-modal Cookie preferences
aria-close Close
aria-accept-all Accept all cookies
aria-reject-all Reject all cookies
aria-manage-preferences Manage cookie preferences
aria-save-preferences Save cookie preferences
aria-category-toggle Toggle {label} cookies

CLI scanner

The @cookyay/scanner package crawls your site with a headless Chromium browser, auto-detects ~50 known third-party services (Google Analytics, Meta Pixel, reCAPTCHA, Hotjar, Stripe, Sentry, and more), classifies cookies and requests against a curated signature database plus the Open Cookie Database, and emits a ready-to-use cookyay.config.json with copy-paste blocking snippets.

Scan-time vs run-time auto-detection. The scanner CLI produces suggested blocking snippets for you to paste into your HTML (declarative blocking). The Cookyay banner (v5+) also supports runtime auto-block via autoBlock: true — when enabled, known third-party scripts/iframes are intercepted automatically without any HTML changes. Both paths share the same ~50-service signature database, so scan-time and run-time classifications always agree.

Usage

# Scan a site and write cookyay.config.json
npx @cookyay/scanner scan https://yoursite.com --config-out cookyay.config.json

# Or install globally
npm install -g @cookyay/scanner
cookyay-scan scan https://yoursite.com --config-out cookyay.config.json

The scan subcommand is optional — npx @cookyay/scanner https://yoursite.com works the same way. Run npx @cookyay/scanner --help for all options.

First run: No separate browser-install step required. On a machine that has never run Playwright, the scanner automatically downloads the Chromium headless shell (~150 MB, one time) before the crawl starts — you will see Chromium not found — downloading (~150MB, one time)... in the terminal. Subsequent runs reuse the downloaded binary with no extra output. If the automatic download fails (offline, locked-down network, or insufficient disk space), the scanner surfaces a clear error and the manual fallback: npx playwright install chromium.

Auto-detection workflow: scan → review → paste

After scanning, open the emitted cookyay.config.json. The suggestedBlocking array lists every detected third-party host with a verbatim snippet to paste into your HTML:

{
  "policyVersion": "REPLACE_ME",
  "categories": {
    "analytics": {
      "label": "Analytics",
      "services": [
        {
          "name": "Google Analytics 4",
          "cookies": ["_ga", "_gid"],
          "_meta": { "confidence": "high", "matchedBy": "cookie", "serviceId": "ga4" }
        }
      ]
    }
  },
  "suggestedBlocking": [
    {
      "host": "googletagmanager.com",
      "services": ["ga4", "gtm"],
      "category": "analytics",
      "confidence": "high",
      "snippet": "<script type=\"text/plain\" data-category=\"analytics\" src=\"https://www.googletagmanager.com/gtag/js\"></script>"
    },
    {
      "host": "static.hotjar.com",
      "services": ["hotjar"],
      "category": "analytics",
      "confidence": "high",
      "snippet": "<script type=\"text/plain\" data-category=\"analytics\" src=\"https://static.hotjar.com/c/hotjar-\"></script>"
    }
  ],
  "_unclassified": [],
  "_noscriptWarnings": []
}

For each entry in suggestedBlocking, copy the snippet value verbatim into your HTML before the actual script or iframe loads. The banner holds those elements inert until the visitor consents to the declared category:

<!-- Declare BEFORE the real GTM snippet (paste from suggestedBlocking[].snippet) -->
<script type="text/plain" data-category="analytics" src="https://www.googletagmanager.com/gtag/js"></script>

<!-- Your actual GTM snippet follows below -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"></script>

When multiple services share a host (e.g. GA4 + Google Ads both fire via googletagmanager.com), they are merged into a single suggestedBlocking entry listing all service IDs — you only need one rule in your markup.

Confidence levels

Each detected service carries a confidence level. Use it to prioritise your review — act on high and medium first:

Level Meaning Example
high Two independent signals agree: a cookie name and a request host both matched the same service on the same page. _ga cookie observed and google-analytics.com request seen.
medium One unambiguous signal: a vendor-unique cookie name or a vendor-specific host, but not both independently confirming. _hjid cookie (Hotjar-specific) seen alone, or static.hotjar.com request without the matching cookie.
low Single generic or shared signal that could plausibly match multiple services. PREF cookie (shared across Google Search, YouTube, and Maps).

For low confidence entries, check the _meta.matchedBy field and the pages list to decide whether the detection is accurate before adding the blocking snippet to your HTML.

reCAPTCHA and form-gating trade-off

reCAPTCHA v2/v3 is classified as functional in the scanner's signature database. This means forms protected by reCAPTCHA are gated until the visitor consents to functional cookies.

This is the defensible default under a strict GDPR reading: reCAPTCHA is a third-party service (www.google.com/recaptcha/) that sets the _GRECAPTCHA session cookie and the NID cross-site identifier. It cannot be classified as necessary because alternative CAPTCHA solutions exist and the cross-site identifiers fall outside the "strictly necessary" exemption. Classifying it functional is consistent with Cookyay's strictest-everywhere posture.

The consent-before-submit UX is the site owner's to message — for example, a note near the form saying "Please accept functional cookies to use this form."

Reclassifying reCAPTCHA as necessary — If you accept the legal risk, you can override the emitted category by moving reCAPTCHA into the necessary bucket in your config. This is a conscious deviation from the default and should be reviewed with qualified legal counsel. Not legal advice.

Output format

The scanner emits a config JSON ready to pass to Cookyay.init(). Top-level fields:

Field Description
policyVersion Replace "REPLACE_ME" with an ISO date string (e.g. "2026-01-01"). Bump it whenever your cookie usage changes — this triggers re-consent for returning visitors.
categories Per-category service lists with confidence metadata. Pass directly to Cookyay.init().
suggestedBlocking Host-deduped blocking rules with paste-ready HTML snippets. See auto-detection workflow above.
_unclassified Artifacts detected but not matched to any known service. Review and assign to the correct category manually.
_noscriptWarnings <noscript> fallback tags detected. These bypass script blocking — remove them from your markup.
_scanMeta Scan metadata: timestamp, target URL, pages visited, classifier version.
Always review _unclassified Artifacts in _unclassified were detected but not recognised by the scanner. Review them manually and assign to the appropriate category before shipping. Never silently drop them.
Act on _noscriptWarnings Any <noscript> tag detected inside third-party embeds bypasses script blocking entirely. Remove these from your markup — there is no programmatic fix.

Set policyVersion

The scanner cannot know when your cookie usage changes. Replace the placeholder "REPLACE_ME" with an ISO date string (e.g. "2026-01-01") and bump it whenever you add or reclassify cookies.

Runtime auto-block (v5+)

autoBlock is a single boolean that lets the banner intercept and hold known third-party trackers inert at runtime, without you hand-declaring each one in your HTML.

Cookyay.init({
  policyVersion: '2026-01-01',
  autoBlock: true,   // opt-in; default false
  categories: { /* ... */ },
})
⚠ "Cookyay first in <head>" is a hard install requirement The bootstrap snippet installs the interception proxy synchronously. Any <script src> or pixel placed in the HTML before the bootstrap has already been fetched by the browser before the proxy exists — it cannot be blocked. This requirement already exists for Consent Mode v2 correctness; runtime auto-block adds a second equally hard reason to enforce it. If you load GTM or GA4 before the Cookyay bootstrap, autoBlock will not intercept those scripts.

Install-order diagnostic (v6): Set debug: true and the banner will warn you in the console whenever it detects a known tracker that loaded before the bootstrap. Example: [Cookyay] INSTALL ORDER WARNING: "Meta Pixel" (https://connect.facebook.net/en_US/fbevents.js) loaded before Cookyay bootstrap. Move Cookyay first in <head>.

What auto-block does

When autoBlock: true, the banner's proxy matches every outgoing <script>, <iframe>, <img> beacon pixel (v6), and — new in v7 — fetch and navigator.sendBeacon calls against a bundled database of curated third-party services. Matches are held until the visitor grants consent to the matching category, then released (scripts/iframes re-executed, pixels fired once, transport calls replayed/sent). Declared rules always take precedence — elements already carrying data-cookyay-state="blocked" are skipped by the auto-block path entirely.

When autoBlock: false (the default), the database tree-shakes to zero — no bundle cost for opt-out installs.

v6 — <img> beacon pixel coverage

v6 extends auto-block to known tracking-pixel endpoints. The proxy intercepts new Image() constructor calls and <img> src assignments and holds them inert until the matching category is consented. On grant, the suppressed pixel is fired once (a single network request to the tracker endpoint). Covered pixels include:

Pixel blocking is scoped strictly to curated host+path combinations from the signature database — a bare <img src="https://facebook.com/photo.jpg"> does not match because the path is not in the pixel endpoint list. First-party images and image CDNs are never touched.

v7 — fetch and navigator.sendBeacon transport coverage

v7 extends auto-block to the network transport layer. In addition to DOM interception, the lazy autoblock-loader chunk also wraps window.fetch and navigator.sendBeacon:

Unload-drop behavior (documented honestly): sendBeacon calls fired at page unload (pagehide / visibilitychange) are dropped, not deferred, when the user has not yet consented. There is no page session to replay into and no sessionStorage persistence in v7. This is the legally correct outcome — no consent, no send.

Phase-2 async escape window (bootstrap-first limit, v7): The fetch/sendBeacon wrappers live in the lazy autoblock-loader chunk, not the synchronous bootstrap snippet. A tracker that fires an async transport call in the short window between page load and the chunk finishing its load may escape interception. This is the same intrinsic bootstrap-first limit documented for DOM interception since v5 — extended honestly to the transport layer. The install-order diagnostic (set debug: true) can help surface this.

Honest limits of auto-block interception

autoBlock works by intercepting DOM API calls (document.createElement, Element.prototype.setAttribute, window.Image) and — from v7 — window.fetch and navigator.sendBeacon. The following remain outside its reach:

Google tags and Consent Mode v2

autoBlock does not DOM-block Google Tag Manager or GA4. Those services are intentionally passed through:

Non-Google trackers in the signature database (Hotjar, Meta Pixel loader, Stripe, Sentry, PostHog, Intercom, and others) are still auto-blocked.

Auto-block and declarative blocking together

The scanner's suggestedBlocking[] snippets and autoBlock: true are complementary, not mutually exclusive:

Approach HTML changes required? Covers GTM-injected tags? Notes
Declarative only (type="text/plain") Yes — paste scanner snippets No (GTM fires dynamically after page load) Most explicit; zero runtime overhead; works back to v1
autoBlock: true (v7) No Yes (intercepted at createElement) Scripts, iframes, <img> pixels, and (v7) fetch/sendBeacon to curated endpoints; Google tags excluded; XMLHttpRequest and document.write deferred; auto-block stays opt-in

For best coverage on sites that use GTM: paste declarative rules from the scanner for scripts in your static HTML, and enable autoBlock: true to also catch tags and pixels injected by GTM at runtime.

Debug mode — Enable debug: true to see auto-block decisions in the browser console: [Cookyay] auto-blocked <img> src="https://www.facebook.com/tr?…" (service: meta-pixel, category: marketing). Also surfaces install-order warnings when a known tracker loaded before the bootstrap. Turn off in production.

GTM integration

Cookyay's UI bundle fires gtag('consent','update',{...}) directly from page-level JavaScript — outside GTM's dataLayer queue — so it does not have the common "update too late" race condition that affects GTM Custom HTML tags.

For most GTM sites, no extra configuration is needed beyond placing the bootstrap snippet before the GTM container snippet in <head>:

<head>
  <!-- 1. Cookyay bootstrap — MUST be first -->
  <script>/* inline bootstrap here */</script>

  <!-- 2. GTM container snippet — after Cookyay bootstrap -->
  <script>(function(w,d,s,l,i){...})(window,document,'script','dataLayer','GTM-XXXX');</script>
</head>

Consent Mode v2 signal map

Banner category Consent Mode v2 signals
necessary functionality_storage + security_storage (always granted)
functional personalization_storage
analytics analytics_storage
marketing ad_storage + ad_user_data + ad_personalization

For advanced GTM scenarios, diagnostics, and the updateConsentState() Sandbox API workaround, see the full GTM integration guide.

GTM tag template A first-party GTM .tpl tag template using GTM's updateConsentState() Sandbox API is planned for v2. It eliminates the residual dataLayer-queue window for fully GTM-managed pages.

GPC behavior (Global Privacy Control)

Cookyay automatically detects the Global Privacy Control (GPC) signal (navigator.globalPrivacyControl === true) and treats it as an opt-out from functional, analytics, and marketing categories.

What happens when GPC is detected

  1. The bootstrap snippet reads navigator.globalPrivacyControl synchronously.
  2. Consent Mode v2 signals are fired with all non-necessary categories denied.
  3. The consent banner is suppressed — the visitor's browser has already expressed their preference.
  4. A small toast message is shown confirming the signal was honored: "Your privacy preference (Global Privacy Control) was detected and applied."
Why show a toast? As of January 1, 2026, California's CCPA regulations require explicit on-page confirmation when a GPC signal is honored. The toast satisfies this requirement. Override the message via the strings.gpcNoticeText config key.

GPC overrides stored consent

If a visitor previously accepted marketing cookies and later enables GPC in their browser, Cookyay detects the live GPC signal on each page load and overrides the stored consent. Stored consent does not override a live GPC signal — this is required for CCPA opt-out purposes.

Testing GPC locally

In Chromium-based browsers, set the flag in the DevTools console:

// In DevTools console — simulates GPC signal
Object.defineProperty(navigator, 'globalPrivacyControl', { value: true })
location.reload()

Or use the Global Privacy Control extension for Chrome/Edge.

Withdrawal & re-prompt

Withdrawal (revoking consent)

Visitors can change their consent choices at any time via the "Cookie settings" link auto-injected in the bottom-left corner, or any element with data-cookyay-open attribute, or via Cookyay.openPreferences().

When a visitor revokes a previously-granted category:

  1. The new consent record is written to the cookyay_consent cookie and localStorage.
  2. A cookyay:change event is dispatched on document.
  3. Your clearOnWithdraw callback is called with the list of revoked category IDs.
  4. A "reload required" prompt is shown: "Your preferences have been saved. Scripts that already ran this session keep running until you reload the page."
Why a reload prompt? Once a marketing or analytics script has executed (e.g. Meta Pixel has initialized), its in-memory state cannot be erased without a full page reload. The reload prompt is honest — it tells visitors their change will take effect after reload rather than silently failing to clean up. This matches GDPR Article 7(3)'s "as easy as giving consent" requirement while being truthful about technical constraints.

Clearing your own cookies on withdrawal

Use the clearOnWithdraw callback to expire first-party cookies:

Cookyay.init({
  policyVersion: '2025-01-01',
  clearOnWithdraw(revoked) {
    if (revoked.includes('analytics')) {
      // Expire GA4 cookies you set on your own domain
      document.cookie = '_ga=; Max-Age=0; Path=/'
      document.cookie = '_gid=; Max-Age=0; Path=/'
    }
  },
})
⚠ Third-party script state Third-party scripts (Google Analytics, Meta Pixel, etc.) write cookies and localStorage entries directly. Cookyay can clear first-party cookies via clearOnWithdraw but cannot clean up storage written by third-party code. A page reload is the only reliable remedy.

Policy-version re-prompt

When you add new cookie categories or materially change data processing, bump the policyVersion in your config:

Cookyay.init({ policyVersion: '2025-06-01' /* was '2025-01-01' */ })

On the next page load, visitors whose stored consent was recorded under a different policyVersion will see the banner again. Their previous choices are discarded and they must consent fresh. This is required by GDPR when purposes change materially.

Consent expiry re-prompt

Consent expires after 365 days by default (configurable via cookie.expiryDays). When the cookie expires the visitor will see the banner again on their next visit.

SSR cookie reading

Cookyay's consent record is stored in a SameSite=Lax cookie named cookyay_consent. This cookie is sent to your server on every request, which means you can read it server-side to conditionally render markup, skip injecting analytics scripts, or implement server-driven consent checks.

Cookie format

The cookie value is a URL-encoded JSON object with short keys for compactness:

{
  "sv": 1,              // schema version
  "t": 1735689600,      // consent timestamp (Unix epoch seconds)
  "pv": "2025-01-01",   // policy version
  "bv": "0.1.0",        // banner version
  "c": {                // per-category boolean choices
    "n": true,          // necessary
    "f": false,         // functional
    "a": true,          // analytics
    "m": false          // marketing
  },
  "gpc": false          // whether GPC was the source of this record
}

Reading in Node.js / server-rendered apps

// Express / any Node.js framework
function readCookyayConsent(cookieHeader) {
  const match = cookieHeader?.match(/(?:^|;\s*)cookyay_consent=([^;]+)/)
  if (!match) return null
  try {
    return JSON.parse(decodeURIComponent(match[1]))
  } catch {
    return null
  }
}

// Usage
const consent = readCookyayConsent(req.headers.cookie)
if (consent?.c?.a) {
  // visitor has consented to analytics — safe to inject GA4
}
// Next.js App Router
import { cookies } from 'next/headers'

const rawCookie = cookies().get('cookyay_consent')?.value
const consent = rawCookie ? JSON.parse(decodeURIComponent(rawCookie)) : null
const analyticsConsented = consent?.c?.a === true
Always validate the schema version Check sv === 1 before trusting the record. Future versions of Cookyay may bump sv; the library re-prompts automatically for unknown versions, but your server code should handle unknown versions gracefully (treat as no consent).

CLS prevention

Reading the cookie on the server and conditionally including/excluding analytics script tags prevents Cumulative Layout Shift (CLS) caused by scripts dynamically appending DOM elements after load. The bootstrap snippet handles this for the client-side path; SSR reading handles it for the initial HTML delivery.

Compliance limitations

Not legal advice This section describes what Cookyay does and does not do technically. Whether your specific deployment is legally compliant is a question for qualified legal counsel, not this library.

Client-side consent record — accountability limitation

Cookyay stores the consent record in the visitor's browser only (a cookyay_consent cookie + a mirrored localStorage entry). This is sufficient for the visitor's experience, but it has a critical limitation for controllers under GDPR:

For full GDPR Art. 7 accountability, forward consent events to your own backend. Client-side storage alone does not satisfy proof-of-consent obligations if you are audited. A regulator asking "show me the consent record for user X on date Y" cannot be answered from a browser-local record alone.

To build a server-side consent log, listen to the cookyay:consent custom event and POST the record to your backend:

document.addEventListener('cookyay:consent', (e) => {
  // e.detail fields: schemaVersion, policyVersion, timestamp, categories
  const { schemaVersion, policyVersion, timestamp, categories } = e.detail
  fetch('/api/consent-log', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ schemaVersion, policyVersion, timestamp, categories }),
  }).catch(() => {}) // best-effort; do not block UX on failure
})

A native webhook is planned for v2. In v1, the event detail schema is webhook-ready: it includes ISO-8601 timestamp, policy version, per-category choices, and the schema version for future-proofing.

What Cookyay does

What Cookyay does not do

"Do Not Sell or Share" link (CCPA)

CCPA requires a persistent "Do Not Sell or Share My Personal Information" link on every page. Cookyay's auto-injected "Cookie settings" link fulfills the withdrawal mechanism but is not the same as a CCPA-required "Do Not Sell" link. To satisfy CCPA, add an explicit link in your site footer and wire it to Cookyay.openPreferences():

<footer>
  <a href="#" onclick="Cookyay.openPreferences(); return false;">
    Do Not Sell or Share My Personal Information
  </a>
</footer>