Consent management: building a compliant audit trail for GDPR and CCPA

When a data protection authority investigates a complaint, the first thing they ask for is evidence: show us when this user gave consent, what they consented to, what they were shown at the time, and how they can withdraw it. If your consent records are a single boolean field in the users table, you can't answer those questions. This post builds a consent management system that can withstand regulatory scrutiny.

What makes a consent record legally defensible

Under GDPR Article 7 and the associated recitals, demonstrable consent requires:

  • Timing — exact timestamp of when consent was given
  • What was consented to — the exact text of the consent request shown to the user, versioned
  • How it was given — affirmative action (not pre-ticked boxes), the mechanism used
  • Context — IP address, user agent, the page/flow where consent was collected
  • Withdrawal — a record that it was withdrawn, when, and how

The consent record schema

-- Consent types with versioned text (the source of truth for what users saw)
CREATE TABLE consent_types (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug        TEXT NOT NULL,    -- e.g. 'marketing_email', 'analytics', 'terms_of_service'
  version     INTEGER NOT NULL,
  title       TEXT NOT NULL,    -- shown to user: "Marketing Emails"
  description TEXT NOT NULL,    -- exact text shown: "We may send you product updates..."
  legal_basis TEXT NOT NULL,    -- 'consent' | 'legitimate_interest' | 'contract' | 'legal_obligation'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (slug, version)
);

-- Individual consent records — append-only, never update
CREATE TABLE consent_records (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES users(id),
  consent_type_id UUID NOT NULL REFERENCES consent_types(id),
  action          TEXT NOT NULL CHECK (action IN ('granted', 'withdrawn')),
  granted_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  ip_address      INET NOT NULL,
  user_agent      TEXT NOT NULL,
  collection_method TEXT NOT NULL, -- 'signup_form', 'settings_page', 'cookie_banner'
  confirmation_page_url TEXT,       -- URL where consent was collected
  metadata        JSONB DEFAULT '{}' -- any additional context
);

-- Materialized current consent state (derived from records)
CREATE VIEW current_consents AS
SELECT DISTINCT ON (user_id, consent_type_slug)
  cr.user_id,
  ct.slug AS consent_type_slug,
  cr.action AS current_state,
  cr.granted_at AS last_changed_at,
  ct.version AS consented_to_version
FROM consent_records cr
JOIN consent_types ct ON ct.id = cr.consent_type_id
ORDER BY cr.user_id, ct.slug, cr.granted_at DESC;

Recording consent

interface ConsentEvent {
  userId: string;
  consentTypeSlug: string;
  action: 'granted' | 'withdrawn';
  ipAddress: string;
  userAgent: string;
  collectionMethod: string;
  confirmationPageUrl?: string;
}

async function recordConsent(event: ConsentEvent, db: DB): Promise<string> {
  // Find the current active version of this consent type
  const consentType = await db.query<{id: string}>(`
    SELECT id FROM consent_types
    WHERE slug = $1
    ORDER BY version DESC
    LIMIT 1
  `, [event.consentTypeSlug]);

  if (!consentType.rows[0]) {
    throw new Error(`Unknown consent type: ${event.consentTypeSlug}`);
  }

  const record = await db.query(`
    INSERT INTO consent_records
      (user_id, consent_type_id, action, ip_address, user_agent, collection_method, confirmation_page_url)
    VALUES ($1, $2, $3, $4, $5, $6, $7)
    RETURNING id
  `, [
    event.userId,
    consentType.rows[0].id,
    event.action,
    event.ipAddress,
    event.userAgent,
    event.collectionMethod,
    event.confirmationPageUrl,
  ]);

  return record.rows[0].id;
}

// At signup: record all consents from the signup form
async function recordSignupConsents(
  userId: string,
  formValues: { marketing: boolean; analytics: boolean },
  req: Request,
  db: DB
): Promise<void> {
  const context = {
    userId,
    ipAddress: req.ip,
    userAgent: req.headers['user-agent'] || '',
    collectionMethod: 'signup_form',
    confirmationPageUrl: req.headers.referer,
  };

  // Terms of service: always granted at signup (basis: contract)
  await recordConsent({ ...context, consentTypeSlug: 'terms_of_service', action: 'granted' }, db);

  // Optional consents: record based on user choice
  await recordConsent({
    ...context,
    consentTypeSlug: 'marketing_email',
    action: formValues.marketing ? 'granted' : 'withdrawn',
  }, db);

  await recordConsent({
    ...context,
    consentTypeSlug: 'analytics',
    action: formValues.analytics ? 'granted' : 'withdrawn',
  }, db);
}

Consent versioning: re-requesting after updates

When your privacy policy or consent text changes, users who consented to v1 haven't consented to v2. Create a new version of the consent type and track which users need to re-consent:

async function checkConsentsNeedingRenewal(userId: string, db: DB): Promise<string[]> {
  const result = await db.query(`
    SELECT ct.slug
    FROM consent_types ct
    WHERE ct.legal_basis = 'consent'
    -- Latest version of each type
    AND ct.version = (
      SELECT MAX(version) FROM consent_types ct2 WHERE ct2.slug = ct.slug
    )
    -- User hasn't consented to this latest version
    AND NOT EXISTS (
      SELECT 1 FROM consent_records cr
      JOIN consent_types ct3 ON ct3.id = cr.consent_type_id
      WHERE cr.user_id = $1
        AND ct3.slug = ct.slug
        AND ct3.version = ct.version
        AND cr.action = 'granted'
    )
  `, [userId]);

  return result.rows.map(r => r.slug);
}

Consent receipts

A consent receipt is a machine-readable summary of a consent event, suitable for giving to the user. The ISO/IEC 29184 standard defines a schema for this. At minimum, provide users a way to download their consent history:

async function generateConsentReceipt(userId: string, db: DB): Promise<ConsentReceipt> {
  const records = await db.query(`
    SELECT
      cr.id,
      cr.action,
      cr.granted_at,
      cr.collection_method,
      cr.ip_address,
      ct.slug AS consent_type,
      ct.version,
      ct.title,
      ct.description,
      ct.legal_basis
    FROM consent_records cr
    JOIN consent_types ct ON ct.id = cr.consent_type_id
    WHERE cr.user_id = $1
    ORDER BY cr.granted_at ASC
  `, [userId]);

  return {
    userId,
    generatedAt: new Date().toISOString(),
    controller: {
      name: 'SummitFlux LLC — Bastionary',
      contact: 'privacy@bastionary.com',
    },
    records: records.rows.map(r => ({
      id: r.id,
      consentType: r.consent_type,
      version: r.version,
      title: r.title,
      description: r.description,
      legalBasis: r.legal_basis,
      action: r.action,
      timestamp: r.granted_at,
      collectionMethod: r.collection_method,
    })),
  };
}

The withdrawal flow

GDPR Article 7(3): withdrawal must be as easy as giving consent. A multi-step buried settings page does not satisfy this. Place consent management prominently in your privacy settings, and handle withdrawals immediately:

router.post('/settings/privacy/withdraw-consent', requireAuth, async (req, res) => {
  const { consentType } = req.body;

  await recordConsent({
    userId: req.user.id,
    consentTypeSlug: consentType,
    action: 'withdrawn',
    ipAddress: req.ip,
    userAgent: req.headers['user-agent'] || '',
    collectionMethod: 'settings_page',
    confirmationPageUrl: req.headers.referer,
  }, db);

  // Immediately act on withdrawal
  switch (consentType) {
    case 'marketing_email':
      await emailProvider.unsubscribe(req.user.email);
      break;
    case 'analytics':
      // Set a flag that causes analytics code to skip this user
      await db.users.update(req.user.id, { analyticsOptOut: true });
      break;
  }

  res.json({ status: 'withdrawn', message: 'Your consent has been withdrawn.' });
});
Consent records must be retained even after a user exercises their right to erasure. The consent record itself is your legal evidence — it's exempt from erasure under the "establishment, exercise, or defense of legal claims" exemption. Pseudonymize the user_id after erasure but retain the consent record itself.