TLDR: Server-side consent enforcement is the only way to guarantee compliance—client-side checks can be bypassed by 42% of users with ad blockers. Your JavaScript consent check is advisory; server-side enforcement is authoritative.
Read full summary
Architecture guide for server-side consent management: API design, consent verification middleware, audit logging, and coordinating with client-side interfaces. Essential for high-stakes compliance requirements. Includes complete TypeScript implementations for consent APIs, edge caching, signal propagation, and real-time enforcement across your entire data infrastructure.
*Summary by Claude AI*
## 42% of Your Consent Data Is Missing
In 2023, a major European e-commerce platform completed a comprehensive audit of their consent management system. The finding shocked their compliance team: only 58% of their consent signals were actually reaching their analytics and advertising platforms.
The culprit wasn't a bug in their code. It was the reality of client-side consent management: ad blockers now affect 42% of users in some European markets. When an ad blocker strips your consent management JavaScript, your carefully designed banner never loads, consent is never recorded, and your analytics fire anyway—with no proof of consent and no audit trail.
The company had been processing data for millions of users with no valid consent record. Their DPA investigation is ongoing.
Server-side consent management involves processing and storing user consent choices on your server (or edge network) rather than relying solely on the user's browser. This prevents ad blockers from stripping consent signals, ensures consistent data governance across all your backend systems, and provides an authoritative record of consent that can't be manipulated by client-side code.
## Why Client-Side Consent Management Is No Longer Enough
Client-side consent management served the industry well for a decade. But the environment has changed dramatically. Browser privacy features increasingly interfere with cookies and local storage. And regulators are looking beyond banner implementations to examine whether consent is actually enforced throughout your data pipeline.
The fundamental problem with client-side-only consent is that it's advisory, not authoritative. Your JavaScript sets a cookie or local storage value indicating consent status, and then trusts that all other scripts will check and honor that value. But there's no enforcement mechanism—nothing stops a rogue script from ignoring consent entirely.
Server-side consent management flips this model. Instead of broadcasting consent status and hoping everyone listens, you centralize consent enforcement at the point where data actually flows. No consent verification, no data processing. Period.
## The Problems with Client-Side Only Approaches
### Ad Blocker Interference
Research across 50 major websites shows that 23% of consent signals never reach analytics platforms when users have ad blockers enabled. This isn't just a data loss problem—it's a compliance problem. You can't prove consent was obtained if your consent management platform was blocked from recording it.
### Performance Impact
Client-side consent scripts add measurable latency to page loads:
| CMP Provider | Script Size | Parse Time | Total Impact |
|--------------|-------------|------------|--------------|
| Provider A | 145KB | 89ms | 320ms |
| Provider B | 98KB | 62ms | 245ms |
| Provider C | 187KB | 115ms | 410ms |
| Server-side | 0KB | 0ms | 15ms (API call) |
That 200-400ms impact directly affects Core Web Vitals, conversion rates, and SEO rankings.
### Reliability Issues
Client-side storage is increasingly unreliable:
- Safari's ITP limits cookie lifetime to 7 days for third-party context
- Firefox's Enhanced Tracking Protection blocks many tracking cookies
- Brave blocks virtually all third-party cookies by default
- Chrome's Privacy Sandbox is fundamentally changing how cookies work
### Security Concerns
Client-side consent data is exposed to JavaScript manipulation. Any script on your page can read, modify, or delete consent values. This creates both compliance risk (consent could be forged) and security risk (consent could be stripped).
## Server-Side Advantages
### Complete Data Collection
When consent is verified server-side, the verification happens regardless of what's running in the browser. Ad blockers can't interfere with server-to-server communication. Your consent rate becomes your actual consent rate, not "consent rate minus ad blocker impact."
### Better Performance
Moving consent logic server-side eliminates client-side JavaScript overhead entirely. The consent check happens during your existing API calls, adding minimal latency:
```typescript
// Traditional client-side approach: 200-400ms added to page load
// Server-side approach: 5-15ms added to API response time
interface PerformanceComparison {
clientSide: {
scriptDownload: '50-150ms';
scriptParse: '30-100ms';
scriptExecute: '50-150ms';
consentCheck: '20-50ms';
total: '150-450ms';
};
serverSide: {
apiCallOverhead: '5-15ms';
cacheHit: '1-3ms';
total: '6-18ms';
};
}
```
### Improved Security
Server-side consent storage can't be manipulated by client-side code. The consent record becomes authoritative—if the server says consent wasn't given, no amount of client-side manipulation can override that determination.
### Unified Architecture
Server-side consent creates a single source of truth that all systems can reference. Your analytics, CRM, email platform, ad servers, and data warehouse all check the same authoritative consent record.
## Architecture Overview
Here's the complete architecture for a server-side consent management system:
```
┌─────────────────────────────────────────────────────────────────────┐
│ User Browser │
│ ┌───────────────────┐ │
│ │ Consent UI │ ────────────────────────────────────────────┐│
│ │ (Lightweight) │ ││
│ └───────────────────┘ ││
└────────────────────────────────────────────────────────────────────┘│
│ │
▼ │
┌─────────────────────────────────────────────────────────────────────┐
│ Edge Network (CDN) │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ Consent Cache │ │ Consent Check │ │
│ │ (KV Store) │ │ Middleware │ │
│ └───────────────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Consent API │
│ ┌───────────────────┐ ┌───────────────────┐ ┌──────────────────┐│
│ │ Store Consent │ │ Check Consent │ │ Audit Logger ││
│ └───────────────────┘ └───────────────────┘ └──────────────────┘│
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Consent Database │
│ ┌─────────────────────────────────────────────────────────────────┐│
│ │ user_id | consent_categories | timestamp | source | version ││
│ └─────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Signal Propagation │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│
│ │ Tag Mgr │ │ Analytics│ │ Ad Server│ │ CRM │ │ DWH ││
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └────────┘│
└─────────────────────────────────────────────────────────────────────┘
```
## Implementation Guide
### Step 1: Consent API Endpoints
The foundation of server-side consent management is a robust API for storing and retrieving consent:
```typescript
import express, { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
interface ConsentRecord {
userId: string;
consentId: string;
categories: ConsentCategories;
timestamp: Date;
expiresAt: Date;
source: ConsentSource;
version: string;
ipHash: string;
userAgent: string;
geoLocation: GeoLocation;
tcfString?: string;
gppString?: string;
}
interface ConsentCategories {
essential: boolean;
analytics: boolean;
marketing: boolean;
personalization: boolean;
advertising: boolean;
}
interface ConsentSource {
type: 'banner' | 'preference_center' | 'api' | 'import';
url: string;
bannerVersion: string;
interactionType: 'accept_all' | 'reject_all' | 'custom' | 'implicit';
}
interface GeoLocation {
country: string;
region?: string;
isEU: boolean;
isCA: boolean;
jurisdiction: 'gdpr' | 'ccpa' | 'lgpd' | 'popia' | 'other';
}
class ConsentAPIServer {
private app: express.Application;
private consentStore: ConsentStore;
private auditLogger: AuditLogger;
private signalPropagator: SignalPropagator;
constructor() {
this.app = express();
this.consentStore = new ConsentStore();
this.auditLogger = new AuditLogger();
this.signalPropagator = new SignalPropagator();
this.setupMiddleware();
this.setupRoutes();
}
private setupMiddleware(): void {
this.app.use(express.json());
this.app.use(this.rateLimiter);
this.app.use(this.requestValidator);
}
private setupRoutes(): void {
// Store new consent
this.app.post('/api/consent', this.storeConsent.bind(this));
// Get current consent status
this.app.get('/api/consent/:userId', this.getConsent.bind(this));
// Update consent (partial update)
this.app.patch('/api/consent/:userId', this.updateConsent.bind(this));
// Withdraw all consent
this.app.delete('/api/consent/:userId', this.withdrawConsent.bind(this));
// Get consent history (for DSAR)
this.app.get('/api/consent/:userId/history', this.getConsentHistory.bind(this));
// Verify consent for specific purpose
this.app.post('/api/consent/verify', this.verifyConsent.bind(this));
// Bulk consent check (for batch processing)
this.app.post('/api/consent/bulk-verify', this.bulkVerifyConsent.bind(this));
}
private async storeConsent(req: Request, res: Response): Promise {
try {
const {
userId,
categories,
source,
tcfString,
gppString
} = req.body;
// Validate required fields
if (!userId || !categories) {
res.status(400).json({ error: 'Missing required fields' });
return;
}
// Determine jurisdiction from request
const geoLocation = this.determineJurisdiction(req);
// Create consent record
const consentRecord: ConsentRecord = {
userId,
consentId: uuidv4(),
categories: this.normalizeCategories(categories),
timestamp: new Date(),
expiresAt: this.calculateExpiration(geoLocation),
source: this.validateSource(source),
version: '2.0',
ipHash: this.hashIP(req.ip || ''),
userAgent: req.headers['user-agent'] || '',
geoLocation,
tcfString,
gppString
};
// Store consent
await this.consentStore.upsert(consentRecord);
// Log for audit
await this.auditLogger.logConsentChange({
action: 'consent_stored',
consentRecord,
requestMetadata: this.extractRequestMetadata(req)
});
// Propagate to downstream systems
await this.signalPropagator.propagate(consentRecord);
res.status(201).json({
success: true,
consentId: consentRecord.consentId,
expiresAt: consentRecord.expiresAt
});
} catch (error) {
console.error('Error storing consent:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
private async getConsent(req: Request, res: Response): Promise {
try {
const { userId } = req.params;
const consent = await this.consentStore.get(userId);
if (!consent) {
// No consent record = no consent given
res.json({
userId,
hasConsent: false,
categories: this.getDefaultCategories(),
message: 'No consent record found'
});
return;
}
// Check if consent has expired
if (new Date() > consent.expiresAt) {
res.json({
userId,
hasConsent: false,
categories: this.getDefaultCategories(),
expired: true,
lastConsentDate: consent.timestamp,
message: 'Consent has expired'
});
return;
}
res.json({
userId,
hasConsent: true,
categories: consent.categories,
consentId: consent.consentId,
timestamp: consent.timestamp,
expiresAt: consent.expiresAt,
version: consent.version
});
} catch (error) {
console.error('Error getting consent:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
private async verifyConsent(req: Request, res: Response): Promise {
try {
const { userId, purpose, purposes } = req.body;
const consent = await this.consentStore.get(userId);
// If checking multiple purposes
if (purposes && Array.isArray(purposes)) {
const results: Record = {};
for (const p of purposes) {
results[p] = this.checkPurposeConsent(consent, p);
}
res.json({ userId, purposes: results });
return;
}
// Single purpose check
const hasConsent = this.checkPurposeConsent(consent, purpose);
// Log verification for audit
await this.auditLogger.logConsentVerification({
userId,
purpose,
result: hasConsent,
timestamp: new Date(),
requestMetadata: this.extractRequestMetadata(req)
});
res.json({
userId,
purpose,
hasConsent,
consentTimestamp: consent?.timestamp,
expiresAt: consent?.expiresAt
});
} catch (error) {
console.error('Error verifying consent:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
private async bulkVerifyConsent(req: Request, res: Response): Promise {
try {
const { userIds, purpose } = req.body;
if (!Array.isArray(userIds) || userIds.length > 1000) {
res.status(400).json({ error: 'Invalid userIds array (max 1000)' });
return;
}
const results = await Promise.all(
userIds.map(async (userId) => {
const consent = await this.consentStore.get(userId);
return {
userId,
hasConsent: this.checkPurposeConsent(consent, purpose)
};
})
);
res.json({ purpose, results });
} catch (error) {
console.error('Error in bulk verify:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
private checkPurposeConsent(
consent: ConsentRecord | null,
purpose: string
): boolean {
if (!consent) return false;
if (new Date() > consent.expiresAt) return false;
const purposeMap: Record = {
analytics: 'analytics',
marketing: 'marketing',
advertising: 'advertising',
personalization: 'personalization',
essential: 'essential'
};
const category = purposeMap[purpose];
if (!category) return false;
return consent.categories[category] === true;
}
private determineJurisdiction(req: Request): GeoLocation {
// In production, use a GeoIP service
const country = req.headers['cf-ipcountry'] as string || 'US';
const euCountries = ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE'];
const isEU = euCountries.includes(country);
const isCA = country === 'US'; // Simplified - would check state for CCPA
let jurisdiction: GeoLocation['jurisdiction'] = 'other';
if (isEU) jurisdiction = 'gdpr';
else if (isCA) jurisdiction = 'ccpa';
else if (country === 'BR') jurisdiction = 'lgpd';
else if (country === 'ZA') jurisdiction = 'popia';
return {
country,
isEU,
isCA,
jurisdiction
};
}
private calculateExpiration(geo: GeoLocation): Date {
// GDPR recommends re-consent every 12 months
// CCPA has different requirements
const months = geo.jurisdiction === 'gdpr' ? 12 : 24;
const expiration = new Date();
expiration.setMonth(expiration.getMonth() + months);
return expiration;
}
private normalizeCategories(categories: Partial): ConsentCategories {
return {
essential: true, // Always true
analytics: categories.analytics ?? false,
marketing: categories.marketing ?? false,
personalization: categories.personalization ?? false,
advertising: categories.advertising ?? false
};
}
private getDefaultCategories(): ConsentCategories {
return {
essential: true,
analytics: false,
marketing: false,
personalization: false,
advertising: false
};
}
private validateSource(source: any): ConsentSource {
return {
type: source?.type || 'banner',
url: source?.url || '',
bannerVersion: source?.bannerVersion || '1.0',
interactionType: source?.interactionType || 'custom'
};
}
private hashIP(ip: string): string {
// Hash IP for privacy - don't store raw IPs
const crypto = require('crypto');
return crypto.createHash('sha256').update(ip + 'salt').digest('hex').slice(0, 16);
}
private extractRequestMetadata(req: Request): Record {
return {
ip: this.hashIP(req.ip || ''),
userAgent: req.headers['user-agent'],
referer: req.headers['referer'],
timestamp: new Date().toISOString()
};
}
private rateLimiter(req: Request, res: Response, next: NextFunction): void {
// Implement rate limiting
next();
}
private requestValidator(req: Request, res: Response, next: NextFunction): void {
// Validate request structure
next();
}
}
class ConsentStore {
private consents: Map = new Map();
async upsert(record: ConsentRecord): Promise {
this.consents.set(record.userId, record);
}
async get(userId: string): Promise {
return this.consents.get(userId) || null;
}
async delete(userId: string): Promise {
this.consents.delete(userId);
}
}
class AuditLogger {
async logConsentChange(event: any): Promise {
console.log('Audit:', JSON.stringify(event));
}
async logConsentVerification(event: any): Promise {
console.log('Verification:', JSON.stringify(event));
}
}
class SignalPropagator {
async propagate(consent: ConsentRecord): Promise {
// Propagate to downstream systems
console.log('Propagating consent:', consent.userId);
}
}
```
### Step 2: Edge Caching for Low Latency
For low-latency consent checks, implement edge caching. This example uses Cloudflare Workers, but the pattern applies to any edge platform:
```typescript
interface Env {
CONSENT_KV: KVNamespace;
CONSENT_API: string;
}
interface CachedConsent {
categories: ConsentCategories;
timestamp: string;
expiresAt: string;
version: string;
cachedAt: string;
}
interface ConsentCategories {
essential: boolean;
analytics: boolean;
marketing: boolean;
personalization: boolean;
advertising: boolean;
}
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
// Route consent checks
if (url.pathname.startsWith('/consent/check/')) {
return handleConsentCheck(request, env);
}
// Route consent updates (pass through to origin)
if (url.pathname.startsWith('/consent/') && request.method !== 'GET') {
return handleConsentUpdate(request, env);
}
// Default: pass through
return fetch(request);
}
};
async function handleConsentCheck(request: Request, env: Env): Promise {
const url = new URL(request.url);
const userId = url.pathname.split('/').pop();
if (!userId) {
return new Response(JSON.stringify({ error: 'Missing userId' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const cacheKey = `consent:${userId}`;
// Try edge cache first
let cached = await env.CONSENT_KV.get(cacheKey, 'json') as CachedConsent | null;
if (cached) {
// Check if cache is still valid (5 minute TTL for consent checks)
const cachedTime = new Date(cached.cachedAt).getTime();
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (now - cachedTime < fiveMinutes) {
return new Response(JSON.stringify({
...cached,
source: 'edge_cache',
latency: 'sub-5ms'
}), {
headers: {
'Content-Type': 'application/json',
'X-Consent-Cache': 'HIT',
'Cache-Control': 'private, max-age=300'
}
});
}
}
// Cache miss or stale - fetch from origin
try {
const originResponse = await fetch(`${env.CONSENT_API}/api/consent/${userId}`, {
headers: {
'Authorization': request.headers.get('Authorization') || '',
'X-Forwarded-For': request.headers.get('CF-Connecting-IP') || ''
}
});
if (!originResponse.ok) {
// Don't cache errors
return originResponse;
}
const consent = await originResponse.json() as any;
// Cache the result
const cacheValue: CachedConsent = {
categories: consent.categories,
timestamp: consent.timestamp,
expiresAt: consent.expiresAt,
version: consent.version,
cachedAt: new Date().toISOString()
};
// Store in KV with TTL matching consent expiration
const ttl = Math.min(
3600, // Max 1 hour cache
Math.floor((new Date(consent.expiresAt).getTime() - Date.now()) / 1000)
);
await env.CONSENT_KV.put(cacheKey, JSON.stringify(cacheValue), {
expirationTtl: ttl
});
return new Response(JSON.stringify({
...cacheValue,
source: 'origin',
cached: true
}), {
headers: {
'Content-Type': 'application/json',
'X-Consent-Cache': 'MISS',
'Cache-Control': `private, max-age=${ttl}`
}
});
} catch (error) {
console.error('Error fetching consent from origin:', error);
// If origin fails but we have stale cache, use it
if (cached) {
return new Response(JSON.stringify({
...cached,
source: 'stale_cache',
warning: 'Origin unavailable, using stale cache'
}), {
headers: {
'Content-Type': 'application/json',
'X-Consent-Cache': 'STALE'
}
});
}
// No cache, origin failed - fail safe (deny consent)
return new Response(JSON.stringify({
categories: {
essential: true,
analytics: false,
marketing: false,
personalization: false,
advertising: false
},
source: 'fail_safe',
error: 'Unable to verify consent'
}), {
status: 503,
headers: {
'Content-Type': 'application/json',
'X-Consent-Cache': 'ERROR'
}
});
}
}
async function handleConsentUpdate(request: Request, env: Env): Promise {
// Pass consent updates to origin
const originResponse = await fetch(`${env.CONSENT_API}${new URL(request.url).pathname}`, {
method: request.method,
headers: request.headers,
body: request.body
});
// If update succeeded, invalidate cache
if (originResponse.ok) {
const url = new URL(request.url);
const userId = url.pathname.split('/')[2]; // Extract userId from path
if (userId) {
await env.CONSENT_KV.delete(`consent:${userId}`);
}
}
return originResponse;
}
```
### Step 3: Consent Verification Middleware
Every API endpoint that processes user data should verify consent before proceeding:
```typescript
import { Request, Response, NextFunction } from 'express';
interface ConsentRequirement {
purposes: string[];
enforcement: 'strict' | 'permissive';
fallbackBehavior: 'deny' | 'allow_essential' | 'degrade';
}
interface ConsentMiddlewareConfig {
consentApiUrl: string;
cacheEnabled: boolean;
cacheTtlSeconds: number;
defaultEnforcement: 'strict' | 'permissive';
auditAllChecks: boolean;
}
class ConsentVerificationMiddleware {
private config: ConsentMiddlewareConfig;
private cache: Map = new Map();
constructor(config: ConsentMiddlewareConfig) {
this.config = config;
}
/**
* Creates middleware that verifies consent for specified purposes
*/
requireConsent(requirement: ConsentRequirement) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = this.extractUserId(req);
if (!userId) {
// No user ID = anonymous request
// Apply anonymous policy based on jurisdiction
const jurisdiction = this.detectJurisdiction(req);
if (this.requiresConsentForAnonymous(jurisdiction)) {
return this.handleNoConsent(req, res, requirement);
}
return next();
}
// Check consent
const consent = await this.getConsent(userId);
// Verify all required purposes have consent
const missingConsent = requirement.purposes.filter(
purpose => !this.hasConsentForPurpose(consent, purpose)
);
if (missingConsent.length > 0) {
// Log the denial
await this.logConsentDenial(userId, requirement.purposes, missingConsent, req);
return this.handleNoConsent(req, res, requirement, missingConsent);
}
// Consent verified - attach to request for downstream use
(req as any).consent = consent;
(req as any).consentVerified = true;
(req as any).verifiedPurposes = requirement.purposes;
// Log successful verification if auditing enabled
if (this.config.auditAllChecks) {
await this.logConsentVerification(userId, requirement.purposes, true, req);
}
next();
} catch (error) {
console.error('Consent verification error:', error);
// Fail safe based on enforcement mode
if (requirement.enforcement === 'strict') {
return res.status(503).json({
error: 'consent_verification_failed',
message: 'Unable to verify consent status'
});
}
// Permissive mode - allow but log
console.warn('Consent verification failed, allowing in permissive mode');
next();
}
};
}
/**
* Middleware that blocks requests without any consent record
*/
requireAnyConsent() {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = this.extractUserId(req);
if (!userId) {
return res.status(401).json({
error: 'authentication_required',
message: 'User identification required for this endpoint'
});
}
const consent = await this.getConsent(userId);
if (!consent || !consent.hasConsent) {
return res.status(403).json({
error: 'consent_required',
message: 'Please provide consent before using this service',
consentUrl: '/consent/preferences'
});
}
(req as any).consent = consent;
next();
};
}
/**
* Middleware that attaches consent status without blocking
*/
attachConsentStatus() {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = this.extractUserId(req);
if (userId) {
const consent = await this.getConsent(userId);
(req as any).consent = consent;
} else {
(req as any).consent = null;
}
(req as any).consentChecked = true;
next();
} catch (error) {
console.error('Error attaching consent status:', error);
(req as any).consent = null;
(req as any).consentError = true;
next();
}
};
}
private async getConsent(userId: string): Promise {
// Check cache first
if (this.config.cacheEnabled) {
const cached = this.cache.get(userId);
if (cached && cached.expiry > Date.now()) {
return cached.consent;
}
}
// Fetch from consent API
const response = await fetch(
`${this.config.consentApiUrl}/api/consent/${userId}`
);
if (!response.ok) {
throw new Error(`Consent API returned ${response.status}`);
}
const consent = await response.json();
// Cache the result
if (this.config.cacheEnabled) {
this.cache.set(userId, {
consent,
expiry: Date.now() + (this.config.cacheTtlSeconds * 1000)
});
}
return consent;
}
private extractUserId(req: Request): string | null {
// Try multiple sources for user ID
return (
(req as any).user?.id ||
req.headers['x-user-id'] as string ||
req.query.userId as string ||
this.extractFromCookie(req, 'user_id') ||
null
);
}
private extractFromCookie(req: Request, name: string): string | null {
const cookies = req.headers.cookie?.split(';') || [];
for (const cookie of cookies) {
const [key, value] = cookie.trim().split('=');
if (key === name) return value;
}
return null;
}
private hasConsentForPurpose(consent: any, purpose: string): boolean {
if (!consent || !consent.hasConsent) return false;
if (!consent.categories) return false;
return consent.categories[purpose] === true;
}
private detectJurisdiction(req: Request): string {
// Use geo headers if available (e.g., from CDN)
const country = req.headers['cf-ipcountry'] || req.headers['x-country-code'];
const euCountries = ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE'];
if (euCountries.includes(country as string)) return 'gdpr';
if (country === 'US') return 'ccpa'; // Simplified
return 'other';
}
private requiresConsentForAnonymous(jurisdiction: string): boolean {
// GDPR requires consent for tracking even anonymous users
return jurisdiction === 'gdpr';
}
private handleNoConsent(
req: Request,
res: Response,
requirement: ConsentRequirement,
missingConsent?: string[]
): void {
switch (requirement.fallbackBehavior) {
case 'deny':
res.status(403).json({
error: 'consent_required',
message: 'This feature requires consent',
requiredPurposes: requirement.purposes,
missingConsent: missingConsent || requirement.purposes,
consentUrl: '/consent/preferences'
});
break;
case 'allow_essential':
// Allow request but strip non-essential data processing
(req as any).consentLimited = true;
(req as any).allowedPurposes = ['essential'];
// Continue to handler, which should check consentLimited flag
break;
case 'degrade':
// Allow request with degraded functionality
(req as any).consentDegraded = true;
(req as any).missingConsent = missingConsent;
res.setHeader('X-Consent-Degraded', 'true');
break;
}
}
private async logConsentDenial(
userId: string,
requestedPurposes: string[],
missingConsent: string[],
req: Request
): Promise {
console.log('Consent denial:', {
userId,
requestedPurposes,
missingConsent,
endpoint: req.path,
timestamp: new Date().toISOString()
});
}
private async logConsentVerification(
userId: string,
purposes: string[],
granted: boolean,
req: Request
): Promise {
console.log('Consent verification:', {
userId,
purposes,
granted,
endpoint: req.path,
timestamp: new Date().toISOString()
});
}
}
// Usage example
const consentMiddleware = new ConsentVerificationMiddleware({
consentApiUrl: process.env.CONSENT_API_URL || 'http://localhost:3001',
cacheEnabled: true,
cacheTtlSeconds: 300,
defaultEnforcement: 'strict',
auditAllChecks: true
});
// Apply to routes
const app = express();
// Analytics endpoint - requires analytics consent
app.post('/api/analytics/track',
consentMiddleware.requireConsent({
purposes: ['analytics'],
enforcement: 'strict',
fallbackBehavior: 'deny'
}),
(req, res) => {
// Process analytics event
}
);
// Personalization endpoint - degrades gracefully without consent
app.get('/api/recommendations',
consentMiddleware.requireConsent({
purposes: ['personalization'],
enforcement: 'permissive',
fallbackBehavior: 'degrade'
}),
(req, res) => {
if ((req as any).consentDegraded) {
// Return generic recommendations
return res.json({ recommendations: getGenericRecommendations() });
}
// Return personalized recommendations
res.json({ recommendations: getPersonalizedRecommendations(req) });
}
);
// Marketing endpoint - requires explicit consent
app.post('/api/marketing/subscribe',
consentMiddleware.requireConsent({
purposes: ['marketing'],
enforcement: 'strict',
fallbackBehavior: 'deny'
}),
(req, res) => {
// Add to marketing list
}
);
function getGenericRecommendations() { return []; }
function getPersonalizedRecommendations(req: Request) { return []; }
```
### Step 4: Signal Propagation to Downstream Systems
Consent changes need to propagate to all systems that process user data:
```typescript
interface DownstreamSystem {
name: string;
type: 'analytics' | 'crm' | 'advertising' | 'email' | 'cdp' | 'data_warehouse';
endpoint: string;
authMethod: 'api_key' | 'oauth' | 'basic';
credentials: Record;
supportsRealtime: boolean;
batchSize?: number;
}
interface PropagationResult {
system: string;
success: boolean;
latency: number;
error?: string;
}
class SignalPropagationService {
private systems: DownstreamSystem[] = [];
private retryQueue: Map = new Map();
private maxRetries = 3;
constructor(systems: DownstreamSystem[]) {
this.systems = systems;
this.startRetryProcessor();
}
async propagateConsentChange(consent: ConsentRecord): Promise {
const results: PropagationResult[] = [];
// Group systems by whether they support real-time updates
const realtimeSystems = this.systems.filter(s => s.supportsRealtime);
const batchSystems = this.systems.filter(s => !s.supportsRealtime);
// Propagate to real-time systems in parallel
const realtimeResults = await Promise.all(
realtimeSystems.map(system => this.propagateToSystem(system, consent))
);
results.push(...realtimeResults);
// Queue batch systems for later processing
for (const system of batchSystems) {
this.queueForBatch(system, consent);
}
// Handle failures
const failures = results.filter(r => !r.success);
if (failures.length > 0) {
await this.handleFailures(failures, consent);
}
return results;
}
private async propagateToSystem(
system: DownstreamSystem,
consent: ConsentRecord
): Promise {
const startTime = Date.now();
try {
const payload = this.buildPayload(system, consent);
const headers = await this.buildHeaders(system);
const response = await fetch(system.endpoint, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000) // 5 second timeout
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return {
system: system.name,
success: true,
latency: Date.now() - startTime
};
} catch (error) {
console.error(`Failed to propagate to ${system.name}:`, error);
return {
system: system.name,
success: false,
latency: Date.now() - startTime,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
private buildPayload(system: DownstreamSystem, consent: ConsentRecord): any {
// Build system-specific payload format
switch (system.type) {
case 'analytics':
return {
userId: consent.userId,
consentState: consent.categories,
timestamp: consent.timestamp.toISOString(),
source: 'consent_api'
};
case 'crm':
return {
contactId: consent.userId,
marketingConsent: consent.categories.marketing,
personalizationConsent: consent.categories.personalization,
consentDate: consent.timestamp.toISOString()
};
case 'advertising':
return {
user: consent.userId,
purposes: {
advertising: consent.categories.advertising,
measurement: consent.categories.analytics
},
tcString: consent.tcfString
};
case 'email':
return {
email_id: consent.userId,
subscribed: consent.categories.marketing,
updated_at: consent.timestamp.toISOString()
};
case 'cdp':
return {
profile_id: consent.userId,
consent: consent.categories,
consent_version: consent.version,
collected_at: consent.timestamp.toISOString(),
expires_at: consent.expiresAt.toISOString()
};
case 'data_warehouse':
return {
user_id: consent.userId,
consent_essential: consent.categories.essential,
consent_analytics: consent.categories.analytics,
consent_marketing: consent.categories.marketing,
consent_personalization: consent.categories.personalization,
consent_advertising: consent.categories.advertising,
consent_timestamp: consent.timestamp.toISOString(),
consent_expiry: consent.expiresAt.toISOString(),
consent_source: consent.source.type,
consent_version: consent.version
};
default:
return consent;
}
}
private async buildHeaders(system: DownstreamSystem): Promise {
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
switch (system.authMethod) {
case 'api_key':
headers['Authorization'] = `Bearer ${system.credentials.apiKey}`;
break;
case 'oauth':
const token = await this.getOAuthToken(system);
headers['Authorization'] = `Bearer ${token}`;
break;
case 'basic':
const credentials = Buffer.from(
`${system.credentials.username}:${system.credentials.password}`
).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
break;
}
return headers;
}
private async getOAuthToken(system: DownstreamSystem): Promise {
// Implement OAuth token retrieval with caching
return system.credentials.token || '';
}
private queueForBatch(system: DownstreamSystem, consent: ConsentRecord): void {
// Add to batch queue for periodic processing
console.log(`Queued consent update for ${system.name} batch processing`);
}
private async handleFailures(
failures: PropagationResult[],
consent: ConsentRecord
): Promise {
for (const failure of failures) {
const key = `${failure.system}:${consent.userId}`;
const existing = this.retryQueue.get(key);
if (!existing || existing.retries < this.maxRetries) {
this.retryQueue.set(key, {
consent,
retries: (existing?.retries || 0) + 1
});
} else {
// Max retries exceeded - alert and log
console.error(`Max retries exceeded for ${failure.system}:${consent.userId}`);
await this.alertOnPropagationFailure(failure.system, consent);
}
}
}
private startRetryProcessor(): void {
// Process retry queue every minute
setInterval(async () => {
for (const [key, item] of this.retryQueue) {
const [systemName] = key.split(':');
const system = this.systems.find(s => s.name === systemName);
if (system) {
const result = await this.propagateToSystem(system, item.consent);
if (result.success) {
this.retryQueue.delete(key);
}
}
}
}, 60000);
}
private async alertOnPropagationFailure(
systemName: string,
consent: ConsentRecord
): Promise {
// Send alert to monitoring system
console.error('ALERT: Consent propagation failure', {
system: systemName,
userId: consent.userId,
timestamp: new Date().toISOString()
});
}
}
// Configure downstream systems
const propagationService = new SignalPropagationService([
{
name: 'Google Analytics',
type: 'analytics',
endpoint: 'https://analytics.google.com/consent/v1/update',
authMethod: 'api_key',
credentials: { apiKey: process.env.GA_API_KEY || '' },
supportsRealtime: true
},
{
name: 'Salesforce',
type: 'crm',
endpoint: 'https://your-instance.salesforce.com/services/data/v54.0/consent',
authMethod: 'oauth',
credentials: { token: process.env.SF_TOKEN || '' },
supportsRealtime: true
},
{
name: 'BigQuery',
type: 'data_warehouse',
endpoint: 'https://bigquery.googleapis.com/bigquery/v2/projects/your-project/datasets/consent/tables/records/insertAll',
authMethod: 'oauth',
credentials: { token: process.env.BQ_TOKEN || '' },
supportsRealtime: false,
batchSize: 1000
}
]);
```
### Step 5: Google Consent Mode v2 Integration
Server-side consent must integrate with Google Consent Mode v2 for proper analytics and advertising functionality. This requires handling the new v2 signals: `ad_user_data` and `ad_personalization`.
**Note on Server-Side GTM (SGTM):** While building a custom API (as shown below) gives you maximum control, Google strongly recommends using **Server-Side Google Tag Manager (SGTM)** for implementing server-side consent with Google tools. SGTM has built-in templates for Consent Mode v2 and simplifies the redaction of PII before data reaches Google's servers. The custom implementation below is useful if you are not using SGTM or need to orchestrate consent across non-Google systems.
```typescript
interface GoogleConsentState {
ad_storage: 'granted' | 'denied';
analytics_storage: 'granted' | 'denied';
ad_user_data: 'granted' | 'denied'; // New in v2
ad_personalization: 'granted' | 'denied'; // New in v2
functionality_storage: 'granted' | 'denied';
personalization_storage: 'granted' | 'denied';
security_storage: 'granted' | 'denied';
}
interface ConsentModeConfig {
defaultState: GoogleConsentState;
waitForUpdate: number;
urlPassthrough: boolean;
adsDataRedaction: boolean;
}
class GoogleConsentModeIntegration {
private config: ConsentModeConfig;
constructor(config: ConsentModeConfig) {
this.config = config;
}
/**
* Maps internal consent categories to Google Consent Mode v2 state
*/
mapToGoogleConsentState(categories: ConsentCategories): GoogleConsentState {
return {
// Ad storage - for advertising cookies
ad_storage: categories.advertising ? 'granted' : 'denied',
// Analytics storage - for analytics cookies
analytics_storage: categories.analytics ? 'granted' : 'denied',
// Ad user data - New v2 signal for sending user data to Google for advertising
ad_user_data: categories.advertising ? 'granted' : 'denied',
// Ad personalization - New v2 signal for personalized ads
ad_personalization: categories.advertising && categories.personalization
? 'granted' : 'denied',
// Functionality storage - for website functionality
functionality_storage: categories.essential ? 'granted' : 'denied',
// Personalization storage - for personalization (non-advertising)
personalization_storage: categories.personalization ? 'granted' : 'denied',
// Security storage - always granted for security purposes
security_storage: 'granted'
};
}
/**
* Generates the gtag consent configuration for server-side rendering
*/
generateConsentScript(consent: ConsentRecord | null): string {
const state = consent
? this.mapToGoogleConsentState(consent.categories)
: this.config.defaultState;
return `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
// Set default consent state (v2 parameters included)
gtag('consent', 'default', {
'ad_storage': '${this.config.defaultState.ad_storage}',
'analytics_storage': '${this.config.defaultState.analytics_storage}',
'ad_user_data': '${this.config.defaultState.ad_user_data}',
'ad_personalization': '${this.config.defaultState.ad_personalization}',
'functionality_storage': '${this.config.defaultState.functionality_storage}',
'personalization_storage': '${this.config.defaultState.personalization_storage}',
'security_storage': '${this.config.defaultState.security_storage}',
'wait_for_update': ${this.config.waitForUpdate}
});
${consent ? `
// Update with actual consent state
gtag('consent', 'update', {
'ad_storage': '${state.ad_storage}',
'analytics_storage': '${state.analytics_storage}',
'ad_user_data': '${state.ad_user_data}',
'ad_personalization': '${state.ad_personalization}',
'functionality_storage': '${state.functionality_storage}',
'personalization_storage': '${state.personalization_storage}',
'security_storage': '${state.security_storage}'
});
` : ''}
${this.config.urlPassthrough ? `
// Enable URL passthrough for conversion tracking without cookies
gtag('set', 'url_passthrough', true);
` : ''}
${this.config.adsDataRedaction ? `
// Enable ads data redaction when consent is denied
gtag('set', 'ads_data_redaction', true);
` : ''}
`;
}
/**
* Generates Measurement Protocol hit with consent state
*/
async sendMeasurementProtocolHit(
measurementId: string,
apiSecret: string,
clientId: string,
consent: ConsentRecord | null,
event: {
name: string;
params: Record;
}
): Promise {
// Only send if analytics consent is granted
if (!consent?.categories.analytics) {
return false;
}
const payload = {
client_id: clientId,
events: [{
name: event.name,
params: {
...event.params,
// Include consent state in params for audit
consent_analytics: consent.categories.analytics,
consent_advertising: consent.categories.advertising
}
}]
};
try {
const response = await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${apiSecret}`,
{
method: 'POST',
body: JSON.stringify(payload)
}
);
return response.ok;
} catch (error) {
console.error('Measurement Protocol error:', error);
return false;
}
}
}
interface ConsentCategories {
essential: boolean;
analytics: boolean;
marketing: boolean;
personalization: boolean;
advertising: boolean;
}
interface ConsentRecord {
userId: string;
categories: ConsentCategories;
timestamp: Date;
expiresAt: Date;
}
// Initialize with default denied state (GDPR-compliant default)
const googleConsentMode = new GoogleConsentModeIntegration({
defaultState: {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied', // Explicitly denied in v2 default
ad_personalization: 'denied', // Explicitly denied in v2 default
functionality_storage: 'granted',
personalization_storage: 'denied',
security_storage: 'granted'
},
waitForUpdate: 500,
urlPassthrough: true,
adsDataRedaction: true
});
```
// Initialize with default denied state (GDPR-compliant default)
const googleConsentMode = new GoogleConsentModeIntegration({
defaultState: {
ad_storage: 'denied',
analytics_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
functionality_storage: 'granted', // Essential functionality
personalization_storage: 'denied',
security_storage: 'granted' // Security always granted
},
waitForUpdate: 500, // Wait 500ms for consent before proceeding
urlPassthrough: true, // Enable for cookieless conversion tracking
adsDataRedaction: true // Redact PII when consent denied
});
```
## FAQ
### How do I handle users who clear their cookies?
Server-side consent is stored by user ID, not cookie. As long as you can identify the user (through login, fingerprint-based ID, or other means), their consent status persists. For anonymous users, you can use a first-party cookie that links to their server-side consent record. If that cookie is cleared, you'll need to re-collect consent—which is actually the legally correct behavior.
### What's the latency impact of server-side consent checks?
With edge caching, consent checks add 5-15ms to requests for cache hits. Cache misses require an origin round-trip (50-150ms depending on geography). Compared to client-side solutions that add 200-400ms to page load, server-side is significantly faster for users while being more reliable for compliance.
### How do I migrate from client-side to server-side consent management?
Start by running both systems in parallel. When a user provides consent through your client-side CMP, also send it to your server-side API. Gradually shift enforcement to server-side while keeping the client-side UI. After 3-6 months when most active users have server-side records, you can deprecate client-side enforcement while keeping the UI for collection.
### Does server-side consent work with IAB TCF?
Yes. Store the TCF consent string in your server-side record and propagate it to TCF-compliant vendors. The consent API should return both your internal consent categories and the TCF string for systems that need it. Edge caching works the same way—just include the TCF string in the cached response.
### How do I handle consent for users in different jurisdictions?
Your consent API should detect user jurisdiction (from IP geolocation or explicit selection) and apply appropriate rules. GDPR requires explicit opt-in, CCPA requires opt-out with notification, and other jurisdictions have different requirements. Store the jurisdiction with each consent record so you can apply the correct enforcement rules.
## Making the Transition
Server-side consent management represents the future of privacy compliance. It solves the reliability and performance problems of client-side approaches while improving security and providing a unified architecture that scales across your entire organization.
Start with the fundamentals: a consent API, edge caching for performance, and middleware for enforcement. Then systematically migrate your data flows to check consent server-side before processing. The investment pays off in compliance confidence, better performance, and data quality that actually reflects your consent rates.
The transition takes effort, but organizations that make it will have a compliance infrastructure that works regardless of what browsers, ad blockers, or privacy regulations throw at them.