Look in Your Own Browser: Which Tokens Are Really There
In the first part the subject was the theory: a JWT is a format, not a login system, and its biggest problem as a browser session is that you can’t revoke it. It’s valid until it expires, no matter what happens in the meantime. From that follows an inconvenient property that tends to slip by in practice: logging in again mints new tokens, it doesn’t kill the old ones. The old copy stays valid and keeps lying around somewhere until its expiry date is reached.
That sounds like theory until you look at what it does in the real world. Security researchers at Group-IB found stored ChatGPT credentials on more than 100,000 devices, collected from the logs of infostealer malware, very largely through a stealer family called Raccoon. What matters about this story is what it wasn’t: no break-in at OpenAI, no data leak on their servers, no firewall circumvented. It was malware that simply read out the browsers of the people affected and took what was sitting there in the way of tokens and stored logins. The servers stayed untouched, the weakest link sat on the users’ devices, and nobody had to crack someone else’s infrastructure for it.
That shifts the question. It isn’t about whether a provider is secure enough. As long as the token sits in the user’s browser, the best server security helps nothing against something that sits on the device itself. So the honest question is a different one: what is actually in my own browser, and who can get at it? So I looked. What follows is a walk through the places where a browser keeps tokens, and through the one insight that carries everything else: a JWT you find is almost never what it looks like.
Look for yourself
The nice thing about it is that you can see it for yourself, no tool, without having to take my word for anything. On any page where you’re logged in, open the DevTools with F12. In the Application tab (in Firefox it’s called Storage) you’ll find the Local Storage entry on the left. Click it, and you’ll see a table of keys and values, everything this page has stored locally on you. Look for values that start with eyJ. That’s the Base64 encoding of {", the beginning of every JWT header, and therefore the reliable telltale sign of a token.
If you have the console open, it’s more convenient. This snippet lists all JWTs on the current page and decodes their payload:
Object.entries(localStorage)
.filter(([k, v]) => /^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(v))
.forEach(([k, v]) => {
const [, payload] = v.split('.');
console.log(k, JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))));
});
It goes through every entry, keeps only the ones that look like a JWT, cuts out the middle part and decodes it. Out comes readable JSON. This is exactly where what part 1 meant by “signed, not encrypted” becomes tangible: you see your own sub, your role, your exp in plain text, without any key at all. A made-up example of what such output looks like:
{ "sub": "user_42", "role": "member", "exp": 1750000000 }
Nobody had to decrypt that. The signature protects the token from being altered, not from being looked at. Whoever gets their hands on it reads the contents as effortlessly as you’re reading your own right now. That’s not an oversight in the design, it’s deliberate: a JWT is meant to let the recipient check the payload without asking back. The price for that is that the payload lies open.
Two more tabs are worth a look. In the Cookies tab you see the page’s cookies listed, and some carry the flag HttpOnly. You see that they exist, you see their name and their expiry date, but you can’t get at their value, not even through the console. Go ahead and try: document.cookie gives you back the normal cookies, the ones marked HttpOnly are simply missing. That’s not a defect, that’s the whole point: JavaScript isn’t allowed to read an HttpOnly cookie, so injected script can’t either. This is exactly where the first concrete difference from localStorage lies, which is open to every script. In the Network tab, finally, you can look at a single request and search the request headers for Authorization: Bearer eyJ. If that shows up, this service sends its JWT along again on every single request, and you can watch live as the token goes over the wire. Three tabs, three different places where tokens live, and three different degrees of protection, from the open localStorage to the cookie that’s invisible to JavaScript.
Why not “everything at once”, and a scanner
At this point an obvious worry comes up: if injected script can read localStorage, then an XSS just reads out everything that’s in the browser. That isn’t true, and the reason is one of the oldest rules of the web. The Same-Origin Policy locks every localStorage access to exactly one origin. A script running on site-a.example can’t get at the localStorage of site-b.example. That’s why a remote attacker with an XSS hole doesn’t clear out the whole browser, only the one site they’ve compromised. That XSS is real and not just a textbook bogeyman is shown, for instance, by CVE-2025-43714, a stored XSS in shared ChatGPT chats, according to NVD. But even that stayed confined to its origin.
“Everything at once” only works through a different vector: local file access. The tokens end up as files in the browser’s profile folder. Whoever reads those files directly bypasses the Same-Origin Policy completely, because they aren’t using any browser API for it to bite against. They read bytes off the disk. Normally that’s only you yourself on your own machine, or local malware, which by then already has the system under its control anyway. So the dangerous thing isn’t that one website gets at another’s tokens, but that something on your system gets around the browser rules by going underneath them.
This very distinction between “via a website” and “via the file system” is also what separates an audit tool from a stealer, even though both read the same bytes. To make it tangible on my own profile, I built myself a small Python tool with Claude that searches the profile folders directly as bytes, decodes the JWTs it finds and sorts them by risk. Expressly an audit tool for your own profile, not a stealer: it shows me what’s sitting on my machine, it sends nothing anywhere. The difference from an infostealer doesn’t lie in the technique of reading, but in the fact that what’s read never leaves the device. The code is out in the open at github.com/syswave-dev/jwt-scan.
The count lies
The scanner’s first run found a few thousand eyJ hits. That sounds like a catastrophe, and this is exactly where it pays not to panic but to look more closely. Almost all of it is junk: long-expired tokens that nobody ever cleared away. There are three reasons behind that.
- localStorage never expires on its own. The browser never cleans it up automatically (according to MDN). A token an app stores there stays put until the app overwrites it itself or you delete the site data. To the browser it’s just a string. That this string is an expired token is something it doesn’t know and never checks.
- On disk it piles up on top of that. Chromium keeps localStorage in a LevelDB. Overwritten or deleted values don’t disappear there right away, only at the so-called compaction, and that’s triggered by write activity. A browser profile you no longer use never compacts. The old ghosts stay physically in the files, and the byte scanner sees those too.
- Reopening doesn’t reliably clean up. Opening an app again doesn’t reliably delete the old ghosts, precisely because no guaranteed compaction takes place. The copies are really gone only once you delete the site data yourself, through the browser settings or the Application tab. And even that only removes the local copy. It doesn’t make the token invalid, because revocation happens server-side, not on your disk. You can scrub your browser spotless, and the stolen copy of a skimmed token keeps working elsewhere. More on that in the next part.
So the format eyJ on its own says nothing about whether a token still counts. Only the evaluation separates signal from junk, meaning whether the token is expired, where it sits and what type it is. “Thousands of JWTs found” is a headline that means nothing. It’s about as telling as “thousands of slips of paper found in the wastebasket”. The question isn’t how many, but which.
Format ≠ risk
This becomes especially clear when you lay two tokens side by side that look identical and mean the opposite. Both start with eyJ, both are formally a JWT, and one belongs where it sits, while the other is the actual problem right there.
One is a Supabase anon key, recognizable by a role: anon in the payload. It belongs in the client code on purpose, it’s public by design. Its protection comes not from secrecy but from Row-Level Security on the database (according to the Supabase docs). An important caveat goes with that, otherwise the statement is wrong: this only holds as long as Row-Level Security is actually active on all tables. If it’s missing, this publicly visible key reads out the whole database. So the key in plain text isn’t a leak here, the missing policy behind it would be one.
Next to it sits a long-lived refresh token in localStorage. That’s the master key that keeps issuing new access tokens, and it sits in the JavaScript-readable plain-text store. That’s the actual risk. Both strings start with eyJ, both consist of three dot-separated parts, both would be fished out by the same regular expression. And yet one is exactly where it belongs, and the other exactly in the wrong place. Identical format, opposite risk. The rule of thumb “JWT found equals leak” is therefore plainly wrong about half the time.
That’s why the tool’s risk rating is deliberately an exposure heuristic, not a sensitivity score. It doesn’t rate how delicate the contents of a token are, a scanner can’t even know that, but how exposed its storage location is. The rating is high only for localStorage, sessionStorage or IndexedDB, because those are the JS-readable, XSS-reachable places. Otherwise low. And low here expressly means “not in the XSS bucket”, not “harmless”. A highly sensitive token in a hard-to-reach place stays sensitive, the rating only says something about the reach of an injected script.
Three worlds, same storage
Once you subtract the expired ghosts and the public-by-design keys, the real finds are what’s left: live, JS-readable, genuine login material. Among those, three categories can be told apart that sit in the same storage and yet mean completely different things.
- The anti-pattern. A long-lived, account-wide refresh token in localStorage at a large provider. It often comes about quite inconspicuously: some SPA SDKs offer exactly this as an option, conveniently packaged. auth0-spa-js, for example, holds tokens in memory only by default, which makes them vanish when the tab closes. But set
cacheLocation: 'localstorage'and they’re stored persistently in localStorage. According to the Auth0 docs, this opt-in deliberately changes the security properties and exposes the tokens to XSS. The trade is convenient, because the session survives closing the tab. But it’s exactly the trade that creates the risk in the first place, and it often stands in one line of configuration that nobody questions anymore. - Done right. A short-lived, device-bound Firebase installation or App Check token in IndexedDB. It identifies the app instance, isn’t a login token, is short-lived and gets renewed automatically (according to the Firebase docs). It’s meant to be exactly where it is.
- Self-inflicted, but fixable. A small service I run myself stores its token out in the open in localStorage, and the token doesn’t even have an expiry date, so it would never be sorted out as expired. This sort of thing happens even to the person writing the scanner. Unlike the big providers, though, I can fix this one myself, it’s my own code.
And then there’s the find that easily slips by and is perhaps the most important. Next to a visible JWT there often sits an opaque refresh token that a pure JWT scanner doesn’t see at all. It isn’t a JWT, it has no dot structure, it matches no eyJ pattern, a pattern matcher walks right past it. But it sits in the same unencrypted localStorage entry, right next to the visible tokens, often as a field in the same JSON object the library stores there. The tool shows the tip of the iceberg, and the actual master key sits inconspicuously beside it. Whoever only looks for JWTs misses precisely the thing that’s worth the most. That’s a second lesson in passing about scanners in general: a tool finds what it looks for, and the most dangerous find is often the one nobody wrote a pattern for.
What the scan misses
In the end the most interesting thing is perhaps what the scan doesn’t find. The really big web logins, Google and Microsoft, don’t show up at all. Not because these services have no tokens, on the contrary, they manage some of the most valuable sessions a browser knows. But because theirs sit in a place the scanner can’t get to. That makes for a picture that looks backwards at first glance: the dangerous thing sits in plain text and is easy to find, while the actually valuable thing eludes the very tool that’s looking for valuable things.
Why that is, why the delicate tokens lie around in the open and the well-protected ones elude the byte scanner, and what the actual fix behind it is, beyond “just encrypt it better”, that’s the next part.
This text builds on Part 1: “A JWT Is a Format, Not a Login System”. It continues in Plain Text and Vault: Why Your localStorage Lies Open and Your Login Doesn’t.