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

// 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)');

Steps

  1. 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: chunked

    Two cookies arrive with the HTML, before a single line of script has run. curl sends no consent signal and receives them anyway.

  2. Step 2.

    Record every Set-Cookie header 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: SchemefulSameSiteUnspecifiedTreatedAsLax

    The third entry is an attempt, not a cookie. Chrome refused it, and it will never appear in any jar you read.

  3. Step 3.

    Read the whole jar at the moment before the banner is answered, and put document.cookie beside 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-4821

    Three cookies stored, two visible to script. sid is HttpOnly and missing from document.cookie, so the jar read is the only one of the two that is complete.

  4. 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: consent

    The button adds one cookie, the record of the answer. Everything the banner claims to gate was stored before it was pressed.

  5. 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; done
    
    sid=s-7f3a2b           {"items":1} <- HTTP 200
    ab=variant-b           {"error":"no session"} <- HTTP 401
    _tk_uid=u-4821         {"error":"no session"} <- HTTP 401

    sid carries 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

Sign: document.cookie is used as the list of cookies the page set.Cause: It omits every HttpOnly cookie. In the capture above the jar holds sid, ab and _tk_uid while document.cookie returns two of them, and the missing one is the session cookie, the single item most likely to be argued about. It also cannot say which of the two it does return came from a response header and which from a script, because both look identical once stored.
Sign: The jar is read and the result is treated as everything that was attempted.Cause: Chrome refused tp_id with SchemefulSameSiteUnspecifiedTreatedAsLax, so it is in the header log and in no jar. Reading storage alone reports the browser's defences, not the site's behaviour, and a visitor on a build with different defaults gets a different answer.
Sign: The tester clicks Reject first, then opens the cookie panel.Cause: By then the pre-consent writes have happened and the rejection may have cleared some of them, so the panel shows the state after two events instead of the state before either. Read the jar in a fresh profile with the banner still on screen. The scripts above launch a new profile every run for that reason.

What to check next

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.

intermediate12 minpublished updated Maks Verny