A user visits example.com, accepts cookies, then clicks through to shop.example.com. The banner appears again. They accept. Next they visit brand-b.com, which your company also operates. Another banner. Three consent prompts for the same organization, three separate consent records, three opportunities for the user to make inconsistent choices that create contradictory audit trails.
This isn't a UX annoyance. It's a compliance problem. Contradictory consent records across your properties mean you can't reliably prove what the user actually consented to. And it's a data quality problem: if analytics consent is granted on one domain but denied on another, your cross-domain user journeys have holes in them.
Our conceptual guide to cross-domain consent sharing covers the legal conditions and architectural principles. This article is different. It's a technical implementation reference: four concrete patterns, their code, their failure modes, and where each one fits. If you're a developer tasked with making consent work across your organization's domains, this is the guide.
Same-Origin vs Cross-Origin: Know Your Scenario First
Before choosing a pattern, identify which scenario you're actually dealing with. The browser's security model draws a hard line between these two cases, and the implementation complexity is radically different.
Same eTLD+1 (Subdomains)
If your domains share a registrable domain (the eTLD+1), you're in the easy case. www.example.com, shop.example.com, app.example.com, and docs.example.com all share the eTLD+1 example.com. A cookie set with Domain=.example.com is readable by all of them. No bridges, no APIs, no tokens needed.
Cross-Origin (Different eTLD+1s)
If your domains have different registrable domains — example.com and example-brand.com — they can't read each other's cookies at all. The same-origin policy blocks it, and no amount of header configuration changes that. Third-party cookies used to offer a workaround (set a cookie on a shared domain, read it from both), but browsers have either killed or severely restricted third-party cookie access. In 2026, cross-origin consent sharing requires an explicit handoff mechanism.
There's a middle case worth noting: example.co.uk and example.com look related but are different eTLD+1s. The .co.uk suffix is on the public suffix list, so you can't set a cookie on .co.uk and have it span both. Treat this as cross-origin.
Pattern 1: First-Party Cookie on Parent Domain
When to use: all your properties are subdomains of the same eTLD+1.
Complexity: low. This is the pattern you should use whenever your domain structure allows it.
The implementation is straightforward. When the user consents on any subdomain, set the consent cookie with the Domain attribute pointing to the parent domain:
// Set consent cookie readable by all subdomains
function setConsentCookie(consent) {
const value = JSON.stringify({
necessary: true,
analytics: consent.analytics,
marketing: consent.marketing,
preferences: consent.preferences,
timestamp: Date.now(),
source: window.location.hostname
});
document.cookie = [
`cb_consent=${encodeURIComponent(value)}`,
`Domain=.example.com`, // note the leading dot
`Path=/`,
`Max-Age=${365 * 24 * 60 * 60}`,
`SameSite=Lax`,
`Secure`
].join('; ');
}
// Read it on any subdomain — same API, no special handling
function readConsentCookie() {
const match = document.cookie.match(/cb_consent=([^;]+)/);
if (!match) return null;
try {
return JSON.parse(decodeURIComponent(match[1]));
} catch {
return null;
}
}Key details that trip people up:
- The
Domainattribute must be the registrable domain, not a subdomain. SettingDomain=shop.example.commakes the cookie visible only toshop.example.comand its sub-subdomains — not towww.example.com. - Don't omit
Domainentirely. A cookie without aDomainattribute is scoped to the exact hostname that set it (no subdomain sharing). This is the default behavior and a common source of "it works on the main site but not on subdomains" bugs. SameSite=Laxis fine here. Same-site context includes all subdomains of the same eTLD+1, soLaxdoesn't block cross-subdomain reads. Don't useSameSite=Noneunless you have a specific reason (it requiresSecureand exposes the cookie to third-party contexts you probably don't want).- Withdrawals propagate automatically. Since all subdomains read and write the same cookie, a user who revokes consent on
shop.example.comimmediately has that revocation visible onapp.example.com— no sync logic needed.
Pattern 2: localStorage + postMessage Bridge
When to use: your domains have different eTLD+1s and you need real-time, client-side consent sync without server roundtrips.
Complexity: medium. Requires a hosted "consent hub" page and careful origin validation.
The core idea: host a small HTML page on a dedicated consent domain (say consent.yourcompany.com). Each of your domains embeds this page as a hidden iframe. The iframe stores consent in its own localStorage (which is origin-scoped to the consent domain) and communicates with the parent page via postMessage. Because every domain embeds the same iframe origin, they all read from and write to the same localStorage.
The Consent Hub (hosted at consent.yourcompany.com/bridge.html)
<!DOCTYPE html>
<html>
<head><title>Consent Bridge</title></head>
<body>
<script>
// Strict allowlist of domains that may use this bridge
const ALLOWED_ORIGINS = [
'https://example.com',
'https://www.example.com',
'https://brand-b.com',
'https://www.brand-b.com'
];
window.addEventListener('message', (event) => {
// CRITICAL: validate origin against allowlist
if (!ALLOWED_ORIGINS.includes(event.origin)) return;
const { action, consent } = event.data;
if (action === 'GET_CONSENT') {
const stored = localStorage.getItem('cb_consent');
event.source.postMessage(
{ action: 'CONSENT_STATE', consent: stored ? JSON.parse(stored) : null },
event.origin // reply only to the requesting origin
);
}
if (action === 'SET_CONSENT' && consent) {
localStorage.setItem('cb_consent', JSON.stringify({
...consent,
updatedAt: Date.now(),
source: event.origin
}));
event.source.postMessage(
{ action: 'CONSENT_SAVED' },
event.origin
);
}
});
</script>
</body>
</html>The Client (runs on each domain)
class ConsentBridge {
constructor(hubUrl) {
this.hubUrl = hubUrl;
this.iframe = null;
this.ready = false;
this.pending = [];
}
init() {
return new Promise((resolve) => {
this.iframe = document.createElement('iframe');
this.iframe.src = this.hubUrl;
this.iframe.style.display = 'none';
this.iframe.setAttribute('aria-hidden', 'true');
document.body.appendChild(this.iframe);
this.iframe.onload = () => {
this.ready = true;
this.pending.forEach(fn => fn());
this.pending = [];
resolve();
};
});
}
send(data) {
const doSend = () =>
this.iframe.contentWindow.postMessage(data, this.hubUrl);
if (this.ready) doSend();
else this.pending.push(doSend);
}
getConsent() {
return new Promise((resolve) => {
const handler = (event) => {
if (event.origin !== new URL(this.hubUrl).origin) return;
if (event.data.action !== 'CONSENT_STATE') return;
window.removeEventListener('message', handler);
resolve(event.data.consent);
};
window.addEventListener('message', handler);
this.send({ action: 'GET_CONSENT' });
// Timeout: if the bridge doesn't respond, treat as no consent
setTimeout(() => {
window.removeEventListener('message', handler);
resolve(null);
}, 2000);
});
}
setConsent(consent) {
this.send({ action: 'SET_CONSENT', consent });
}
}
// Usage
const bridge = new ConsentBridge('https://consent.yourcompany.com/bridge.html');
await bridge.init();
const existing = await bridge.getConsent();
if (existing) {
// Apply stored consent — skip the banner
applyCookieConsent(existing);
} else {
// Show the banner, then save the decision
showBanner((consent) => bridge.setConsent(consent));
}Security Requirements (Non-Negotiable)
- Origin validation in the hub. The
ALLOWED_ORIGINScheck is not optional. Without it, any site can embed your consent hub iframe and read or write consent state — effectively injecting consent on behalf of the user. This is a consent injection vulnerability. - No wildcard origins. Don't use
event.source.postMessage(data, '*'). Always reply to the specific origin. - CSP frame-ancestors. Add a
Content-Security-Policy: frame-ancestors https://example.com https://brand-b.comheader to the hub page. This prevents unauthorized sites from embedding the iframe even if they bypass the JS check. - Storage partitioning. Modern browsers (Safari, Firefox) partition third-party iframe storage by the embedding site's origin. This means
consent.yourcompany.com's localStorage when embedded onexample.comis separate from when it's embedded onbrand-b.com. This pattern may require the Storage Access API to work in those browsers, which requires user interaction — significantly complicating the flow.
Pattern 3: Server-Side Consent Synchronization via API
When to use: you need reliable consent sync across different eTLD+1s and your domains have a shared backend or can call a central API.
Complexity: medium-high. Requires a backend consent service, user identification, and API integration on each domain.
This is the most robust pattern for organizations with server-side infrastructure. Instead of fighting browser storage restrictions, store the canonical consent state server-side and have each domain query it.
The Identity Problem
Server-side sync requires answering a hard question: how do you identify the same visitor across different domains? Post-third-party-cookie, you can't silently match a user. Every handoff must be explicit. The main options:
- Authenticated users. If the user is logged in on both domains (SSO, shared auth), use their user ID. Cleanest approach; works for returning visitors without additional handoff steps.
- Consent token via redirect. When the user navigates from domain A to domain B, append a short-lived token (see Pattern 4). Domain B exchanges the token with the consent API to retrieve consent state.
- Per-domain visitor ID + API mapping. Set a CMP-specific visitor ID cookie on each domain. The consent API maps multiple domain-specific IDs to a single consent record, established during the initial handoff.
API Endpoints
// Express.js example
const express = require('express');
const app = express();
// Retrieve consent
app.get('/api/consent/:visitorId', async (req, res) => {
const { visitorId } = req.params;
const origin = req.get('Origin');
// Validate requesting origin
if (!ALLOWED_ORIGINS.includes(origin)) {
return res.status(403).json({ error: 'Origin not allowed' });
}
const record = await db.query(
'SELECT purposes, updated_at, source_domain FROM consent_records WHERE visitor_id = $1',
[visitorId]
);
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
res.json(record.rows[0] || null);
});
// Store or update consent
app.post('/api/consent', async (req, res) => {
const { visitorId, purposes, sourceDomain } = req.body;
const origin = req.get('Origin');
if (!ALLOWED_ORIGINS.includes(origin)) {
return res.status(403).json({ error: 'Origin not allowed' });
}
await db.query(
`INSERT INTO consent_records (visitor_id, purposes, source_domain, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (visitor_id)
DO UPDATE SET purposes = $2, source_domain = $3, updated_at = NOW()`,
[visitorId, JSON.stringify(purposes), sourceDomain]
);
res.set('Access-Control-Allow-Origin', origin);
res.json({ status: 'saved' });
});The main advantage: withdrawal propagation is immediate. When a user revokes consent on any domain, the update is visible everywhere on the next check. The main disadvantage: latency on first load. Cache consent in a first-party cookie after the initial lookup to avoid the API call on subsequent visits.
Pattern 4: URL Parameter Passing (Consent Token in Redirect)
When to use: you control the navigation between domains (e.g., links, checkout redirects) and need a lightweight handoff without shared infrastructure.
Complexity: low-medium, but easy to get wrong from a security perspective.
When a user navigates from one of your domains to another, append a signed, short-lived token to the URL. The receiving domain validates the token and applies the consent state without showing the banner.
Token Generation (Sending Domain)
import crypto from 'crypto';
const SHARED_SECRET = process.env.CONSENT_TOKEN_SECRET;
const TOKEN_TTL_SECONDS = 120; // 2-minute window
function generateConsentToken(consent) {
const payload = {
c: consent, // consent state
t: Math.floor(Date.now() / 1000), // issued-at
n: crypto.randomBytes(8).toString('hex') // nonce
};
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = crypto
.createHmac('sha256', SHARED_SECRET)
.update(data)
.digest('base64url');
return `${data}.${signature}`;
}
// Append to outgoing links
const token = generateConsentToken(currentConsent);
const targetUrl = `https://brand-b.com/page?_consent=${token}`;Token Validation (Receiving Domain)
function validateConsentToken(token) {
const [data, signature] = token.split('.');
if (!data || !signature) return null;
// Verify signature
const expected = crypto
.createHmac('sha256', SHARED_SECRET)
.update(data)
.digest('base64url');
if (!crypto.timingSafeEqual(
Buffer.from(signature), Buffer.from(expected)
)) {
return null; // tampered
}
// Decode and check expiry
const payload = JSON.parse(
Buffer.from(data, 'base64url').toString()
);
const age = Math.floor(Date.now() / 1000) - payload.t;
if (age > TOKEN_TTL_SECONDS || age < 0) {
return null; // expired or clock-skewed
}
return payload.c; // the consent state
}
// On page load, check for consent token in URL
const params = new URLSearchParams(window.location.search);
const consentToken = params.get('_consent');
if (consentToken) {
// Validate server-side or via an edge function
const consent = await fetch('/api/validate-consent-token', {
method: 'POST',
body: JSON.stringify({ token: consentToken })
}).then(r => r.json());
if (consent) {
applyCookieConsent(consent);
// Clean the URL (remove the token)
const url = new URL(window.location);
url.searchParams.delete('_consent');
history.replaceState(null, '', url.toString());
}
}Security Requirements
- HMAC signature. Without cryptographic signing, anyone can forge a consent token. A user (or attacker) could craft a URL with
_consent=all_grantedand bypass the banner entirely. - Short TTL. Two minutes is generous. The token should only survive the redirect. An expired token means the user gets the banner normally — safe default.
- Nonce. Prevents replay attacks. A consumed nonce should be rejected on subsequent use. For lightweight implementations, the short TTL is usually sufficient; for high-security contexts, track consumed nonces in a short-lived cache.
- URL cleanup. Remove the
_consentparameter from the URL after processing. If the user bookmarks or shares the URL, you don't want stale consent tokens in the link. - Server-side validation. Never validate the token client-side. The shared secret can't be in the browser.
Privacy and Legal Implications
Cross-domain consent sharing isn't just a technical decision. It carries specific GDPR and ePrivacy implications that constrain which patterns you can use and how.
Controller Alignment
Consent under GDPR is given to a specific data controller for specific purposes. If two domains are operated by the same legal entity, sharing consent between them is defensible. If different entities operate the domains, consent given to Entity A doesn't transfer to Entity B, even if they're commercially linked — each entity must collect its own.
Consent vs Legitimate Interest for the Propagation Mechanism
A common question: can you use legitimate interest as the legal basis for the consent sharing mechanism itself? The distinction matters. The original consent ("do you accept analytics cookies?") must be freely given, specific, and informed — that's Article 7 GDPR. But the internal propagation of that decision across your own properties — the cookie, the API call, the token — could arguably fall under legitimate interest (you have a legitimate interest in not asking the same user the same question repeatedly).
However, this argument doesn't stretch as far as some implementations assume. The propagation mechanism must not become a tracking mechanism. A consent token that doubles as a cross-site user identifier goes beyond "propagating a decision" and into "cross-site tracking" — which is exactly what consent rules are designed to govern. Keep the propagation minimal: transmit the consent state, not user identity or browsing behavior.
Transparency Requirements
Regardless of the pattern you choose, the user must know their decision will apply across your domains. Disclose the linked properties in your cookie banner or privacy policy. Something like: "Your cookie preferences apply across [domain list], all operated by [Company Name]." Without this disclosure, the consent may not be considered "informed" — which is one of GDPR's validity requirements.
Cross-Domain Consent and Google Consent Mode v2
Consent Mode v2 adds a layer of complexity to cross-domain sharing that catches most implementations off guard. The critical thing to understand: Consent Mode signals do not propagate across domains. Each domain must fire its own gtag('consent', 'default', ...) and gtag('consent', 'update', ...) calls independently.
Here's why. Google's consent signals are per-page-load state. They live in the dataLayer / gtag command queue on the current page. When a user navigates from example.com to brand-b.com, the browser loads a completely new page with a new JavaScript context. The consent default and update calls from example.com don't exist on brand-b.com. Even if you've shared the consent decision via a cookie, token, or API, you still need to translate that decision into Consent Mode signals on the receiving domain.
The implementation pattern:
// On every domain, in an inline <script> in <head>
// Step 1: Always set denied defaults (before GTM loads)
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
});
// Step 2: Check for shared consent (cookie, API, or token)
const consent = readSharedConsent(); // your cross-domain mechanism
if (consent) {
// Step 3: Immediately update if consent exists
gtag('consent', 'update', {
'ad_storage': consent.marketing ? 'granted' : 'denied',
'analytics_storage': consent.analytics ? 'granted' : 'denied',
'ad_user_data': consent.marketing ? 'granted' : 'denied',
'ad_personalization': consent.marketing ? 'granted' : 'denied'
});
}The wait_for_update: 500 parameter tells Google tags to wait 500ms for an update call before firing with the default (denied) values. This is essential for cross-domain scenarios where the consent lookup (API call or token validation) takes time. Without it, tags fire in denied mode and behavioral modeling kicks in unnecessarily — even though you already have consent.
All four parameters — ad_storage, analytics_storage, ad_user_data, ad_personalization — must be present in both the default and update calls. Missing ad_user_data or ad_personalization means you're effectively running Consent Mode v1 semantics. For a deeper look at why setups fail, see our Consent Mode v2: Advanced vs Basic guide.
Iframe Consent Propagation
Cross-domain consent isn't only about your own domains. It also applies to third-party content embedded on your site: chatbots, video players, social widgets, maps, and payment forms. These are cross-origin iframes, and they set their own cookies independently of your consent decision.
The Problem
When you embed a YouTube video, a Zendesk chat widget, or a Google Maps iframe, the embedded content runs in its own origin. Your consent cookie on example.com is invisible to the iframe at youtube.com. If the user denied marketing cookies on your site, the YouTube iframe has no way to know that — and it will set its own cookies according to YouTube's defaults.
Placeholder Pattern
The standard solution is to not load the iframe until consent is granted. Show a placeholder instead:
function renderEmbed(container, embedUrl, category) {
const consent = readConsent();
if (!consent[category]) {
// Show placeholder with consent prompt
container.innerHTML = `
<div class="consent-placeholder">
<p>This content is hosted by a third party.
Loading it may set cookies on your device.</p>
<button onclick="grantAndLoad('${category}', '${embedUrl}', this)">
Accept and load content
</button>
</div>
`;
return;
}
// Consent granted — load the iframe
const iframe = document.createElement('iframe');
iframe.src = embedUrl;
iframe.setAttribute('loading', 'lazy');
container.appendChild(iframe);
}
function grantAndLoad(category, url, button) {
updateConsent({ [category]: true });
const container = button.closest('.consent-placeholder').parentNode;
container.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.src = url;
container.appendChild(iframe);
}Privacy-Enhanced Embeds
Some third-party services offer privacy-enhanced modes that reduce or eliminate cookies. YouTube's youtube-nocookie.com domain, for instance, claims to set no cookies until the user clicks play. Google Maps has a similar embed mode. When available, use these for the "no consent" state and switch to the full embed on consent. This gives users a functional experience (they can watch the video) without requiring blanket marketing consent.
Communication via postMessage
If you control both the parent page and the embedded iframe (e.g., you embed your own widget on a client's site), you can propagate consent via postMessage:
// Parent page: send consent to your embedded widget
const widget = document.getElementById('my-widget-iframe');
widget.contentWindow.postMessage(
{ type: 'CONSENT_UPDATE', consent: currentConsent },
'https://widget.yourcompany.com'
);
// Inside the iframe (widget.yourcompany.com):
window.addEventListener('message', (event) => {
if (event.origin !== 'https://client-site.com') return;
if (event.data.type === 'CONSENT_UPDATE') {
applyConsent(event.data.consent);
}
});This is the pattern most chatbot and widget providers should be implementing — and most aren't. If you provide embeddable widgets, accept consent state from the host page rather than running your own independent consent collection inside the iframe.
How CookieBeam Handles Cross-Domain Consent
CookieBeam's cross-domain consent implementation combines Pattern 1 (parent-domain cookie) and Pattern 3 (server-side sync) depending on the domain structure, managed through its domain groups feature.
Domain Groups
In the CookieBeam dashboard, each banner has a domain group: the set of domains that share a single consent configuration. When you create a banner for example.com, CookieBeam automatically adds both example.com and www.example.com to its domain group. You can add additional subdomains (shop.example.com, app.example.com) to the same group.
For subdomains of the same eTLD+1, CookieBeam uses the parent-domain cookie approach. The consent cookie's Domain attribute is configurable in Settings > General > Consent Cookie. Setting it to .example.com makes the consent cookie readable across all subdomains in the group. This is the simplest configuration and requires no additional infrastructure.
Cookie Domain Override
The cookie domain setting lets you explicitly control the scope of the consent cookie. By default, CookieBeam sets the cookie on the exact hostname. To enable subdomain sharing, override it to the parent domain with a leading dot. This is a single setting change — no code modifications, no bridge pages, no API integration.
Consent Validation Across Domains
CookieBeam validates that consent API requests originate from domains in the banner's domain group. A consent log submitted from shop.example.com is accepted only if that domain is in the banner's domain group. This prevents rogue domains from injecting consent records into your audit trail — the same origin-validation discipline that the manual postMessage pattern requires, but handled automatically.
For organizations that need consent sharing across different eTLD+1s, the crossDomainConsent feature (available on Business and Enterprise plans) handles the server-side synchronization. The consent state is stored centrally and queried by the CookieBeam script on each domain during initialization, with the consent cookie acting as a local cache to avoid API latency on subsequent visits.
Each domain still fires its own Consent Mode v2 signals independently — CookieBeam's loader script handles the default/update sequence on every page load, reading from either the shared cookie or the API response to determine the current consent state. For a broader look at regional consent adaptation across domains, see our regional consent guide.
Choosing the Right Pattern
Here's the decision matrix:
| Scenario | Pattern | Complexity | Withdrawal Sync |
|---|---|---|---|
| All subdomains of one domain | Parent-domain cookie | Low | Automatic |
| Different eTLD+1s, no server infra | postMessage bridge | Medium | Near-real-time |
| Different eTLD+1s, shared backend | Server-side API | Medium-high | Immediate |
| Navigation between domains you control | URL token (+ any above) | Low-medium | One-way per redirect |
| Logged-in users across domains | Server-side API with SSO | Medium | Immediate |
In practice, most organizations use a combination. Pattern 1 for subdomains, Pattern 4 for the initial handoff between different eTLD+1s, and Pattern 3 as the canonical store. Pattern 2 works but is increasingly fragile due to browser storage partitioning — test it thoroughly in Safari and Firefox before committing.
Whatever you choose, three things are non-negotiable: disclosure to the user that consent applies across domains, withdrawal propagation that works in both directions, and per-domain Consent Mode firing. Get those right and the pattern choice is an implementation detail. Get them wrong and the pattern doesn't matter — you have a compliance gap.