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.' });
});