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
- Node 22, the installed Chrome, and
npm i puppeteer-core. - curl for step 3, to read the record the way it would be produced on request.
- The fixture below on port 9601, started before the runs. Its
/echoroute returns theCookieheader the browser sent. - A fresh profile per run. Withdrawal is only meaningful after a grant in the same profile, and the script does both in one session.
- One capture on one machine, Chrome 152 on Windows, 2026-09-12. 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/'));
// 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
- Step 1.
Start the fixture and leave it running.
node consent-fixture.mjsfixture on http://localhost:9601/ - Step 2.
Grant consent, withdraw it, and read the jar on both sides of the click.
node withdraw-run.mjsafter 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=withdrawRead 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, andlocalStoragestill holdsfx_analytics_queue. Quote the last line in the defect report: the browser sentfx_ga,fx_fbpandfx_uidon the next request, as it did while consent was live. - 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" }decisionandwithdrawnAtsay the consent was taken back, andcategoriesstill readsanalytics: true. Keeping the granted categories next to a withdrawal time is right, because the grant was real up to that instant. Code that readscategorieswithoutwithdrawnAtkeeps treating this visitor as consenting. - Step 4.
Run the same flow with the deletion the withdrawal owes, so the expected end state is on paper.
node withdraw-fix.mjsas 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=withdrawThree cookies remain: the session id, the visit flag and the consent state, all of which the site needs to honour the withdrawal. The
Cookieheader 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
What to check next
- How to check a consent record: the fields the record needs before and after a withdrawal.
- How to test cookie consent banner: the grant path, tested by diffing the accept and reject jars.
- How to check cookies on a website: building the list of names that a withdrawal has to clear.
- How to check if local storage is used before consent: the queued event this page found still sitting in
localStorage. - How to test data deletion request: the stronger request, where the server side has to disappear as well.
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.
Related on this site
intermediate10 minpublished updated Maks Verny