Zurück zum Blog
Technical

Cross-Domain Consent: Solving the Multi-Site Challenge

Alex Kowalski, Platform ArchitectNovember 4, 202515 Min. Lesezeit
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** ```javascript // 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** ```javascript // 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** ```python # 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** ```javascript // 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** ```javascript // 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** ```javascript // 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** ```javascript // 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)** ```javascript // 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: ```python 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 ```javascript // 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 ```javascript // 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.
A

Alex Kowalski, Platform Architect

Autor bei GetCookies, spezialisiert auf Datenschutz-Compliance, Einwilligungsmanagement und Optimierung von digitalem Marketing.

Bereit, Cookie-Einwilligung zu vereinfachen?

GetCookies macht DSGVO, CCPA und globale Datenschutz-Compliance mühelos. Starten Sie heute.