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
- 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. - DOMStorage.domStorageItemAdded and IndexedDB.requestDatabase in the protocol reference.
- Your own fixture, never somebody else's site. Save this as
fixture.mjs, runnode fixture.mjs, stop it by PID afterwards. Ports 9610 and 9611 were free on this machine. It writes twolocalStoragekeys, onesessionStoragekey and one IndexedDB record during the load, with the banner still unanswered.
// 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)');
- The figures below are one capture on one machine, Chrome 152 on 2026-09-12.
Steps
- 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: trueThree 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.
- 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
requestDatawith an empty index name fails on a store that has no index, so the values come from a read inside the page. - 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-4821The 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. - 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; donesid=s-7f3a2b {"items":1} <- HTTP 200 ab=variant-b {"error":"no session"} <- HTTP 401 _tk_uid=u-4821 {"error":"no session"} <- HTTP 401The same test applies to a storage key.
cart_draftholds a basket the visitor built, so removing it loses work the visitor asked for._tk_uidis 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
What to check next
- How to check if cookies are set without consent: the cookie half of the same timing question, including cookies no script can see.
- How to check if tracking scripts load before consent: whether the identifier in storage also left the browser.
- How to check localstorage in chrome: reading and editing keys by hand when consent is not the question.
- How to check session storage in chrome: the per-tab area, which the same events cover.
- How to check if a service worker is registered: prints the
open()launcher these scripts import.
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.
Related on this site
intermediate11 minpublished updated Maks Verny