Terug naar blog
Technical

Cross-Domain Consent: Solving the Multi-Site Challenge

Het GetCookies-teamNovember 4, 202515 min leestijd
Cross-DomainArchitectureTechnicalImplementation

TLDR: Same company, same user, three different consent states. Cross-domain consent sharing requires URL decoration, server-side sync, or centralized authentication—with cryptographic signatures to prevent injection attacks.

Read full summary

Technical guide to cross-domain consent synchronization using URL decoration, first-party cookies with CNAME, and server-side approaches. Includes security considerations and compliance requirements for multi-domain setups.

Summary by Claude AI

Same Company, Same User, Three Different Consent States

A retail conglomerate learned this the hard way in 2023. A user accepted all cookies on their shoes website, then navigated to their clothing site and rejected everything. When the user filed a DSAR, the company discovered they had three conflicting consent records for the same person—one for each domain.

The user's complaint became a regulatory investigation. The DPA found that the company had been collecting data on the clothing site based on consent given on the shoes site, without any technical mechanism to actually verify that consent. Fine: €450,000.

This is the cross-domain consent problem: how do you maintain a consistent, compliant consent experience when users navigate between related properties, while respecting browser privacy protections designed to prevent exactly this kind of cross-site tracking?

When a user grants consent on brand-shoes.com, that consent cannot automatically apply to brand-clothing.com or brand-accessories.com—even if all three sites are owned by the same company.

Why Browser Storage Is Domain-Siloed

Modern browsers partition storage by domain as a fundamental privacy protection. This means:

  • Cookies set on domain-a.com cannot be read by domain-b.com
  • localStorage on one domain is completely isolated from another
  • Third-party cookie blocking (enabled by default in Safari, Firefox, and increasingly Chrome) prevents cross-domain tracking via embedded content

This partitioning exists for good reason—it prevents the kind of invisible cross-site tracking that users find invasive. But it creates a genuine UX problem for organizations operating multiple legitimate properties.

The User Experience Impact

Without cross-domain consent management, users face:

  1. Consent Fatigue: Seeing the same consent banner on every related website
  2. Inconsistent Preferences: Granting analytics consent on one site, denying it on another, without realizing they're making different choices
  3. Friction: Being interrupted repeatedly during a natural multi-site journey (e.g., going from blog to product site to checkout)

The Compliance Complexity

For organizations, the challenges include:

  1. Consent Audit Difficulty: How do you prove consistent consent across properties?
  2. Preference Synchronization: How do you honor a user's "reject all" choice across sites?
  3. TCF Compliance: How do you maintain valid TC Strings across domains?
  4. Regulatory Questions: Does consent on Site A legally cover data processing that happens on Site B?

Solution 1: URL Decorators (Query Parameter Approach)

The URL decorator approach passes consent information between domains via query parameters, allowing Site B to "hydrate" the consent state from Site A.

How It Works

User Journey:
1. User visits brand-shoes.com, grants consent
2. Consent ID and state stored locally
3. User clicks link to brand-clothing.com
4. Link is decorated: brand-clothing.com?consent_id=abc123&consent_state=encoded_data
5. brand-clothing.com reads URL parameters
6. Validates the consent token
7. Restores consent state without showing banner

Implementation

Site A: Decorating Outbound Links

// consent-decorator.js

class ConsentDecorator {
  constructor(options) {
    this.consentKey = options.consentKey || 'user_consent';
    this.trustedDomains = options.trustedDomains || [];
    this.encryptionKey = options.encryptionKey;
  }

  // Get current consent state
  getConsentState() {
    const stored = localStorage.getItem(this.consentKey);
    if (!stored) return null;

    try {
      return JSON.parse(stored);
    } catch (e) {
      return null;
    }
  }

  // Create a consent token for URL decoration
  createConsentToken() {
    const consent = this.getConsentState();
    if (!consent) return null;

    const token = {
      id: consent.consentId,
      state: consent.purposes,
      tcString: consent.tcString,
      timestamp: consent.timestamp,
      domain: window.location.hostname,
      signature: this.signToken(consent)
    };

    // Base64 encode for URL safety
    return btoa(JSON.stringify(token));
  }

  // Sign token to prevent tampering
  signToken(consent) {
    const data = `${consent.consentId}:${consent.timestamp}:${this.encryptionKey}`;
    // In production, use proper HMAC
    return this.simpleHash(data);
  }

  // Decorate a URL with consent parameters
  decorateUrl(url) {
    const token = this.createConsentToken();
    if (!token) return url;

    const urlObj = new URL(url);

    // Only decorate trusted domains
    if (!this.isTrustedDomain(urlObj.hostname)) {
      return url;
    }

    urlObj.searchParams.set('_consent', token);
    return urlObj.toString();
  }

  // Check if domain is trusted
  isTrustedDomain(hostname) {
    return this.trustedDomains.some(domain =>
      hostname === domain || hostname.endsWith('.' + domain)
    );
  }

  // Automatically decorate all links to trusted domains
  autoDecorateLinks() {
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a[href]');
      if (!link) return;

      const href = link.getAttribute('href');
      if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;

      try {
        const decoratedUrl = this.decorateUrl(href);
        if (decoratedUrl !== href) {
          link.setAttribute('href', decoratedUrl);
        }
      } catch (e) {
        // Invalid URL, skip decoration
      }
    });
  }

  simpleHash(str) {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash;
    }
    return Math.abs(hash).toString(36);
  }
}

// Usage
const decorator = new ConsentDecorator({
  trustedDomains: ['brand-shoes.com', 'brand-clothing.com', 'brand-accessories.com'],
  encryptionKey: 'your-secret-key' // In production, use proper key management
});

decorator.autoDecorateLinks();

Site B: Receiving and Validating Consent

// consent-receiver.js

class ConsentReceiver {
  constructor(options) {
    this.consentKey = options.consentKey || 'user_consent';
    this.trustedDomains = options.trustedDomains || [];
    this.encryptionKey = options.encryptionKey;
    this.maxAge = options.maxAge || 86400000; // 24 hours
  }

  // Check URL for consent token
  receiveFromUrl() {
    const params = new URLSearchParams(window.location.search);
    const token = params.get('_consent');

    if (!token) return null;

    // Clean URL (remove consent parameter)
    this.cleanUrl();

    // Validate and parse token
    return this.validateToken(token);
  }

  // Validate incoming consent token
  validateToken(tokenString) {
    try {
      const token = JSON.parse(atob(tokenString));

      // Check token age
      if (Date.now() - token.timestamp > this.maxAge) {
        console.warn('Consent token expired');
        return null;
      }

      // Verify source domain is trusted
      if (!this.isTrustedDomain(token.domain)) {
        console.warn('Consent token from untrusted domain:', token.domain);
        return null;
      }

      // Verify signature (prevents tampering)
      const expectedSignature = this.signToken({
        consentId: token.id,
        timestamp: token.timestamp
      });

      if (token.signature !== expectedSignature) {
        console.warn('Consent token signature invalid');
        return null;
      }

      return {
        consentId: token.id,
        purposes: token.state,
        tcString: token.tcString,
        timestamp: token.timestamp,
        receivedFrom: token.domain
      };
    } catch (e) {
      console.error('Failed to parse consent token:', e);
      return null;
    }
  }

  // Apply received consent state
  applyConsent(consent) {
    if (!consent) return false;

    // Store in localStorage
    localStorage.setItem(this.consentKey, JSON.stringify({
      ...consent,
      appliedAt: Date.now()
    }));

    // Update CMP/consent manager
    if (window.__tcfapi) {
      // If using TCF, update consent
      this.updateTcfConsent(consent.tcString);
    }

    return true;
  }

  updateTcfConsent(tcString) {
    // Implementation depends on your CMP
    // Most CMPs provide an API to set consent state
    if (window.GetCookie) {
      window.GetCookie.setConsentFromTcString(tcString);
    }
  }

  cleanUrl() {
    const url = new URL(window.location.href);
    url.searchParams.delete('_consent');
    window.history.replaceState({}, '', url.toString());
  }

  isTrustedDomain(hostname) {
    return this.trustedDomains.some(domain =>
      hostname === domain || hostname.endsWith('.' + domain)
    );
  }

  signToken(consent) {
    const data = `${consent.consentId}:${consent.timestamp}:${this.encryptionKey}`;
    return this.simpleHash(data);
  }

  simpleHash(str) {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash;
    }
    return Math.abs(hash).toString(36);
  }
}

// Usage on receiving site
const receiver = new ConsentReceiver({
  trustedDomains: ['brand-shoes.com', 'brand-clothing.com', 'brand-accessories.com'],
  encryptionKey: 'your-secret-key' // Must match sender's key
});

// On page load, check for incoming consent
document.addEventListener('DOMContentLoaded', () => {
  const consent = receiver.receiveFromUrl();
  if (consent && receiver.applyConsent(consent)) {
    console.log('Consent state restored from:', consent.receivedFrom);
    // Skip showing consent banner
  } else {
    // Show normal consent banner
    initConsentBanner();
  }
});

URL Decorator Limitations

Limitation Impact Mitigation
Only works for clicks Direct URL entry shows banner Accept as expected behavior
URL length limits Very large consent states may be truncated Compress/hash state; pass reference ID instead
Social sharing Shared URLs include consent params Clean URLs before copy/share
Analytics pollution Consent params in referrer data Clean URLs immediately on receipt
Mobile app gaps In-app browsers may not preserve Use deep linking with consent params

Solution 2: Server-Side Consent (Central Authentication)

For organizations with user authentication, server-side consent management provides a more robust solution. Consent preferences are stored in a central database and associated with the user's account, making them available across all authenticated sessions.

Architecture Overview

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│  brand-shoes.com │    │brand-clothing.com│    │brand-api.com    │
└────────┬────────┘    └────────┬────────┘    └────────┬────────┘
         │                      │                      │
         │  User ID + Session   │                      │
         └──────────┬───────────┘                      │
                    │                                  │
                    ▼                                  │
         ┌──────────────────┐                          │
         │ Central Auth/SSO │◄─────────────────────────┘
         └────────┬─────────┘
                  │
                  ▼
         ┌──────────────────┐
         │ Consent Database │
         │   - User ID      │
         │   - Preferences  │
         │   - TC String    │
         │   - Last Updated │
         └──────────────────┘

Implementation

Backend: Consent Storage API

# consent_api.py (FastAPI example)

from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
from typing import Dict, Optional
from datetime import datetime
import json

app = FastAPI()

class ConsentPreferences(BaseModel):
    purposes: Dict[str, bool]  # {"analytics": True, "marketing": False}
    vendors: Dict[str, bool]   # {"google": True, "facebook": False}
    tc_string: Optional[str]
    last_updated: datetime

class ConsentUpdate(BaseModel):
    purposes: Dict[str, bool]
    vendors: Optional[Dict[str, bool]]
    tc_string: Optional[str]

# Dependency to get current user from session/JWT
async def get_current_user(request: Request):
    user_id = request.session.get("user_id")
    if not user_id:
        raise HTTPException(status_code=401, detail="Not authenticated")
    return user_id

@app.get("/api/consent")
async def get_consent(user_id: str = Depends(get_current_user)):
    """Get user's consent preferences."""
    consent = await db.get_consent(user_id)
    if not consent:
        return {"exists": False}

    return {
        "exists": True,
        "preferences": consent.preferences,
        "tc_string": consent.tc_string,
        "last_updated": consent.last_updated.isoformat()
    }

@app.put("/api/consent")
async def update_consent(
    update: ConsentUpdate,
    user_id: str = Depends(get_current_user)
):
    """Update user's consent preferences."""
    consent = ConsentPreferences(
        purposes=update.purposes,
        vendors=update.vendors or {},
        tc_string=update.tc_string,
        last_updated=datetime.utcnow()
    )

    await db.save_consent(user_id, consent)

    # Propagate to connected systems
    await notify_consent_change(user_id, consent)

    return {"success": True, "updated_at": consent.last_updated.isoformat()}

@app.delete("/api/consent")
async def delete_consent(user_id: str = Depends(get_current_user)):
    """Delete user's consent (withdraw all)."""
    await db.delete_consent(user_id)
    await notify_consent_withdrawal(user_id)
    return {"success": True}

Frontend: Consent Sync Client

// consent-sync.js

class ServerConsentSync {
  constructor(options) {
    this.apiBaseUrl = options.apiBaseUrl;
    this.localKey = options.localKey || 'user_consent';
    this.syncInterval = options.syncInterval || 300000; // 5 minutes
  }

  // Fetch consent from server
  async fetchServerConsent() {
    try {
      const response = await fetch(`${this.apiBaseUrl}/api/consent`, {
        credentials: 'include' // Include auth cookies
      });

      if (!response.ok) {
        if (response.status === 401) {
          // User not logged in - use local consent only
          return null;
        }
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();
      return data.exists ? data : null;
    } catch (error) {
      console.error('Failed to fetch server consent:', error);
      return null;
    }
  }

  // Save consent to server
  async saveToServer(consent) {
    try {
      const response = await fetch(`${this.apiBaseUrl}/api/consent`, {
        method: 'PUT',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          purposes: consent.purposes,
          vendors: consent.vendors,
          tc_string: consent.tcString
        })
      });

      return response.ok;
    } catch (error) {
      console.error('Failed to save consent to server:', error);
      return false;
    }
  }

  // Synchronize local and server consent
  async sync() {
    const serverConsent = await this.fetchServerConsent();
    const localConsent = this.getLocalConsent();

    if (!serverConsent && !localConsent) {
      // No consent anywhere - show banner
      return { needsBanner: true };
    }

    if (serverConsent && !localConsent) {
      // Server has consent, local doesn't - apply server state
      this.setLocalConsent(serverConsent);
      return { needsBanner: false, source: 'server' };
    }

    if (!serverConsent && localConsent) {
      // Local has consent, server doesn't - push to server
      await this.saveToServer(localConsent);
      return { needsBanner: false, source: 'local' };
    }

    // Both exist - use most recent
    const serverTime = new Date(serverConsent.last_updated).getTime();
    const localTime = localConsent.updatedAt || 0;

    if (serverTime > localTime) {
      this.setLocalConsent(serverConsent);
      return { needsBanner: false, source: 'server' };
    } else {
      await this.saveToServer(localConsent);
      return { needsBanner: false, source: 'local' };
    }
  }

  // Local storage operations
  getLocalConsent() {
    const stored = localStorage.getItem(this.localKey);
    if (!stored) return null;
    try {
      return JSON.parse(stored);
    } catch (e) {
      return null;
    }
  }

  setLocalConsent(consent) {
    localStorage.setItem(this.localKey, JSON.stringify({
      purposes: consent.preferences?.purposes || consent.purposes,
      vendors: consent.preferences?.vendors || consent.vendors,
      tcString: consent.tc_string || consent.tcString,
      updatedAt: Date.now()
    }));
  }

  // Start periodic sync
  startPeriodicSync() {
    setInterval(() => this.sync(), this.syncInterval);
  }
}

// Usage
const consentSync = new ServerConsentSync({
  apiBaseUrl: 'https://api.brand-group.com'
});

// On page load
document.addEventListener('DOMContentLoaded', async () => {
  const result = await consentSync.sync();

  if (result.needsBanner) {
    showConsentBanner();
  } else {
    applyConsentState(consentSync.getLocalConsent());
    console.log('Consent restored from:', result.source);
  }

  // Start background sync
  consentSync.startPeriodicSync();
});

Server-Side Consent Benefits

Benefit Description
Audit Trail Complete history of consent changes
Cross-Device Consent follows user across devices
Immediate Sync Changes propagate instantly
Backup Consent survives browser data clearing
Integration Easy to connect with CRM, marketing systems

Security Considerations

Preventing Consent Injection Attacks

A malicious actor might try to inject fake consent tokens to bypass consent requirements. Implement these protections:

1. Cryptographic Signatures

// Use HMAC for production
import { createHmac } from 'crypto';

function signConsent(consent, secretKey) {
  const payload = JSON.stringify({
    id: consent.id,
    state: consent.state,
    timestamp: consent.timestamp
  });

  return createHmac('sha256', secretKey)
    .update(payload)
    .digest('hex');
}

function verifySignature(consent, signature, secretKey) {
  const expected = signConsent(consent, secretKey);
  return signature === expected;
}

2. Token Expiration

// Reject tokens older than 24 hours
const MAX_TOKEN_AGE = 24 * 60 * 60 * 1000;

function isTokenExpired(timestamp) {
  return Date.now() - timestamp > MAX_TOKEN_AGE;
}

3. Domain Allowlisting

// Only accept tokens from known domains
const TRUSTED_DOMAINS = [
  'brand-shoes.com',
  'brand-clothing.com',
  'brand-accessories.com'
];

function isTrustedSource(domain) {
  return TRUSTED_DOMAINS.includes(domain);
}

4. One-Time Token Use (Optional)

// For high-security scenarios, make tokens single-use
const usedTokens = new Set();

function markTokenUsed(tokenId) {
  usedTokens.add(tokenId);
}

function isTokenUsed(tokenId) {
  return usedTokens.has(tokenId);
}

Rate Limiting

Prevent abuse by rate-limiting consent API endpoints:

from fastapi import Request
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@app.get("/api/consent")
@limiter.limit("60/minute")
async def get_consent(request: Request, user_id: str = Depends(get_current_user)):
    # ... implementation

TCF Compliance Across Domains

When using IAB TCF, cross-domain consent must preserve valid TC Strings:

TC String Validation

// Validate TC String before accepting from another domain
function validateTcString(tcString) {
  if (!tcString || typeof tcString !== 'string') {
    return false;
  }

  // TC Strings start with 'C' (version 2.x)
  if (!tcString.startsWith('C')) {
    return false;
  }

  // Attempt to decode
  try {
    // Use IAB's official decoder
    const decoded = TCString.decode(tcString);

    // Verify version is 2.x
    if (decoded.version !== 2) {
      return false;
    }

    // Check not expired (TC Strings include creation date)
    const createdDate = decoded.created;
    const maxAge = 13 * 30 * 24 * 60 * 60 * 1000; // ~13 months
    if (Date.now() - createdDate > maxAge) {
      return false;
    }

    return true;
  } catch (e) {
    return false;
  }
}

Updating CMP After Cross-Domain Transfer

// After receiving valid consent from another domain
function applyTcfConsent(tcString) {
  // Wait for TCF API to be ready
  __tcfapi('addEventListener', 2, (tcData, success) => {
    if (success && tcData.cmpStatus === 'loaded') {
      // CMP-specific method to restore consent
      // This varies by CMP implementation
      if (window.GetCookie) {
        window.GetCookie.restoreConsentState(tcString);
      }
    }
  });
}

Best Practices Summary

Do:

  • Sign all consent tokens cryptographically
  • Validate token age and source domain
  • Clean consent parameters from URLs immediately
  • Log all cross-domain consent transfers for audit
  • Test consent flow across all domain combinations
  • Handle edge cases (expired tokens, invalid signatures)

Don't:

  • Trust consent tokens without validation
  • Include sensitive data in URL parameters
  • Ignore browser privacy features
  • Assume cookies will work cross-domain
  • Forget mobile app and in-app browser scenarios

Finding the Right Balance

Cross-domain consent management requires careful balance between user experience and security. URL decorators provide a lightweight solution for related sites, while server-side consent offers robust synchronization for authenticated users.

The key is choosing the right approach for your architecture:

  • URL Decorators: Simple setup, works for anonymous users, limited to click-through navigation
  • Server-Side: More complex, requires authentication, but provides complete synchronization and audit capability

Most organizations benefit from implementing both: URL decorators for anonymous visitors navigating between marketing sites, and server-side consent for logged-in customers who expect their preferences to follow them everywhere.

Whatever approach you choose, security is paramount. A compromised consent system could either expose your organization to compliance violations (if consent is injected) or damage user trust (if legitimate consent is rejected). Implement proper validation, use cryptographic signatures, and maintain comprehensive audit logs.

G

Het GetCookies-team

Geschreven en bijgehouden door het GetCookies-team van Getia AS in Oslo, Noorwegen. Dit artikel is algemene informatie over cookietoestemming, geen juridisch advies.

Scan je eigen site gratis

Het gratis plan draait de banner op één site met drie cookiescans per maand. Geen creditcard, geen verkoopgesprek.