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.