How to check if cookies are set without consent
Load the page in a fresh browser profile, record Network.responseReceivedExtraInfo and read the whole jar with Network.getAllCookies before you touch the banner. On the fixture below three cookies are already stored at that moment, sid, ab and _tk_uid, and the Accept button adds one.
Why check this
This runs on the release candidate, after any change to the tag manager, the A/B framework or the banner vendor, and again when marketing asks for a new pixel. It is a timing check, not an inventory: the question is not which cookies a site sets, it is which of them exist before the visitor has answered anything.
The defect it catches is a cookie that moved from the post-consent branch into the document response. An A/B variant assigned in the HTTP response, or an identifier written by an inline script above the banner, is stored on every visit including the ones that end in Reject. Nobody sees it in a diff, because the banner still works and the cookie list on the privacy page is unchanged.
Prerequisites
- Node 22 and the Chrome on this machine, driven by
npm i puppeteer-core. Theopen()launcher these scripts import is printed in full in How to check if a service worker is registered; save it assession.mjsnext to them. - Network.getAllCookies and Network.responseReceivedExtraInfo in the protocol reference.
- Your own fixture, never somebody else's site. One page load is not enough evidence to accuse a real operator of anything, and scanning a host you do not own is out of bounds. Save this as
fixture.mjs, runnode fixture.mjs, and stop it by PID when you are finished. Ports 9610 and 9611 were free on this machine.
// fixture.mjs - consent-timing fixture. Two origins, one deliberate violation each.
import { createServer } from 'node:http';
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
const TAG = 'http://127.0.0.1:9611';
const PAGE = `<!doctype html><meta charset=utf-8><title>Consent fixture</title>
<h1>Checkout</h1>
<div id=banner>We use cookies. <button id=accept>Accept all</button></div>
<script>
document.cookie = '_tk_uid=u-4821; path=/; max-age=31536000'; // script cookie, pre-consent
localStorage.setItem('_tk_uid', 'u-4821'); // localStorage, pre-consent
localStorage.setItem('cart_draft', '{"sku":"A-1"}');
sessionStorage.setItem('nav_start', String(Date.now()));
const r = indexedDB.open('analytics', 1); // IndexedDB, pre-consent
r.onupgradeneeded = (e) => e.target.result.createObjectStore('events', { keyPath: 'id' });
r.onsuccess = (e) => e.target.result.transaction('events', 'readwrite')
.objectStore('events').add({ id: 1, e: 'pageview', uid: 'u-4821' });
document.getElementById('accept').onclick = () => {
document.cookie = 'consent=all; path=/; max-age=15552000';
document.getElementById('banner').remove();
dispatchEvent(new Event('consent'));
};
</script>
<script src="${TAG}/tag.js"></script>
<script src="${TAG}/defer.js"></script>`;
createServer((req, res) => { // first party, localhost:9610
if (req.url === '/cart') { // the function sid exists for
const ok = (req.headers.cookie || '').includes('sid=');
return res.writeHead(ok ? 200 : 401, { 'content-type': 'application/json' })
.end(ok ? '{"items":1}' : '{"error":"no session"}');
}
if (req.url !== '/') return res.writeHead(404).end();
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'set-cookie': ['sid=s-7f3a2b; Path=/; HttpOnly; SameSite=Lax', // strictly necessary
'ab=variant-b; Path=/; Max-Age=7776000'], // header cookie, pre-consent
});
res.end(PAGE);
}).listen(9610, 'localhost');
createServer((req, res) => { // tag origin, 127.0.0.1:9611
const u = new URL(req.url, TAG);
if (u.pathname === '/tag.js') // sends on load
return res.writeHead(200, { 'content-type': 'text/javascript' })
.end(`new Image().src = '${TAG}/collect?e=pageview&uid=u-4821';`);
if (u.pathname === '/defer.js') // loads now, sends after consent
return res.writeHead(200, { 'content-type': 'text/javascript' })
.end(`addEventListener('consent', () => { new Image().src = '${TAG}/collect?e=consented'; });`);
if (u.pathname === '/collect')
return res.writeHead(200, { 'content-type': 'image/gif', 'set-cookie': 'tp_id=t-99; Path=/' }).end(GIF);
res.writeHead(404).end();
}).listen(9611, '127.0.0.1');
console.log('fixture on http://localhost:9610/ (site) and http://127.0.0.1:9611/ (tag origin)');
- Every browser figure below is one capture on one machine, Chrome 152 on 2026-09-12.
Steps
- Step 1.
Read the response headers the document itself carries.
curl -sS -D - -o /dev/null http://localhost:9610/HTTP/1.1 200 OK content-type: text/html; charset=utf-8 set-cookie: sid=s-7f3a2b; Path=/; HttpOnly; SameSite=Lax set-cookie: ab=variant-b; Path=/; Max-Age=7776000 Date: Sat, 12 Sep 2026 07:59:27 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunkedTwo cookies arrive with the HTML, before a single line of script has run.
curlsends no consent signal and receives them anyway. - Step 2.
Record every
Set-Cookieheader the browser receives, with the time it arrived.// hdrs.mjs: every Set-Cookie header the fixture sends, with the time it arrived. import { open } from './session.mjs'; const s = await open(); const cdp = await s.page.createCDPSession(); await cdp.send('Network.enable'); const req = new Map(); let t0 = null; cdp.on('Network.requestWillBeSent', (e) => { if (t0 === null) t0 = e.timestamp; req.set(e.requestId, e); }); cdp.on('Network.responseReceivedExtraInfo', (e) => { const raw = e.headers['set-cookie']; if (!raw) return; const r = req.get(e.requestId); for (const line of raw.split('\n')) { const b = (e.blockedCookies || []).find((x) => x.cookieLine === line); console.log(`+${String(Math.round((r.timestamp - t0) * 1000)).padStart(3)} ms ${r.request.url}`); console.log(` ${line}${b ? '\n BLOCKED: ' + b.blockedReasons.join(', ') : ''}`); } }); await s.goto('http://localhost:9610/'); await new Promise((r) => setTimeout(r, 800)); await s.close();+ 0 ms http://localhost:9610/ sid=s-7f3a2b; Path=/; HttpOnly; SameSite=Lax + 0 ms http://localhost:9610/ ab=variant-b; Path=/; Max-Age=7776000 + 21 ms http://127.0.0.1:9611/collect?e=pageview&uid=u-4821 tp_id=t-99; Path=/ BLOCKED: SchemefulSameSiteUnspecifiedTreatedAsLaxThe third entry is an attempt, not a cookie. Chrome refused it, and it will never appear in any jar you read.
- Step 3.
Read the whole jar at the moment before the banner is answered, and put
document.cookiebeside it.// jar.mjs: the whole cookie jar before the banner is answered, next to document.cookie. import { open } from './session.mjs'; const s = await open(); const cdp = await s.page.createCDPSession(); await s.goto('http://localhost:9610/'); await new Promise((r) => setTimeout(r, 800)); const { cookies } = await cdp.send('Network.getAllCookies'); console.log('name domain httpOnly expires value'); for (const c of cookies) console.log( `${c.name.padEnd(10)} ${c.domain.padEnd(10)} ${String(c.httpOnly).padEnd(9)} ` + `${(c.session ? 'session' : new Date(c.expires * 1000).toISOString().slice(0, 10)).padEnd(11)} ${c.value}` ); console.log('\ndocument.cookie:', await s.page.evaluate(() => document.cookie)); await s.close();name domain httpOnly expires value sid localhost true session s-7f3a2b ab localhost false 2026-12-11 variant-b _tk_uid localhost false 2027-09-12 u-4821 document.cookie: ab=variant-b; _tk_uid=u-4821Three cookies stored, two visible to script.
sidisHttpOnlyand missing fromdocument.cookie, so the jar read is the only one of the two that is complete. - Step 4.
Click Accept and compare the names on both sides of the click.
// click.mjs: what the Accept button actually adds to a jar that is already full. import { open } from './session.mjs'; const s = await open(); const cdp = await s.page.createCDPSession(); const names = async () => (await cdp.send('Network.getAllCookies')).cookies.map((c) => c.name); await s.goto('http://localhost:9610/'); await new Promise((r) => setTimeout(r, 800)); const before = await names(); await s.page.click('#accept'); await new Promise((r) => setTimeout(r, 800)); const after = await names(); console.log('before the click:', before.length, '->', before.join(', ')); console.log('after the click: ', after.length, '->', after.join(', ')); console.log('added by the click:', after.filter((n) => !before.includes(n)).join(', ') || '(none)'); await s.close();before the click: 3 -> sid, ab, _tk_uid after the click: 4 -> sid, ab, _tk_uid, consent added by the click: consentThe button adds one cookie, the record of the answer. Everything the banner claims to gate was stored before it was pressed.
- Step 5.
Test the "strictly necessary" claim. Send each cookie on its own and ask for the function it is said to serve.
for c in "sid=s-7f3a2b" "ab=variant-b" "_tk_uid=u-4821"; do printf '%-22s ' "$c"; curl -s -o - -w ' <- HTTP %{http_code}\n' -b "$c" http://localhost:9610/cart; donesid=s-7f3a2b {"items":1} <- HTTP 200 ab=variant-b {"error":"no session"} <- HTTP 401 _tk_uid=u-4821 {"error":"no session"} <- HTTP 401sidcarries the basket. The other two carry nothing the visitor asked for, so the exception the banner relies on covers one of the three cookies it set.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| A name in the jar before the click | Set without consent, whatever the script does later | Trace it to a header or a script with step 2, then judge whether it is necessary. |
| A set-cookie header on the document response | The server set it, no script involved | A consent script cannot suppress it. The fix is in the response, not in the tag manager. |
| BLOCKED under a header line | The browser refused the cookie | Record the attempt. The party still tried, and another browser may allow it. |
| The click adds one name only | The banner records the answer and gates nothing | Compare with the request log before calling that harmless. |
| A removed cookie whose function still returns HTTP 200 | The function works without it | It is not strictly necessary, whichever category the banner puts it in. |
Common mistakes
What to check next
- How to check if tracking scripts load before consent: the same timing question applied to requests, where loading and sending are different findings.
- How to check if local storage is used before consent: the writes that are not cookies and that most consent tooling never looks at.
- How to check cookie expiry: reads the lifetime that separates a session cookie from a 90 day identifier.
- How to check if cookies are secure and HttpOnly: the flags that decide whether a cookie is visible to the script you are auditing.
- How to check if a service worker is registered: prints the
open()launcher these scripts import.
FAQ
Which cookies may be set before consent?
Only the ones a requested function cannot work without: the session identifier, a load balancer affinity value, a fraud token on a payment step. Step 5 is the test. Remove the cookie, ask for the function, and read the status code. Analytics, A/B assignment and advertising identifiers fail it.
How do I prove which cookie was issued without user consent?
Read the jar before any click, in a fresh profile, as in step 3. Anything present at that moment was issued without consent. The header log from step 2 then tells you whether a server or a script put it there.
How do I detect cookies loading before consent without writing a script?
Open DevTools, Application panel, Storage, Cookies, before touching the banner, and keep the Network panel on the document request to read its set-cookie headers. A script earns its place when the check has to run on every build.
Does a cookie the browser blocked still count?
Treat it as an attempt to record, not a violation to report. Chrome blocked tp_id on its SameSite default. Another browser, or the same one after a third party moves to SameSite=None; Secure, stores it. The header log is the durable evidence.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0curl 8.21.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
intermediate12 minpublished updated Maks Verny