How to check cookies on a website

Load the page, then read the profile's cookie store with CDP Network.getAllCookies instead of document.cookie. The jar returns name, domain, path, expiry, HttpOnly and SameSite for every cookie. On the fixture below the jar held six cookies where document.cookie showed four, missing both HttpOnly ones.

Why check this

A cookie inventory is the input to every other privacy check. The consent banner test, the retention review and the privacy notice all need the same list: which cookies exist, who set them, how long they live. Build it once per release, and again after any change to the tag manager or a third-party script.

The failure it prevents is an inventory that is quietly short. document.cookie is the obvious instrument and it cannot see HttpOnly cookies, by design. On the fixture below that gap hides two names, and one of them is the server-set tracking identifier, the cookie the inventory exists to find. A list built that way looks complete and omits the item that matters.

The second gap is the opposite shape. A cookie a third party tried to set and the browser refused never enters the jar at all, so no jar read will ever mention it. That fact lives in the network events, and a reader who only inspects storage will report a clean result for a request that was trying to track.

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/'));
// pixel-origin.mjs   node pixel-origin.mjs   ->   http://127.0.0.1:9602/px.gif
import { createServer } from 'node:http';
const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'image/gif',
    'set-cookie': ['tp_id=t-55; Max-Age=31536000; Path=/; SameSite=None'] });
  res.end(gif);
}).listen(9602, () => console.log('pixel origin on http://127.0.0.1:9602/'));
// cookie-inventory.mjs   node cookie-inventory.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');

  await page.goto('http://localhost:9601/', { waitUntil: 'networkidle2' });
  await page.click('#accept');
  await page.waitForFunction("state.textContent !== 'consent: not recorded'");

  const js = await page.evaluate(() => document.cookie);
  console.log('document.cookie :', js);

  const { cookies } = await cdp.send('Network.getAllCookies');
  console.log('\nname       domain      path expires    httpOnly sameSite');
  for (const c of cookies.sort((a, b) => a.name.localeCompare(b.name))) {
    console.log([c.name.padEnd(10), c.domain.padEnd(11), c.path.padEnd(4),
      (c.expires === -1 ? 'session' : new Date(c.expires * 1000).toISOString().slice(0, 10)).padEnd(10),
      String(c.httpOnly).padEnd(8), c.sameSite || '(unset)'].join(' '));
  }
  const jsNames = new Set(js.split('; ').map((c) => c.split('=')[0]));
  console.log('\njar count', cookies.length, ' document.cookie count', jsNames.size);
  console.log('in the jar, not in document.cookie :',
    cookies.filter((c) => !jsNames.has(c.name)).map((c) => c.name).join(' '));
} finally {
  await browser.close();
  rmSync(profile, { recursive: true, force: true });
}
// cookie-thirdparty.mjs   node cookie-thirdparty.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 refused = [];
  cdp.on('Network.responseReceivedExtraInfo', (e) => (e.blockedCookies || []).forEach(
    (b) => refused.push(`${b.cookieLine.split(';')[0]}  ${b.blockedReasons.join(',')}`)));

  await page.goto('http://localhost:9601/?tp=1', { waitUntil: 'networkidle2' });
  const { cookies } = await cdp.send('Network.getAllCookies');
  console.log('domains in the jar :', [...new Set(cookies.map((c) => c.domain))].join(' '));
  console.log('cookies on 127.0.0.1 :', cookies.filter((c) => c.domain === '127.0.0.1').length);
  console.log('Set-Cookie refused :', refused.join(' | ') || 'none');
} finally {
  await browser.close();
  rmSync(profile, { recursive: true, force: true });
}

Steps

  1. Step 1.

    Start the site under test.

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

    Start the second origin in another shell, so the page has a cross-site request to inspect.

    node pixel-origin.mjs
    
    pixel origin on http://127.0.0.1:9602/
  3. Step 3.

    Take the inventory: what script can read, then what the browser is holding.

    node cookie-inventory.mjs
    
    document.cookie : fx_seen=1; fx_ga=GA1.2.884.1789; fx_fbp=fb.1.771; fx_consent=accept
    
    name       domain      path expires    httpOnly sameSite
    fx_consent localhost   /    2027-09-12 false    (unset)
    fx_fbp     localhost   /    2027-09-12 false    (unset)
    fx_ga      localhost   /    2027-09-12 false    (unset)
    fx_seen    localhost   /    2027-09-12 false    (unset)
    fx_sid     localhost   /    session    true     (unset)
    fx_uid     localhost   /    2027-09-12 true     (unset)
    
    jar count 6  document.cookie count 4
    in the jar, not in document.cookie : fx_sid fx_uid

    Read the table column by column. expires of session means the cookie dies with the browser, so fx_sid is the only one here that does not persist. The four dated cookies all land on 2027-09-12, one year out, which is the Max-Age the server sent. httpOnly is true for exactly the two names the last line reports as missing from document.cookie, and fx_uid is a tracking identifier. sameSite is unset on all six, so Chrome treats them as Lax.

  4. Step 4.

    Load the variant that pulls an image from the other origin, and read what the browser refused.

    node cookie-thirdparty.mjs
    
    domains in the jar : localhost
    cookies on 127.0.0.1 : 0
    Set-Cookie refused : tp_id=t-55  SameSiteNoneInsecure

    The jar holds one domain and no third-party cookie, which on its own reads as a clean result. The third line says a cookie was offered and rejected, and names the reason: SameSite=None without Secure is refused outright. The attempt is in the inventory only because the network event was read.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | jar count above document.cookie count | HttpOnly cookies exist | Keep the CDP list as the inventory. Note each missing name and its owner. | | expires is session | The cookie ends with the browser | Check it is a session identifier and not analytics wearing a short lifetime. | | A date far in the future | A persistent cookie | Compare it to the retention the privacy notice states. See How to check cookie expiry. | | domain differs from the page host | The cookie belongs to another party | Name the owner and the purpose before deciding which consent category it falls under. | | Set-Cookie refused is not none | Something tried to set a cookie and failed | Read the reason. A refusal today is a cookie that returns as soon as the flag is fixed. | | sameSite is (unset) | The server sent no attribute | Chrome applies Lax. Record the effective value, not the blank. |

Common mistakes

Sign: The inventory is built from document.cookie and looks complete.Cause: document.cookie omits HttpOnly cookies. In the capture above it returned four names while the browser held six, and the two it dropped were the session identifier and the server-set tracking id. The count is short in the direction that matters.
Sign: No third-party cookie appears, and the site is reported as free of them.Cause: A cookie the browser refused never reaches the jar, so no storage read can mention it. Network.responseReceivedExtraInfo carries the attempt and the reason, here SameSiteNoneInsecure. Read the network events before concluding nothing was tried.
Sign: The inventory is taken in a profile that has been used before.Cause: Network.getAllCookies returns the whole profile store, not the cookies of the current page. An old cookie from another site appears in the list and is attributed to the site under test. Start every run in a temporary profile directory.
Sign: The inventory is taken once and treated as the site's cookie list.Cause: Cookie sets depend on the consent decision, the page, and whether the visitor is logged in. The capture above follows one click of Accept all on one page. An audit needs one run per consent path and one per route that loads a different script.

What to check next

FAQ

How do I see website cookies without DevTools open?

Drive Chrome from a script and call Network.getAllCookies over the DevTools Protocol, as in step 3. The browser needs to run; the panel does not. How to check cookie flags with curl covers the part that needs no browser at all.

How do I do a cookie audit?

Run the inventory once per consent path and once per route that loads different scripts, then merge the lists. For each name record the setter, the purpose, the lifetime and whether it is first or third party.

Why does the browser show more cookies than my script?

document.cookie hides HttpOnly cookies from script, which is the point of the flag. The DevTools panel and Network.getAllCookies read the store directly and list them.

Can I scan a website for cookies from the command line?

The command line shows Set-Cookie headers on the responses you request. Cookies written by page script, and cookies set by subresources the browser fetched on its own, need a browser to appear.

Do blocked cookies belong in the inventory?

Yes, as attempts. Record the name, the origin and the block reason. A cookie refused for a missing Secure flag becomes a live cookie the day someone adds it.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-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.

basic8 minpublished updated Maks Verny