Implement consent recording, versioning, and enforcement in a web app
For developers adding or fixing consent management in a web app. This walks you through a concrete Postgres-backed implementation that records consent decisions with policy versions, exposes API endpoints, stores a browser cookie, and enforces the decision before loading optional trackers or marketing features.
TL;DR — You need three things working together: a versioned consent policy in your database, an append-only consent decision log tied to a user or anonymous browser ID, and server/client checks that block non-essential processing until consent says otherwise. The most common failure is storing only a boolean like
accepted=true; fix it by recordingpolicy_version,decision,scope,subject_id, anddecided_at, then enforce from that record on every request. Reading time: ~5 min
Goal
When you finish, your app will persist consent decisions with a policy version, let users change or withdraw those decisions, and reliably honour the latest decision in both backend processing and frontend script loading.
Prerequisites
- Postgres 14+ access — check with
psql --version - Node.js 20+ — check with
node --version - A web app where you can add middleware and API routes
- Ability to run SQL migrations against your app database
- A server-side session or auth mechanism, or an anonymous browser ID cookie
- Your current consent categories and policy text/version, for example:
necessary,analytics,marketing, policy version2026-10-01 curlandjqinstalled — check withcurl --versionandjq --version
Steps
Step 1: Create versioned consent policy tables
Run this migration:
create table if not exists consent_policy_versions (
id bigserial primary key,
version text not null unique,
published_at timestamptz not null default now(),
is_active boolean not null default false,
policy_url text not null,
checksum_sha256 text not null,
categories jsonb not null
);
create table if not exists consent_decisions (
id bigserial primary key,
subject_type text not null check (subject_type in ('user','browser')),
subject_id text not null,
policy_version text not null references consent_policy_versions(version),
category text not null,
decision text not null check (decision in ('granted','denied')),
source text not null check (source in ('banner','settings','api','admin')),
ip inet,
user_agent text,
decided_at timestamptz not null default now(),
withdrawn_at timestamptz,
metadata jsonb not null default '{}'::jsonb
);
create index if not exists idx_consent_subject_category_time
on consent_decisions(subject_type, subject_id, category, decided_at desc);
create index if not exists idx_consent_policy_version
on consent_decisions(policy_version);
You should see CREATE TABLE and CREATE INDEX with no errors.
Step 2: Insert the active policy version
Insert one active policy row with literal values:
insert into consent_policy_versions (
version,
published_at,
is_active,
policy_url,
checksum_sha256,
categories
) values (
'2026-10-01',
now(),
true,
'https://app.example.com/legal/privacy?version=2026-10-01',
'b6d81b360a5672d80c27430f39153e2c1b0a5d4f4a7fcd6b4c6f6d9a8e9f1234',
'{"necessary":{"required":true},"analytics":{"required":false},"marketing":{"required":false}}'::jsonb
)
on conflict (version) do update
set is_active = excluded.is_active,
policy_url = excluded.policy_url,
checksum_sha256 = excluded.checksum_sha256,
categories = excluded.categories;
update consent_policy_versions
set is_active = false
where version <> '2026-10-01';
You should see INSERT 0 1 and UPDATE <n>.
Step 3: Add an anonymous browser ID cookie
Add middleware that issues a stable browser ID if the user is not logged in:
import crypto from 'node:crypto';
export function getSubject(req, res) {
if (req.user?.id) {
return { subjectType: 'user', subjectId: String(req.user.id) };
}
let browserId = req.cookies?.cid;
if (!browserId) {
browserId = crypto.randomUUID();
res.cookie('cid', browserId, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: 31536000000,
path: '/'
});
}
return { subjectType: 'browser', subjectId: browserId };
}
You should see a Set-Cookie: cid=...; HttpOnly; Secure; SameSite=Lax header on the first anonymous request.
Step 4: Add the consent write API
Create a route that writes one row per category decision:
import express from 'express';
import pg from 'pg';
import { getSubject } from './consent-subject.js';
const router = express.Router();
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
router.post('/api/consent', express.json(), async (req, res) => {
const { subjectType, subjectId } = getSubject(req, res);
const { policyVersion, decisions, source } = req.body;
const allowedCategories = ['necessary', 'analytics', 'marketing'];
const allowedDecision = ['granted', 'denied'];
for (const [category, decision] of Object.entries(decisions)) {
if (!allowedCategories.includes(category)) return res.status(400).json({ error: `invalid category ${category}` });
if (!allowedDecision.includes(decision)) return res.status(400).json({ error: `invalid decision ${decision}` });
}
const client = await db.connect();
try {
await client.query('begin');
for (const [category, decision] of Object.entries(decisions)) {
await client.query(
`insert into consent_decisions
(subject_type, subject_id, policy_version, category, decision, source, ip, user_agent)
values ($1,$2,$3,$4,$5,$6,$7,$8)`,
[subjectType, subjectId, policyVersion, category, decision, source || 'banner', req.ip, req.get('user-agent') || '']
);
}
await client.query('commit');
res.status(204).end();
} catch (e) {
await client.query('rollback');
res.status(500).json({ error: 'consent_write_failed', detail: e.message });
} finally {
client.release();
}
});
export default router;
You should get HTTP 204 No Content for a valid POST.
Step 5: Add the consent read query for enforcement
Use this exact query to resolve the latest decision per category:
select distinct on (category)
category,
decision,
policy_version,
decided_at,
withdrawn_at
from consent_decisions
where subject_type = $1
and subject_id = $2
and withdrawn_at is null
order by category, decided_at desc;
And use it in code like this:
export async function getConsentMap(db, subjectType, subjectId) {
const { rows } = await db.query(
`select distinct on (category) category, decision
from consent_decisions
where subject_type = $1 and subject_id = $2 and withdrawn_at is null
order by category, decided_at desc`,
[subjectType, subjectId]
);
return Object.fromEntries(rows.map(r => [r.category, r.decision]));
}
You should get an object like { analytics: 'denied', marketing: 'granted' }.
Step 6: Enforce consent before loading optional scripts
Render optional tags only when the decision is granted:
const consent = await getConsentMap(db, subjectType, subjectId);
const analyticsTag = consent.analytics === 'granted'
? '<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>'
: '';
const marketingPixel = consent.marketing === 'granted'
? '<script src="/static/marketing-pixel.js"></script>'
: '';
For backend jobs or event forwarding, gate them the same way:
if (consent.analytics === 'granted') {
await analyticsQueue.add({ userId: req.user.id, event: 'signup_completed' });
}
You should see no analytics or marketing network requests in the browser until the matching category is granted.
Step 7: Support withdrawal and policy re-consent
To withdraw current consent for one category:
update consent_decisions
set withdrawn_at = now()
where subject_type = $1
and subject_id = $2
and category = $3
and withdrawn_at is null;
To detect that the active policy changed and the banner must reappear:
select version
from consent_policy_versions
where is_active = true;
Compare that active version to the latest recorded version for the subject:
select max(policy_version) as latest_seen_version
from consent_decisions
where subject_type = $1 and subject_id = $2;
You should see the banner again when latest_seen_version is null or differs from the active version.
View as text
[Browser] ---- 1. GET / ----> [App] | | | 2. no consent cookie | 3. read active policy v v [CID cookie] <---- 4. Set-Cookie ---- [App] | | 5. POST /api/consent v [App] ---- 6. INSERT decisions ----> [Postgres] ^ | 7. later requests read latest decision | [Browser]
Verify it works
Create a consent decision:
curl -i -X POST http://localhost:3000/api/consent \
-H 'Content-Type: application/json' \
-H 'Cookie: cid=11111111-1111-1111-1111-111111111111' \
--data '{"policyVersion":"2026-10-01","source":"banner","decisions":{"necessary":"granted","analytics":"denied","marketing":"granted"}}'
Expected response shape:
HTTP/1.1 204 No Content
Set-Cookie: cid=11111111-1111-1111-1111-111111111111; Path=/; HttpOnly; Secure; SameSite=Lax
Check the rows exist:
psql "$DATABASE_URL" -c "select subject_type, subject_id, policy_version, category, decision, withdrawn_at from consent_decisions where subject_id = '11111111-1111-1111-1111-111111111111' order by decided_at desc;"
Expected output shape:
subject_type | subject_id | policy_version | category | decision | withdrawn_at
--------------+---------------------------------------+----------------+------------+----------+--------------
browser | 11111111-1111-1111-1111-111111111111 | 2026-10-01 | marketing | granted |
browser | 11111111-1111-1111-1111-111111111111 | 2026-10-01 | analytics | denied |
browser | 11111111-1111-1111-1111-111111111111 | 2026-10-01 | necessary | granted |
(3 rows)
Then load your page and confirm in browser devtools that denied categories do not fire requests. For example, with analytics denied, Network should show no requests to gtag/js, your analytics collector, or your server-side analytics queue endpoint.
Common pitfalls
Step stores only one boolean instead of per-category rows
Mistake: saving consent=true in a user table.
Symptom: you cannot prove what was consented to, under which policy version, or withdraw only analytics while keeping necessary cookies.
Fix: insert one row per category into consent_decisions with policy_version, category, decision, and decided_at.
Latest-decision query returns stale consent
Mistake: selecting any matching row without ordering, or ignoring withdrawn_at.
Symptom: users withdraw marketing consent but the app still loads the pixel.
Fix: use distinct on (category) ... order by category, decided_at desc and filter withdrawn_at is null.
Re-consent never appears after policy text changes
Mistake: updating policy text in place without a new version string.
Symptom: old users are treated as consented to a materially changed policy.
Fix: insert a new row in consent_policy_versions with a new literal version like 2026-11-15 and set only that row is_active = true.
Anonymous users lose consent on every visit
Mistake: storing the browser ID in sessionStorage or a non-persistent cookie.
Symptom: the banner reappears every browser restart and your database fills with duplicate browser subjects.
Fix: issue a persistent cid cookie with maxAge: 31536000000, HttpOnly, Secure, and SameSite=Lax.
Optional scripts still load before consent is checked
Mistake: hardcoding third-party script tags into the base HTML template.
Symptom: network requests to analytics or marketing vendors happen on first paint even when the user has not consented.
Fix: render those tags only after the server-side consent lookup returns granted, or inject them client-side only after the consent API state says granted.
Consent is recorded but background processing ignores it
Mistake: gating only frontend tags and not server-side event pipelines, email audiences, or ad sync jobs.
Symptom: no browser requests are visible, but denied users still appear in downstream analytics or marketing systems.
Fix: add the same consent check before queue publish, webhook send, export job, and audience sync code paths.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI