How to revoke cookie consent

Accept, then click Withdraw consent and read the cookie jar again over CDP. The fixture updated the banner, the state cookie and the stored record, and left fx_ga, fx_fbp and fx_uid in place. On the next page load the browser still sent all three to the server.

Why check this

Withdrawal is the path nobody exercises. The accept button is clicked in every smoke test, the reject button in most, and the link that takes consent back is tested once, by hand, by reading the dialog. Run this in regression on any release that touches the banner or the preference centre.

The failure it catches is a withdrawal that changes the interface and nothing else. The banner comes back, the state cookie flips, the record gains a withdrawal timestamp, and the identifiers written while consent was live stay in the jar. Nothing deleted them, because deletion is a separate piece of code from recording the decision, and only the recording part is visible on screen.

GDPR Article 7(3) requires withdrawal to be as easy as giving consent, and stops processing going forward. In engineering terms the identifiers have to stop arriving at the server. A cookie still sent on every request is a live identifier whatever the banner says, so the check ends at the request, not at the dialog.

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/'));
// withdraw-run.mjs   node withdraw-run.mjs
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

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');
  const jar = async () => (await cdp.send('Network.getAllCookies')).cookies
    .filter((c) => c.domain === 'localhost').map((c) => c.name).sort().join(' ');
  const store = () => page.evaluate(() => Object.keys(localStorage).sort().join(' '));
  const click = async (id) => {
    await page.evaluate(() => (window.__last = state.textContent));
    await page.click('#' + id);
    await page.waitForFunction('state.textContent !== window.__last');
  };

  await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
  await click('accept');
  console.log('after accept   jar:', await jar());
  console.log('after accept   localStorage:', await store());

  await click('withdraw');
  console.log('\nUI says        :', await page.$eval('#state', (e) => e.textContent));
  console.log('banner back    :', await page.$eval('#banner', (e) => !e.hidden));
  console.log('record         :', JSON.stringify(await page.evaluate(
    () => fetch('/consent/record').then((r) => r.json()))).replace(/","/g, '", "'));
  console.log('\nafter withdraw jar:', await jar());
  console.log('after withdraw localStorage:', await store());

  await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
  console.log('\nnext page load jar:', await jar());
  console.log('server saw        :', (await page.evaluate(
    () => fetch('/echo').then((r) => r.json()))).cookieHeader);
} finally {
  await browser.close();
  rmSync(profile, { recursive: true, force: true });
}
// withdraw-fix.mjs   node withdraw-fix.mjs
import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const NON_ESSENTIAL = ['fx_ga', 'fx_fbp', 'fx_uid'];
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');
  const jar = async () => (await cdp.send('Network.getAllCookies')).cookies
    .filter((c) => c.domain === 'localhost').map((c) => c.name).sort().join(' ');

  await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
  await page.click('#accept');
  await page.waitForFunction("state.textContent !== 'consent: not recorded'");
  await page.click('#withdraw');
  await page.waitForFunction("state.textContent === 'consent: withdraw'");
  console.log('as the fixture leaves it :', await jar());

  for (const name of NON_ESSENTIAL) await cdp.send('Network.deleteCookies', { name, url: 'http://localhost:9601/' });
  await page.evaluate(() => localStorage.removeItem('fx_analytics_queue'));
  console.log('after the deletion       :', await jar());
  console.log('localStorage             :', await page.evaluate(() => Object.keys(localStorage).sort().join(' ')));

  await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
  console.log('server saw               :', (await page.evaluate(
    () => fetch('/echo').then((r) => r.json()))).cookieHeader);
} 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.

    Grant consent, withdraw it, and read the jar on both sides of the click.

    node withdraw-run.mjs
    
    after accept   jar: fx_consent fx_fbp fx_ga fx_seen fx_sid fx_uid
    after accept   localStorage: fx_analytics_queue fx_consent
    
    UI says        : consent: withdraw
    banner back    : true
    record         : {"id":"cr-qi2649vz", "subject":"sid-7f3a", "decision":"withdraw", "at":"2026-09-12T07:58:50.470Z", "categories":{"necessary":true,"analytics":true,"marketing":true},"policyVersion":"2026-03-01", "bannerVersion":"1.4.0", "scope":"http://localhost:9601", "method":"banner-button", "withdrawnAt":"2026-09-12T07:58:50.488Z"}
    
    after withdraw jar: fx_consent fx_fbp fx_ga fx_seen fx_sid fx_uid
    after withdraw localStorage: fx_analytics_queue fx_consent
    
    next page load jar: fx_consent fx_fbp fx_ga fx_seen fx_sid fx_uid
    server saw        : fx_sid=sid-7f3a; fx_seen=1; fx_ga=GA1.2.884.1789; fx_fbp=fb.1.771; fx_uid=u-91af; fx_consent=withdraw

    Read it in two halves. The withdrawal worked: the label changed, the banner came back, the record gained withdrawnAt, and the next page load wrote no new tracking cookie. Then read the jar lines. The six names before the withdrawal are the six names after it, and localStorage still holds fx_analytics_queue. Quote the last line in the defect report: the browser sent fx_ga, fx_fbp and fx_uid on the next request, as it did while consent was live.

  3. Step 3.

    Read the stored record on its own, the way it would be produced on request.

    curl -s -b "fx_sid=sid-7f3a" http://localhost:9601/consent/record
    
    {
    "id": "cr-qi2649vz",
    "subject": "sid-7f3a",
    "decision": "withdraw",
    "at": "2026-09-12T07:58:50.470Z",
    "categories": {
      "necessary": true,
      "analytics": true,
      "marketing": true
    },
    "policyVersion": "2026-03-01",
    "bannerVersion": "1.4.0",
    "scope": "http://localhost:9601",
    "method": "banner-button",
    "withdrawnAt": "2026-09-12T07:58:50.488Z"
    }

    decision and withdrawnAt say the consent was taken back, and categories still reads analytics: true. Keeping the granted categories next to a withdrawal time is right, because the grant was real up to that instant. Code that reads categories without withdrawnAt keeps treating this visitor as consenting.

  4. Step 4.

    Run the same flow with the deletion the withdrawal owes, so the expected end state is on paper.

    node withdraw-fix.mjs
    
    as the fixture leaves it : fx_consent fx_fbp fx_ga fx_seen fx_sid fx_uid
    after the deletion       : fx_consent fx_seen fx_sid
    localStorage             : fx_consent
    server saw               : fx_sid=sid-7f3a; fx_seen=1; fx_consent=withdraw

    Three cookies remain: the session id, the visit flag and the consent state, all of which the site needs to honour the withdrawal. The Cookie header on the next request carries no identifier. That is the expected end state, stated as an observation rather than an opinion.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The jar is unchanged after withdrawal | Nothing deleted the identifiers | File it. The dialog is correct and the outcome is not. | | server saw still lists tracking cookies | The identifier is live on every request | Quote this line. It is the shortest proof that the withdrawal did not take effect. | | A new cookie appears after withdrawal | Something writes on the way out | Name the setter before anything else. This is the more serious of the two failures. | | localStorage keeps a queued event | The page will send it on the next visit | Storage needs the same clearing as cookies. | | Only the state cookie changed | The withdrawal is a display change | Compare against the end state in step 4. | | A long-lived cookie survives a cleared session | The identifier outlives the visit | Check its lifetime in How to check cookie expiry. |

Common mistakes

Sign: The test ends when the confirmation appears and the banner returns.Cause: Both are DOM changes in the success callback of the same request that records the decision. In the capture above they were correct while six cookies stayed in the jar. Read the jar after the dialog, never instead of it.
Sign: Withdrawal is tested in a clean profile with no prior consent.Cause: With nothing granted there is nothing to delete, so the test passes on an empty jar. Grant first, in the same profile and the same run, then withdraw. That is the only sequence that exercises the deletion path.
Sign: The jar is checked and the request is not.Cause: A cookie the page cannot read may still be sent. fx_uid is HttpOnly, so a document.cookie check misses it in both directions. Reading the Cookie header the server received, as the /echo route does, settles it in one line.
Sign: The record says withdrawn and downstream code keeps tracking.Cause: The record in step 3 keeps categories analytics true alongside withdrawnAt, which is correct as history. Code that branches on categories alone reads a withdrawn visitor as consenting. Check both fields wherever the record is consumed.

What to check next

FAQ

How do I delete cookies on withdrawal?

Send expired Set-Cookie headers for every non-essential name on the withdrawal response, with the same domain and path they were written with. Then repeat the jar read and the Cookie header read, as step 4 does, to confirm the browser dropped them.

Does withdrawal have to remove cookies that were already set?

Article 7(3) stops processing from that point and does not undo what came before. A cookie still sent on every request is still an identifier the server can act on, so the practical test is the Cookie header after the withdrawal, not the record.

Can I test this without a browser?

Only the response half. curl shows whether the withdrawal endpoint sends expiring Set-Cookie headers. Whether the browser drops them, and what it sends next, needs a real jar.

Why did the banner reappear if nothing was deleted?

The interface reads the state cookie, and the state cookie changed. Deleting identifiers is separate code, often owned by a different team, and its absence leaves no trace on screen.

What should the record gain on withdrawal?

A withdrawal timestamp next to the original grant, not an overwrite. The capture in step 3 keeps at and adds withdrawnAt, which preserves the period when processing was lawful and marks the instant it stopped.

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