How to check a consent record
A consent record has to say who agreed, to what, when, under which policy version and by what action. Read the record the server stored, not the fx_consent cookie: a cookie holding one word answers none of those. On the fixture, seven required fields were missing from the cookie.
Why check this
The obligation is to be able to show, later, that a particular person agreed to a particular thing. GDPR Article 7(1) puts the burden on the controller to demonstrate consent, and Article 4(11) defines it as specific and informed. The regulation names the obligation and not the schema, so the engineering question is which fields make the obligation checkable.
Run this once the banner works, before a release that changes the banner text, the category list or the policy. A banner that stores its decision correctly and stores nothing else passes every functional test and fails the only question anyone asks months later: what was this person shown, and what did they agree to.
The second failure is quieter. The record can be complete and still disagree with the browser. On the reject path below, the stored record says analytics is false while three analytics cookies sit in the jar. A record that does not match observed behaviour is a claim, not evidence, and checking the record alone will never reveal it.
Prerequisites
- Node 22, the installed Chrome, and
npm i puppeteer-core. - curl for step 3. Any build reads plain HTTP here; the version below is the one that produced the output.
- The fixture on port 9601, printed below, started before the runs. It stores a record per session id and serves it at
/consent/record. - No commercial consent platform is installed on this machine, so every figure here comes from the fixture. A vendor product stores the same decision in its own shape, and the field check in step 2 is what transfers, not the field names.
- One capture on one machine, Chrome 152 on Windows, 2026-09-12. Record ids and timestamps change on every run.
// consent-fixture.mjs node consent-fixture.mjs -> http://localhost:9601/
import { createServer } from 'node:http';
const YEAR = 'Max-Age=31536000; Path=/';
const records = new Map(); // sid -> consent record
const html = `<!doctype html><meta charset="utf-8"><title>Fixture shop</title>
<h1>Fixture shop</h1><p id="state">consent: not recorded</p>
<div id="banner"><p>We use cookies for analytics and marketing.</p>
<button id="accept">Accept all</button> <button id="reject">Reject all</button></div>
<button id="withdraw">Withdraw consent</button>
<script>
const send = (d) => fetch('/consent', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ decision: d }) }).then((r) => r.json()).then((j) => {
state.textContent = 'consent: ' + j.decision;
banner.hidden = j.decision !== 'withdraw';
localStorage.setItem('fx_consent', JSON.stringify({ decision: j.decision, at: j.at }));
if (j.decision === 'accept') localStorage.setItem('fx_analytics_queue', '["pageview:/"]');
sessionStorage.setItem('fx_banner_seen', '1');
});
accept.onclick = () => send('accept');
reject.onclick = () => send('reject');
withdraw.onclick = () => send('withdraw');
</script>`;
createServer((req, res) => {
const sid = /fx_sid=([^;]+)/.exec(req.headers.cookie || '')?.[1] || 'sid-7f3a';
if (req.url === '/echo') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ cookieHeader: req.headers.cookie || null }));
}
if (req.url === '/consent/record') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(records.get(sid) || null, null, 2));
}
if (req.url === '/consent' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
return req.on('end', () => {
const decision = JSON.parse(body).decision;
const at = new Date().toISOString();
const prev = records.get(sid);
records.set(sid, decision === 'withdraw'
? { ...prev, decision: 'withdraw', withdrawnAt: at }
: { id: 'cr-' + Math.random().toString(36).slice(2, 10), subject: sid, decision, at,
categories: { necessary: true, analytics: decision === 'accept', marketing: decision === 'accept' },
policyVersion: '2026-03-01', bannerVersion: '1.4.0', scope: 'http://localhost:9601',
method: 'banner-button' });
// The defect under test: both decisions set the same analytics cookies.
const jar = decision === 'withdraw' ? [] : [`fx_ga=GA1.2.884.1789; ${YEAR}`, `fx_fbp=fb.1.771; ${YEAR}`,
`fx_uid=u-91af; HttpOnly; ${YEAR}`];
res.writeHead(200, { 'content-type': 'application/json',
'set-cookie': [...jar, `fx_consent=${decision}; ${YEAR}`] });
res.end(JSON.stringify({ decision, at }));
});
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8',
'set-cookie': [`fx_sid=${sid}; HttpOnly; Path=/`, `fx_seen=1; ${YEAR}`] });
res.end(html + (req.url.includes('tp=1') ? '<img src="http://127.0.0.1:9602/px.gif" alt="">' : ''));
}).listen(9601, () => console.log('fixture on http://localhost:9601/'));
// record-check.mjs node record-check.mjs accept|reject
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const REQUIRED = ['id', 'subject', 'decision', 'at', 'categories', 'policyVersion', 'scope', 'method'];
const profile = mkdtempSync(join(tmpdir(), 'consent-'));
const browser = await launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
headless: true, userDataDir: profile });
try {
const page = (await browser.pages())[0];
const cdp = await page.createCDPSession();
await cdp.send('Network.enable');
await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
await page.click('#' + process.argv[2]);
await page.waitForFunction("state.textContent !== 'consent: not recorded'");
const cookie = await page.evaluate(() => /fx_consent=([^;]*)/.exec(document.cookie)[1]);
console.log('cookie : fx_consent=' + cookie);
console.log('storage :', await page.evaluate(() => localStorage.getItem('fx_consent')));
console.log('cookie as a record, missing :',
REQUIRED.filter((f) => ({ decision: cookie })[f] === undefined).join(' '));
const rec = await page.evaluate(() => fetch('/consent/record').then((r) => r.json()));
console.log('record :', JSON.stringify(rec, null, 1).replace(/\n\s*/g, ' '));
console.log('missing :', REQUIRED.filter((f) => rec?.[f] === undefined).join(' ') || 'none');
const { cookies } = await cdp.send('Network.getAllCookies');
console.log('record says analytics :', rec.categories.analytics);
console.log('jar holds :', cookies.filter(
(c) => ['fx_ga', 'fx_fbp', 'fx_uid'].includes(c.name)).map((c) => c.name).sort().join(' ') || 'none');
} finally {
await browser.close();
rmSync(profile, { recursive: true, force: true });
}
Steps
- Step 1.
Start the fixture and leave it running.
node consent-fixture.mjsfixture on http://localhost:9601/ - Step 2.
Accept, then read what the browser holds and what the server stored.
node record-check.mjs acceptcookie : fx_consent=accept storage : {"decision":"accept","at":"2026-09-12T07:56:23.869Z"} cookie as a record, missing : id subject at categories policyVersion scope method record : { "id": "cr-1let3std", "subject": "sid-7f3a", "decision": "accept", "at": "2026-09-12T07:56:23.869Z", "categories": { "necessary": true, "analytics": true, "marketing": true }, "policyVersion": "2026-03-01", "bannerVersion": "1.4.0", "scope": "http://localhost:9601", "method": "banner-button" } missing : none record says analytics : true jar holds : fx_fbp fx_ga fx_uidLine 3 is the finding. Treated as a record,
fx_consent=acceptis missing seven of the eight fields: no identifier, no subject, no time, no category breakdown, no policy version, no scope, no method. ThelocalStoragecopy on line 2 adds a timestamp and stops there. Line 4 is a record that answers all eight, and line 5 confirms it. - Step 3.
Produce the same record without the browser, the way an auditor would ask for it.
curl -s -b "fx_sid=sid-7f3a" http://localhost:9601/consent/record{ "id": "cr-1let3std", "subject": "sid-7f3a", "decision": "accept", "at": "2026-09-12T07:56:23.869Z", "categories": { "necessary": true, "analytics": true, "marketing": true }, "policyVersion": "2026-03-01", "bannerVersion": "1.4.0", "scope": "http://localhost:9601", "method": "banner-button" }The id matches the one from step 2,
cr-1let3std, so the record survives outside the session that created it. A record that can only be produced from the visitor's own browser cannot be produced at all once that browser clears its storage. - Step 4.
Reject in a fresh profile and compare the stored record against the cookie jar.
node record-check.mjs rejectcookie : fx_consent=reject storage : {"decision":"reject","at":"2026-09-12T07:56:25.748Z"} cookie as a record, missing : id subject at categories policyVersion scope method record : { "id": "cr-0bnkm89g", "subject": "sid-7f3a", "decision": "reject", "at": "2026-09-12T07:56:25.748Z", "categories": { "necessary": true, "analytics": false, "marketing": false }, "policyVersion": "2026-03-01", "bannerVersion": "1.4.0", "scope": "http://localhost:9601", "method": "banner-button" } missing : none record says analytics : false jar holds : fx_fbp fx_ga fx_uidThe last two lines contradict each other. The record states that analytics consent was refused, and the browser is holding
fx_ga,fx_fbpandfx_uid. Every field check on this record passes. The record is complete and wrong.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| missing : none | The record carries all eight fields | Move on to the comparison in step 4. Completeness is not correctness. |
| missing names a field | The record cannot answer one question | Add the field at the point of writing. Backfilling it later invents data. |
| The record holds only a decision | Consent is stored as a flag | It cannot show what was shown, when, or under which text. Treat it as a defect. |
| The record lives only in the browser | Clearing storage destroys the evidence | Store it server side, keyed to a subject that outlives the cookie. |
| Record and jar disagree | The decision is recorded and not applied | Fix the application path first. See How to check if cookies are set without consent. |
| policyVersion never changes | The record cannot be tied to a text | Version the banner text and the policy, and stamp both into the record. |
Common mistakes
What to check next
- How to test cookie consent banner: the behaviour the record is supposed to describe, tested by diffing two cookie jars.
- How to revoke cookie consent: what the record has to gain when the visitor changes their mind.
- How to check cookies on a website: the inventory you compare the record's categories against.
- How to check if local storage is used before consent: the other half of the browser side, where the record copy in step 2 lives.
- How to test data deletion request: the request that arrives later and needs the subject key from this record.
FAQ
How do I record consent?
Write a server-side row at the moment of the click: an id, the subject key, the decision per category, an ISO timestamp, the policy and banner versions, the scope it covers, and the action that produced it. The browser copy is a cache of that row.
How do I ensure valid consent?
Check three things separately. The record carries the eight fields. The categories in the record match the cookies and requests observed in the same run. And the same decision is reproducible outside the visitor's browser, as in step 3.
Is a consent cookie enough on its own?
No. The check in step 2 listed seven fields the cookie cannot supply. It is enough to drive the page on the next visit, which is a different job from demonstrating what someone agreed to.
Should the record live in the browser or on the server?
Both, for different reasons. The browser copy decides what loads on the next page view. The server copy is the evidence, because it survives a cleared profile and can be produced on request.
What does a version number in the record buy?
It ties the decision to the text that was on screen. Banner wording and the privacy policy both change, and without policyVersion and bannerVersion no one can say afterwards which words a visitor accepted.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76curl 8.21.0puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny