How to check if local storage is used before consent

Subscribe to DOMStorage.domStorageItemAdded in a fresh profile and read the IndexedDB list before you touch the banner. On the fixture below three keys are written 29 ms into the load and one database is already open, while the banner is still on screen and document.cookie shows none of it.

Why check this

Run this beside the cookie check on every release candidate, and again after any change to a tag manager or an analytics SDK. Consent tooling grew up around cookies, so a vendor that moved its identifier from a cookie to localStorage disappears from the cookie banner report and keeps working exactly as before.

The failure it catches is an identifier that survives a rejected banner. A tester clears cookies, reloads, and sees the same visitor id come back, because it was never in a cookie. The regulation that people quote at cookie banners covers storing and reading information on a user's device, and it names no storage mechanism, so localStorage and IndexedDB sit in the same category as the cookie next to them.

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.

    Record the storage writes as they happen, rather than reading the result afterwards.

    // dom.mjs: localStorage and sessionStorage writes, recorded as they happen.
    import { open } from './session.mjs';
    const s = await open();
    const cdp = await s.page.createCDPSession();
    await cdp.send('DOMStorage.enable');
    const t0 = Date.now();
    cdp.on('DOMStorage.domStorageItemAdded', (e) =>
      console.log(`+${String(Date.now() - t0).padStart(4)} ms  ` +
        `${(e.storageId.isLocalStorage ? 'localStorage' : 'sessionStorage').padEnd(14)} ${e.key} = ${e.newValue}`));
    await s.goto('http://localhost:9610/');
    await new Promise((r) => setTimeout(r, 900));
    console.log('\nbanner still on the page:', await s.page.evaluate(() => !!document.getElementById('banner')));
    await s.close();
    
    +  28 ms  sessionStorage nav_start = 1789199614744
    +  29 ms  localStorage   _tk_uid = u-4821
    +  29 ms  localStorage   cart_draft = {"sku":"A-1"}
    
    banner still on the page: true

    Three writes, each with a timestamp, and the banner is still unanswered when the last one lands. The event carries the key and the value, so the finding is specific before anyone opens a storage panel.

  2. Step 2.

    List the IndexedDB the page opened, keyed by origin.

    // idb.mjs: the IndexedDB the page opened before the banner was answered.
    import { open } from './session.mjs';
    const s = await open();
    const cdp = await s.page.createCDPSession();
    await cdp.send('IndexedDB.enable');
    await s.goto('http://localhost:9610/');
    await new Promise((r) => setTimeout(r, 900));
    const { frameTree } = await cdp.send('Page.getFrameTree');
    const { storageKey } = await cdp.send('Storage.getStorageKeyForFrame', { frameId: frameTree.frame.id });
    console.log('storage key:', storageKey);
    const { databaseNames } = await cdp.send('IndexedDB.requestDatabaseNames', { storageKey });
    for (const databaseName of databaseNames) {
      const { databaseWithObjectStores: db } = await cdp.send('IndexedDB.requestDatabase', { storageKey, databaseName });
      for (const st of db.objectStores)
        console.log(`  ${db.name} v${db.version} / ${st.name}  keyPath=${st.keyPath.string}  indexes=${st.indexes.length}`);
    }
    try {
      await cdp.send('IndexedDB.requestData', { storageKey, databaseName: 'analytics',
        objectStoreName: 'events', indexName: '', skipCount: 0, pageSize: 10 });
    } catch (e) {
      console.log('  requestData indexName="" ->', e.message.split('\n')[0]);
    }
    console.log('  records, read from the page instead:', await s.page.evaluate(() => new Promise((res) => {
      const r = indexedDB.open('analytics');
      r.onsuccess = (e) => {
        const q = e.target.result.transaction('events').objectStore('events').getAll();
        q.onsuccess = () => res(JSON.stringify(q.result));
      };
    })));
    await s.close();
    
    storage key: http://localhost:9610/
    analytics v1 / events  keyPath=id  indexes=0
    requestData indexName="" -> Protocol error (IndexedDB.requestData): Could not get index
    records, read from the page instead: [{"id":1,"e":"pageview","uid":"u-4821"}]

    The database and its object store are visible over the protocol. The record is not, because requestData with an empty index name fails on a store that has no index, so the values come from a read inside the page.

  3. Step 3.

    Ask the browser for the origin's storage usage and compare it with what step 1 found.

    // quota.mjs: what a storage-usage audit sees, and what it leaves out.
    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, 900));
    const u = await cdp.send('Storage.getUsageAndQuota', { origin: 'http://localhost:9610' });
    for (const b of u.usageBreakdown) console.log(`${b.storageType.padEnd(16)} ${b.usage} bytes`);
    console.log(`total            ${u.usage} bytes`);
    console.log('\nlocalStorage keys the page holds:',
      await s.page.evaluate(() => Object.keys(localStorage).join(', ')));
    console.log('document.cookie: ', await s.page.evaluate(() => document.cookie));
    await s.close();
    
    file_systems     0 bytes
    indexeddb        1734 bytes
    cache_storage    0 bytes
    service_workers  0 bytes
    total            1734 bytes
    
    localStorage keys the page holds: cart_draft, _tk_uid
    document.cookie:  ab=variant-b; _tk_uid=u-4821

    The breakdown has four rows and none of them is localStorage. Two keys are in the page at that moment and contribute zero bytes to the quota report, so a usage figure is not an audit.

  4. Step 4.

    Separate the storage that serves the visitor from the storage that serves the operator.

    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

    The same test applies to a storage key. cart_draft holds a basket the visitor built, so removing it loses work the visitor asked for. _tk_uid is the identifier in the beacon query string, and nothing the visitor requested depends on it.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | A write logged before the click | Information stored on the device without consent | File it with the key name, the value and the offset from the log. | | The same value in a key and in a cookie | One identifier written twice | Clearing cookies alone restores it. Say so in the report, or the fix will miss half of it. | | An IndexedDB database open on load | A store created before any answer | Read its object stores with step 2 and its records from the page. | | indexeddb bytes but no localStorage row | The quota API does not cover web storage | Use the DOMStorage events. A usage figure cannot answer this question. | | A key the site cannot work without | Closer to strictly necessary | Keep the judgement per key. A draft basket and a visitor id are not the same case. |

Common mistakes

Sign: The storage panel is opened after the banner has been answered.Cause: By then the pre-consent writes and the post-consent writes are the same three keys in one list, with nothing to separate them. The DOMStorage events in step 1 carry the offset, 29 ms in this capture, and that offset is the entire finding.
Sign: Storage.getUsageAndQuota is used as the storage inventory.Cause: Its breakdown returned file_systems, indexeddb, cache_storage and service_workers, and no row for localStorage or sessionStorage at all. Two localStorage keys were present in the same capture. An audit built on usage figures reports zero for the mechanism most identifiers now live in.
Sign: IndexedDB.requestData is called with an empty index name and the error is read as a missing database.Cause: Chrome 152 answers Could not get index for a store that has no index, even though the database and the store are listed a line earlier and an empty index name means the store itself. The same call succeeds once the store has an index. Read the records from the page instead of concluding the database is absent.

What to check next

FAQ

Does local storage require cookie consent?

The rule people quote at cookie banners is about storing or gaining access to information on a user's device, and it names no mechanism. A localStorage identifier is in the same category as the cookie beside it. A draft basket the visitor typed is the exception, on the same reasoning that covers a session cookie.

Why does a cleared cookie jar not clear the identifier?

Clearing cookies leaves localStorage, sessionStorage, IndexedDB and the Cache API untouched. The capture above holds _tk_uid in both a cookie and a key, so a test that clears only cookies watches the value come straight back and reports a bug that is not there.

Is sessionStorage exempt because it ends with the tab?

Lifetime changes the risk, not the category. nav_start was written 28 ms into the load with the banner unanswered. Treat it as a finding of lower severity and keep it in the report.

How do I check this without writing a script?

Open DevTools, Application panel, Storage, and read Local Storage, Session Storage and IndexedDB before touching the banner. The panel shows the state and not the moment, so a script is what you need when the question is whether a write happened before the click.

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.

intermediate11 minpublished updated Maks Verny