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

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

  1. Step 1.

    Start the fixture and leave it running.

    node consent-fixture.mjs
    
    fixture on http://localhost:9601/
  2. Step 2.

    Accept, then read what the browser holds and what the server stored.

    node record-check.mjs accept
    
    cookie   : 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_uid

    Line 3 is the finding. Treated as a record, fx_consent=accept is missing seven of the eight fields: no identifier, no subject, no time, no category breakdown, no policy version, no scope, no method. The localStorage copy on line 2 adds a timestamp and stops there. Line 4 is a record that answers all eight, and line 5 confirms it.

  3. 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.

  4. Step 4.

    Reject in a fresh profile and compare the stored record against the cookie jar.

    node record-check.mjs reject
    
    cookie   : 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_uid

    The last two lines contradict each other. The record states that analytics consent was refused, and the browser is holding fx_ga, fx_fbp and fx_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

Sign: The consent cookie is treated as the consent record.Cause: A cookie holding one word carries no time, no scope, no version and no subject. The field check in step 2 reported seven of eight fields missing. The cookie is a runtime switch for the page, and it was never evidence.
Sign: The record passes every field check and the site still tracks a visitor who refused.Cause: Field validation reads the record against itself. Step 4 shows a record that is complete, internally consistent and contradicted by the cookie jar on the same machine at the same moment. Compare the record to observed behaviour, always in the same run.
Sign: The record is written to localStorage and nowhere else.Cause: Storage is per browser, per profile, and the visitor can clear it. A record that only exists there disappears with the first cache clear, which is the moment it becomes useful.
Sign: Timestamps are stored as local time with no offset.Cause: A record dated 14:20 with no zone cannot be placed against a policy that changed that afternoon. The fixture writes an ISO 8601 instant in UTC, which is the form that survives a server move.

What to check next

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.

intermediate10 minpublished updated Maks Verny