Powrót do bloga
Technical

Server-Side Consent Management: Architecture, Benefits, and Migration Guide

Zespół GetCookiesNovember 25, 202514 min czytania
Server-SideArchitectureImplementationPerformance

TLDR: Server-side consent enforcement closes gaps that client-side checks leave open, for example when ad blockers strip the consent script. 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

Part of Your Consent Data May Be Missing

Illustrative scenario: a composite example, not a specific reported case.

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: many users run ad blockers. 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

When users have ad blockers enabled, some consent signals never reach analytics platforms. 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:

// 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:

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<void> {
    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<void> {
    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<void> {
    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<string, boolean> = {};
        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<void> {
    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<string, keyof ConsentCategories> = {
      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>): 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<string, any> {
    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<string, ConsentRecord> = new Map();

  async upsert(record: ConsentRecord): Promise<void> {
    this.consents.set(record.userId, record);
  }

  async get(userId: string): Promise<ConsentRecord | null> {
    return this.consents.get(userId) || null;
  }

  async delete(userId: string): Promise<void> {
    this.consents.delete(userId);
  }
}

class AuditLogger {
  async logConsentChange(event: any): Promise<void> {
    console.log('Audit:', JSON.stringify(event));
  }

  async logConsentVerification(event: any): Promise<void> {
    console.log('Verification:', JSON.stringify(event));
  }
}

class SignalPropagator {
  async propagate(consent: ConsentRecord): Promise<void> {
    // 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:

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<Response> {
    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<Response> {
  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<Response> {
  // 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:

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<string, { consent: any; expiry: number }> = 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<any> {
    // 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<void> {
    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<void> {
    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:

interface DownstreamSystem {
  name: string;
  type: 'analytics' | 'crm' | 'advertising' | 'email' | 'cdp' | 'data_warehouse';
  endpoint: string;
  authMethod: 'api_key' | 'oauth' | 'basic';
  credentials: Record<string, string>;
  supportsRealtime: boolean;
  batchSize?: number;
}

interface PropagationResult {
  system: string;
  success: boolean;
  latency: number;
  error?: string;
}

class SignalPropagationService {
  private systems: DownstreamSystem[] = [];
  private retryQueue: Map<string, { consent: ConsentRecord; retries: number }> = new Map();
  private maxRetries = 3;

  constructor(systems: DownstreamSystem[]) {
    this.systems = systems;
    this.startRetryProcessor();
  }

  async propagateConsentChange(consent: ConsentRecord): Promise<PropagationResult[]> {
    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<PropagationResult> {
    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<HeadersInit> {
    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<string> {
    // 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<void> {
    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<void> {
    // 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.

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<string, any>;
    }
  ): Promise<boolean> {
    // 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.

Najczęściej zadawane pytania

Does server-side tracking bypass GDPR?
No. You still need to obtain user consent before processing data, regardless of whether that processing happens on the client or server. Server-side tracking is a method of data delivery, not a loophole for consent.
G

Zespół GetCookies

Napisane i aktualizowane przez zespół GetCookies w Getia AS w Oslo w Norwegii. Ten artykuł zawiera ogólne informacje o zgodzie na pliki cookie i nie stanowi porady prawnej.

Przeskanuj własną stronę za darmo

Darmowy plan wyświetla baner na jednej stronie i daje trzy skany cookies miesięcznie. Bez karty, bez rozmów handlowych.