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.
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.
Cookyay uses a two-part install. Both parts are required and their order is critical — getting this wrong is the #1 breakage point.
<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.
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>
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>
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>
<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.
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'
)
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
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>
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-/'
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.
<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>
<iframe>)
Replace src with data-src and add data-category.
Cookyay swaps data-src → src
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-nocookie.com still writes localStorage and sends data to Google. Full
iframe blocking via data-src swap is required for GDPR compliance.
| 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 |
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).
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. |
{
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
}>
}
// 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>
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()
})
Cookyay emits console.warn (not console.error) for common
mistakes so they're visible during development without breaking CI:
policyVersion: [cookyay] policyVersion is required…
[cookyay] categories["xyz"] is not a known category…
[cookyay] categories["analytics"] has no declared services…
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.
| 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 |
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.
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.
# 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.
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.
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 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."
necessary bucket in your config. This is a conscious deviation from the
default and should be reviewed with qualified legal counsel. Not legal advice.
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. |
_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.
_noscriptWarnings
Any <noscript> tag detected inside third-party embeds bypasses script
blocking entirely. Remove these from your markup — there is no programmatic fix.
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.
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: { /* ... */ },
})
<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. 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>.
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.
<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:
facebook.com/trpx.ads.linkedin.com/collectct.pinterest.com/v3/tr.snapchat.com/panalytics.tiktok.com/i18n/pixel/alb.reddit.com/rp.gif
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.
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:
fetch to a curated tracking endpoint — the caller's
Promise<Response> resolves immediately with a benign
204 No Content stub (preventing hangs and timeouts in calling code). A
clone of the original request is queued and replayed via the saved original
fetch reference when consent is granted within the same page session.
navigator.sendBeacon to a curated endpoint — the call
returns true synchronously (preserving caller retry expectations) and the
payload is queued for same-session delivery on grant.
fetch/sendBeacon URL that is not in the
curated signature database is forwarded synchronously to the original function without
any staging.
region1.google-analytics.com/g/collect is never network-blocked.
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.
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:
fetch and sendBeacon calls are intercepted only when the
destination URL matches a curated tracking endpoint in the signature database. Your own
API traffic is never touched.
XMLHttpRequest interception — Older transport; deferred to
a later version. No curated tracker in the v7 DB is XHR-only; declare XHR-based scripts
manually if needed.
srcset attribute — The override filters on
src only; srcset-based requests are not intercepted. No known
major tracker uses srcset for pixel firing.
innerHTML-injected <img src>
— HTML injected via innerHTML or insertAdjacentHTML bypasses
the createElement wrapper. In practice, any script that injects pixels this
way is itself blocked first by the script-level proxy — so the pixel injection never
runs.
debug: true to surface these in the console.
document.write ad injection — Legacy DoubleClick / old
AdSense. Deferred (high page-rendering breakage risk).
autoBlock flag defaults to
false. There is no default-on mode in v7 — enabling it is an explicit site
owner decision.
autoBlock does not DOM-block Google Tag Manager or GA4.
Those services are intentionally passed through:
gtag('consent','default',{all denied}) synchronously before any Google tag
loads, so GTM/GA4 degrade gracefully — they load but collect no data until the visitor
grants consent.
gtag('consent','update','granted') signals, because GTM would never have
loaded to receive them.
Non-Google trackers in the signature database (Hotjar, Meta Pixel loader, Stripe, Sentry, PostHog, Intercom, and others) are still auto-blocked.
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: 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.
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>
| 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.
.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.
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.
navigator.globalPrivacyControl synchronously.
strings.gpcNoticeText config key.
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.
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.
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:
cookyay_consent cookie and
localStorage.
cookyay:change event is dispatched on document.clearOnWithdraw callback is called with the list of revoked category
IDs.
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=/'
}
},
})
clearOnWithdraw but cannot clean up storage written by third-party code. A
page reload is the only reliable remedy.
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 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.
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.
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
}
// 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
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).
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.
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:
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.
strings config.
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>