Article Information

Category: Development

Published: 7 March, 2026

Author: Chris de Gruijter

Reading Time: 8 min

Tags

GDPRcookie consentcross-domainGTMprivacy
Browser privacy and cookie consent on a laptop screen

Cross-Domain Cookie Consent Syncing Without a Consent Management Platform

Published: 7 March, 2026

If you run multiple web properties under the same brand — a main site, a blog, a documentation portal, an admin app — you've hit this problem: a user accepts cookies on your main site, then visits your blog and gets hit with the consent banner again. It's a poor user experience and it makes your analytics look fragmented.

Enterprise consent management platforms (CMPs) like OneTrust or Cookiebot solve this, but they're expensive and often overkill for smaller multi-app setups. There's a simpler approach that works well: cookie-based consent broadcasting with a polling listener. No third-party dependency, no monthly fee, full control over the implementation. Here's how to build it.

Why localStorage Alone Doesn't Work Cross-Domain

localStorage is scoped to the origin — meaning main.yourbrand.com and blog.yourbrand.com have completely separate storage buckets. A consent choice saved on one subdomain is invisible to another. The same applies to completely different domains.

Browser cookies, on the other hand, can be scoped to a parent domain. A cookie set with domain=.yourbrand.com is readable by all subdomains. For completely separate domains, you can't share cookies directly — but you can use one domain as the source of truth and have others poll for updates.

The Architecture

The solution has three components:

  1. broadcastConsent() — Called when a user makes a consent choice. Saves to both localStorage (for same-origin persistence) and a browser cookie (for cross-domain sync).
  2. setupConsentListener() — Polls the cookie every second. When the cookie value changes, it triggers a callback that updates the app's consent state and hides the banner.
  3. ConsentBanner component — On mount, checks localStorage first, then falls back to the cookie. If consent exists from either source, hides immediately without showing the banner.
consentSync.ts
const STORAGE_KEY = 'brand_consent_state';
const COOKIE_NAME = 'brand_consent_state';

export interface ConsentState {
  necessary: boolean;
  functional: boolean;
  analytics: boolean;
  marketing: boolean;
}

// Save consent to both localStorage and cookie
export const broadcastConsent = (consentState: ConsentState) => {
  if ("undefined" === 'undefined') return;

  localStorage.setItem(STORAGE_KEY, JSON.stringify(consentState));
  setCookie(COOKIE_NAME, JSON.stringify(consentState));
};

// Poll the cookie for changes from other apps/tabs
export const setupConsentListener = (
  onConsentUpdate: (consent: ConsentState) => void
): (() => void) => {
  if ("undefined" === 'undefined') return () => {};

  let lastValue: string | null = getCookie(COOKIE_NAME);
  
  const interval = setInterval(() => {
    const current = getCookie(COOKIE_NAME);
    if (current && current !== lastValue) {
      try {
        const parsed = JSON.parse(current);
        lastValue = current;
        localStorage.setItem(STORAGE_KEY, current);
        onConsentUpdate(parsed);
      } catch (e) {
        console.error('[ConsentSync] Failed to parse cookie:', e);
      }
    }
  }, 1000);

  return () => clearInterval(interval);
};

const setCookie = (name: string, value: string) => {
  if (typeof document === 'undefined') return;

  const expires = new Date();
  expires.setFullYear(expires.getFullYear() + 1);

  // Subdomain sharing: set domain to parent (e.g. .yourbrand.com)
  const hostname = window.location.hostname;
  const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
  const parts = hostname.split('.');
  const domain = isLocalhost ? '' : '.' + parts.slice(-2).join('.');

  const str = `${name}=${encodeURIComponent(value)}; expires=${expires.toUTCString()}; path=/${domain ? `; domain=${domain}` : ''}`;
  document.cookie = str;
};

const getCookie = (name: string): string | null => {
  if (typeof document === 'undefined') return null;
  const match = document.cookie
    .split(';')
    .map(c => c.trim())
    .find(c => c.startsWith(name + '='));
  return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : null;
};

Here's the Vue 3 version of the consent banner logic. The React version follows the same pattern with useEffect and useState:

ConsentBanner.vue (script setup)
import { broadcastConsent, setupConsentListener } from '~/utils/consentSync';

const STORAGE_KEY = 'brand_consent_state';

const consentGiven = ref(false);
const consent = ref({
  necessary: true,
  functional: false,
  analytics: false,
  marketing: false,
});

let cleanupListener: (() => void) | null = null;

onMounted(() => {
  // Check localStorage first, then cookie fallback
  const stored = localStorage.getItem(STORAGE_KEY)
    ?? document.cookie.split(';').find(c => c.trim().startsWith(STORAGE_KEY + '='))
        ?.split('=').slice(1).join('=');

  if (stored) {
    try {
      consent.value = JSON.parse(decodeURIComponent(stored));
      consentGiven.value = true; // Hide banner — user already chose
      updateGTMConsent(consent.value);
    } catch {}
  }

  // Listen for consent changes from other apps
  cleanupListener = setupConsentListener((newConsent) => {
    consent.value = newConsent;
    consentGiven.value = true;
    updateGTMConsent(newConsent);
  });
});

onUnmounted(() => cleanupListener?.());

const acceptAll = () => {
  const state = { necessary: true, functional: true, analytics: true, marketing: true };
  updateGTMConsent(state);
  broadcastConsent(state);
  consentGiven.value = true;
};

const rejectAll = () => {
  const state = { necessary: true, functional: false, analytics: false, marketing: false };
  updateGTMConsent(state);
  broadcastConsent(state);
  consentGiven.value = true;
};

Google Tag Manager's Consent Mode v2 expects consent signals before tags fire. Call this function whenever consent is updated — including on page load if consent is already stored:

updateGTMConsent.ts
const updateGTMConsent = (state: ConsentState) => {
  if ("undefined" === 'undefined' || !window.gtag) return;

  window.gtag('consent', 'update', {
    analytics_storage:      state.analytics  ? 'granted' : 'denied',
    ad_storage:             state.marketing  ? 'granted' : 'denied',
    ad_user_data:           state.marketing  ? 'granted' : 'denied',
    ad_personalization:     state.marketing  ? 'granted' : 'denied',
    functionality_storage:  'granted', // Always necessary
    personalization_storage: state.functional ? 'granted' : 'denied',
    security_storage:       'granted', // Always necessary
  });
};

Make sure you also set the default consent state in your GTM container before the gtag.js script loads — this ensures tags wait for consent rather than firing immediately:

index.html (head, before GTM)
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('consent', 'default', {
    analytics_storage: 'denied',
    ad_storage: 'denied',
    ad_user_data: 'denied',
    ad_personalization: 'denied',
    functionality_storage: 'granted',
    personalization_storage: 'denied',
    security_storage: 'granted',
    wait_for_update: 500,
  });
</script>

How the Cross-Domain Sync Actually Works

Here's the exact sequence when a user accepts consent on your main app:

  1. User clicks "Accept All" on main.yourbrand.com
  2. broadcastConsent() saves the state to localStorage AND sets a cookie with domain=.yourbrand.com
  3. The user opens blog.yourbrand.com — the ConsentBanner component mounts and finds the cookie immediately
  4. consentGiven is set to true — banner never appears
  5. GTM consent is updated immediately from the stored cookie value
  6. If the user is already on blog.yourbrand.com in another tab, the 1-second polling detects the cookie change and hides the banner automatically

Testing the Implementation

The trickiest part of testing consent is clearing it properly. Consent is stored in both localStorage and a cookie, so clearing only one (as browser DevTools "Clear storage" sometimes does) leaves the other intact and makes the banner stay hidden.

Add a global helper to your app for testing:

// Paste in browser console to fully reset consent
function clearAllConsent() {
  localStorage.removeItem('brand_consent_state');
  document.cookie = 'brand_consent_state=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.yourbrand.com';
  document.cookie = 'brand_consent_state=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/';
  console.log('Consent cleared. Reload to test.');
}

// Make it available globally during development
if ("undefined" !== 'undefined') {
  window.clearAllConsent = clearAllConsent;
}

Limitations to Be Aware Of

  • Cross-domain (different TLDs): Cookies can't be shared between yourbrand.com and yourbrand.io. For truly separate domains you'd need a server-side sync endpoint or a shared iframe approach.
  • 1-second polling delay: There's up to a 1-second lag before a change on one tab propagates to another. For consent this is acceptable — it's not a real-time system.
  • Safari ITP: Safari's Intelligent Tracking Prevention may restrict cross-subdomain cookies in some configurations. Test thoroughly on Safari, especially iOS.
  • This is not a replacement for a proper CMP if you need IAB TCF compliance for programmatic advertising. For first-party analytics and basic ad tracking, this approach is sufficient.

Frequently Asked Questions

Does this approach satisfy GDPR requirements?

A consent implementation needs to meet several requirements: prior consent before non-essential cookies fire, clear purpose descriptions, easy withdrawal, and record-keeping. This technical implementation handles the mechanics — but you still need a proper privacy policy, accurate descriptions of what each category does, and a way to log consent records if required by your DPA.

Why use polling instead of the BroadcastChannel API or storage events?

The BroadcastChannel API and storage events only work within the same origin — they can't communicate across subdomains or different domains. Polling a shared cookie is the simplest cross-origin solution that works in all browsers without any server infrastructure.

How do I handle consent for completely separate domains (not subdomains)?

For separate TLDs you need a server-side solution. The common approach is a shared consent endpoint: when consent is given on domain A, it posts to your API which sets a consent record server-side. Domain B checks that API on load. Alternatively, a shared iframe on both domains can use postMessage to sync state, though this is more complex to implement correctly.