A cookie scanner that reads document.cookie answers a narrower question than the law asks. Article 5(3) of the ePrivacy Directive covers storing or accessing any information on a device, and the EDPB spelled out in October 2024 that this reaches local storage, IndexedDB, cached pixels, URL identifiers, ETags and locally computed results. We built a page that stores five identifiers and sets no cookie: document.cookie came back empty while the browser held 1,951 bytes. Then, with storage cleared and no cookies at all, the browser handed an identifier straight back to the server in an If-None-Match header. Here is what to enumerate instead, and the console snippet to do it.
Most consent audits, including the automated ones, are built on a question the law does not ask: which cookies are set? The statute is written about storage, not about cookies, and the gap between the two is where the awkward findings live.
The rule was never about cookies
Article 5(3) of the ePrivacy Directive governs “the storing of information, or the gaining of access to information already stored, in the terminal equipment of a subscriber or user”. The word cookie does not appear. In Guidelines 2/2023 on the technical scope of Article 5(3), version 2.0, adopted on 7 October 2024, the EDPB works through what that actually covers, and three findings matter for anyone running a scan.
The information does not have to be personal data. The Board quotes the Court of Justice: protection applies to any information stored in terminal equipment, “regardless of whether or not it is personal data” (paragraph 10). A random opaque string qualifies.
Storing and accessing are separate triggers. “Storing of information and access to information already stored do not need to be both present for Article 5(3) ePD to apply” (paragraph 30). Reading something you did not put there counts on its own, and paragraph 31 is explicit that there are no restrictions on the origin of the information.
Locally generated data is not automatically exempt. Paragraph 44 does carve out information that a browser or app processes on the device — but only “as long as the information does not leave the device”. The moment that value, or any derivation of it, is sent anywhere, Article 5(3) applies again. Paragraphs 52 and 53 make the same point about results computed in the browser and then read through a client-side API: being produced locally “does not preclude the application of Article 5(3)”.
What a document.cookie scan misses
We wrote a fixture page for this post: it stores an identifier in five different places and sets no cookie whatsoever. Loaded in Chromium on 4 September 2026, enumerating each bucket returned this.
document.cookie (empty string)
localStorage _vid = a3f9c1e07b2d4488
sessionStorage _sid = sess-77120
indexedDB analytics_queue (v1)
cacheStorage tracker-v1
serviceWorkers http://localhost:8477/
navigator.storage.estimate().usage 1951
A cookie-shaped audit reports a clean page. The browser is holding 1,951 bytes and a stable visitor identifier.
None of this is exotic. Storing an identifier in localStorage instead of a cookie is one line of code, it survives the cookie-clearing that users actually do, and it is invisible to the tooling most teams point at their own site.
The ETag is the one people miss entirely
Paragraph 43 of the guidelines names ETag and HSTS directly as mechanisms whose use to collect information can bring Article 5(3) into play. This is worth demonstrating, because it needs no storage API at all.
An ETag is a cache validator: the server labels a response, the browser stores the label, and on the next request it sends the label back so the server can reply “unchanged”. Nothing stops that label from being a per-visitor identifier. Our fixture served a 1×1 pixel with ETag: "id-8f14e45fceea167a". We then cleared localStorage, confirmed there were no cookies, and triggered a revalidating request. The server logged:
[px] If-None-Match: "id-8f14e45fceea167a"
No cookie. No storage API. The browser returned the identifier because that is what HTTP caching is for. Paragraph 50 anticipates exactly this: distributing a pixel “does constitute storage, at the very least through the caching mechanism of the client-side software”, and the guidelines add that this holds “even if this storage is not permanent”.
The same paragraph and the one after it cover tracking links — an identifier appended to a URL is, in the Board’s reading, an instruction to the device to send that identifier back, and so counts as gaining access. Your UTM-adjacent affiliate parameters are in scope, whatever your scanner says.
Enumerate the buckets yourself
Open a page in a clean profile, do not touch the banner, and run this in the console. It takes a few seconds and usually changes the conversation.
const dbs = typeof indexedDB.databases === 'function'
? (await indexedDB.databases()).map(d => `${d.name} (v${d.version})`)
: ['databases() unavailable'];
console.table({
cookies: document.cookie || '(empty)',
localStorage: Object.keys(localStorage).join(', ') || '(empty)',
sessionStorage: Object.keys(sessionStorage).join(', ') || '(empty)',
indexedDB: dbs.join(', ') || '(none)',
cacheStorage: (await caches.keys()).join(', ') || '(none)',
serviceWorkers: (await navigator.serviceWorker.getRegistrations())
.map(r => r.scope).join(', ') || '(none)',
bytesUsed: (await navigator.storage.estimate()).usage,
});
indexedDB.databases() is the only line with a support caveat; MDN lists it as Baseline, newly available since May 2024, so current browsers are fine and older ones will fall through to the message rather than throwing.
Three things to know before you trust the output.
document.cookie does not show HttpOnly cookies. Anything set with that flag is invisible to JavaScript by design. To see the real set you need the browser’s own view — context.cookies() in Playwright, Network.getAllCookies over the DevTools protocol, or the Application panel.
Cookies ignore the port; storage does not. This bit us while measuring for this post. The first run reported three cookies the fixture had never set, because cookies are scoped by host and every previous dev server on localhost shares that host. localStorage and IndexedDB are keyed by full origin, port included, so they stayed clean. If you audit on localhost, clear cookies first or your results are someone else’s.
Read storage before you interact with anything. The interesting question is what exists before consent, and a single click on a banner destroys the evidence for that page load.
What to do with what you find
Finding a localStorage key is the start of the work, not the end. Trace it: does the value leave the device? Paragraph 44 is the dividing line, and it is a question about network traffic, not about storage. A preference key that never leaves is a different thing from an identifier posted to an analytics endpoint, even though both look identical in the console.
Then check the other direction. Our Playwright script for detecting pre-consent tracking captures the requests; the snippet above captures the residue. You want both, because each misses what the other catches. And withdrawal is a third question again: rejecting cookies does not delete them, and it does not empty localStorage or IndexedDB either — the same libraries that clear cookies on withdrawal generally leave every other bucket untouched.
If your last audit produced a short, clean list, that is worth a second look rather than relief. We wrote about the gap between CMP configuration and real-world behaviour before; this is the same gap seen from the storage side, and a scanner scoped to cookies will report a clean bill of health on a page that is holding a stable identifier in four other places.
Auditing storage means looking at what a page holds before anyone clicks, on more than the one URL you happen to test. Run an audit across your site with CookieInspector.