Plain Text and Vault: Why Your localStorage Lies Open and Your Login Doesn't
In the last part the scan ended on an observation that feels backwards. The sensitive tokens lay openly in localStorage, easy to find. The really big web logins, Google and Microsoft, didn’t show up at all. Not because those services have no tokens, quite the opposite, they manage some of the most valuable sessions a browser knows. They didn’t show up because their tokens live in a place the scanner couldn’t reach.
The obvious assumption is that all tokens lie around somewhere in the browser and a thorough tool finds them all. That isn’t true. Not all token storage is built the same. One sits in plain text on the disk, the other in a vault the browser set up for itself. This one asymmetry explains both: why the scan found exactly what it found, and why the obvious fix, “then just encrypt the cookie better,” ends up missing the problem.
The first two parts of this series lay the groundwork. Part 1 showed that a JWT is a format, not a login system, and that its sorest spot in the browser is its bearer character: whoever has the token is in. Look in Your Own Browser went through one’s own browser with a self-built scanner and sorted junk from risk. This part resolves what stayed open there, and closes the series.
Plain text versus vault
localStorage sits on the disk in plain text. That was the finding from “Look in Your Own Browser,” and it’s the reason the scanner found anything at all. It read the profile files as bytes, no decryption, no admin rights, in the perfectly ordinary user context. Whatever is in localStorage is readable there; to the browser it’s just a string.
Cookies are built differently. Since Chrome 127 (July 2024) the cookie store sits behind App-Bound Encryption; before that it sat behind the older DPAPI scheme. The key the cookies are encrypted with is wrapped so that only the signed browser executable can open it again, by way of a system service that checks who’s asking before it decrypts. A foreign process that simply copies the cookie file holds encrypted junk afterwards. It has the bytes, but not the key, and it can’t get to the key without convincing the service that it is the browser.
That’s the whole difference at its core. With localStorage it’s enough to read the file, then you have the contents. With the cookie, reading gets you only halfway, because the contents are encrypted and the key hangs on a door that asks about identity instead of privileges. The effort for the one and for the other is a full order of magnitude apart, and that order of magnitude is why the big logins were simply missing from the scan.
This is exactly where Google’s and Microsoft’s sessions live. Their logins run over HttpOnly cookies, which gives them two layers of protection stacked on top of each other. First, they’re invisible to JavaScript; injected script can’t read them, that was the Same-Origin point from “Look in Your Own Browser.” Second, they’re encrypted on the disk; a file-reading process can’t get at the plaintext. localStorage gets neither of these layers. It’s readable by any script on the origin and, on top of that, lies unencrypted on the disk.
The absence of the big logins from the scan is therefore not a gap in the tool, but itself the proof. You can reproduce this without my tool. Open the DevTools at ChatGPT and look in localStorage; you’ll find its token there in plain text. Do the same at a Google service, and there’s nothing usable in localStorage, the login sits in an HttpOnly cookie whose value the console won’t even show you. The dangerously exposed thing is visible, the well-protected thing slips away. This is exactly the skew the scanner finds, and it inverts the intuition: the storage that’s easiest to read holds, of all things, the tokens most likely to be a problem, while the most valuable sessions live where the byte scanner comes up empty.
An infostealer with good intentions
My audit tool and an infostealer read the same bytes in the same way. Both go through the profile files, both pull out what looks like a token. The only difference is where the read data goes: in my case onto my own screen, in the stealer’s case over the wire to a foreign server. The technique of reading is identical.
This isn’t a flaw in the tool. This is exactly what the threat model looks like. If a harmless audit script without special privileges can clear out localStorage, then malware running as the same user can too. And it runs as exactly that user, because an infostealer you catch starts in your own account, not in a foreign one.
Before Chrome 127 this held for cookies as well. DPAPI bound the key to the Windows account alone, so any process running as you was allowed to use and decrypt it. For a file-copying stealer the cookie was therefore as open as localStorage. App-Bound Encryption shifted that line, away from the question “are you this user?” toward “are you the signed browser?”. Running as the same user is no longer enough to get at the cookie key.
That’s why a simple, file-copying stealer runs into a wall today with the big providers’ cookies. With localStorage it doesn’t run into a wall, because none ever stood there. localStorage was never encrypted, and no browser version has changed that. The asymmetry from the previous section is therefore not chance, but the direct result of one kind of storage getting a vault and the other never getting one.
“SYSTEM = cracked” isn’t true
The obvious assumption at this point is: if the cookie key hangs on a SYSTEM service, then I’ll just start my process as SYSTEM myself. There are tools for that, PsExec for one. Whoever runs as SYSTEM has the highest privileges on a Windows machine, so the encryption is bypassed that way. Not true, and the reason is what App-Bound Encryption just shifted.
The protection line no longer sits at the privilege, but at the identity of the executable. The system service doesn’t ask “are you privileged enough?”, it asks “are you the real, signed browser executable at the expected path?”. SYSTEM privileges don’t answer that second question. A foreign process started as SYSTEM is still not the browser, and the service doesn’t hand it the key.
The bypasses that work in practice therefore don’t beat SYSTEM, they impersonate the browser. CyberArk’s “C4 Bomb” and process-injection methods aim at exactly this: not more privilege, but faking the browser’s identity or smuggling themselves into the running browser process. How that works in detail I won’t spell out here.
App-Bound Encryption is still no impenetrable shield. Any on-disk encryption is, against an attacker who already sits as you on the running machine, fundamentally surmountable. The browser has to be able to open its cookie store without your involvement, otherwise you’d have to type a password on every start. What the browser can decrypt on its own, malware running as you can also have it decrypt, by passing itself off as the browser. App-Bound Encryption is friction, not magic. It raises the bar against the mass infostealer that simply copies files; it doesn’t lock anything away irrevocably.
Still, the blanket dismissal “it’s on your disk, so it’s lost anyway” falls short. There’s a real difference between “reads a plain-text file” and “has to pass itself off as the signed browser process or inject into it.” The first thing any run-of-the-mill script can do, the second costs effort and is more likely to get caught. That the layer has real effect shows in a side effect: legitimate tools like yt-dlp, which export cookies from the browser to reuse them, broke on Windows Chrome precisely because of App-Bound Encryption (reference). If the layer even catches tools that are explicitly built for cookie export, then it’s not a mere claim.
Binding instead of encryption
The actual failure was never that the cookie was too weakly encrypted. It’s that a bearer token is copyable at all. “Whoever has it is in” also means: whoever has a copy is in just the same. On-disk encryption changes nothing fundamental about that, it only makes obtaining the copy more expensive. Once the token is out, it works everywhere.
The fix that actually makes a stolen copy worthless starts one layer deeper. It binds the token to the device it was created on. A copy without that device is then no longer a valid key, no matter how cleanly it was copied. The trick behind it is always the same: instead of sending along a secret that anyone who has seen it once can pass on, the browser proves anew on every session that it possesses a secret it never gives out. What goes over the wire is only the proof, not the secret itself. An intercepted copy of the proof gets you nowhere, because the next proof is already a different one and can’t be produced without the secret. There are two forms of this, one in the browser’s hardware and one a level up in the application.
- DBSC (Device Bound Session Credentials). On login the browser stores a private key in a security chip on the device, the TPM, from which it can’t be copied out. With that key it signs a server challenge every few minutes and thereby proves the session is still running on the same device, entirely without the user’s involvement. A cookie someone copies to another machine is dead there, because the private key is missing and the foreign device can’t answer the server’s challenge. According to Google, DBSC has been generally available on Windows since Chrome 146. A variant for macOS via its Secure Enclave is announced; Edge’s origin trial ended without general availability.
- DPoP (RFC 9449). The same principle, but at the application level instead of in the hardware. On every token-protected request the client proves it possesses a particular key pair. A stolen token without the matching private key is useless, because the thief can’t demonstrate possession. It’s the same proof of possession as in DBSC, just located in the OAuth layer and not in the browser substrate.
DBSC doesn’t stop the local attacker who already sits on your device and uses the running session directly. Whoever is on the machine as you can operate the browser, and the browser has its bound token within reach. What DBSC stops is reuse elsewhere: that the stolen copy still works on the attacker’s server. It thereby closes exactly the path the infostealer from “Look in Your Own Browser” takes, namely copy and use from your own server. That’s not the whole problem, but it’s the part that makes the mass thefts from the hundred thousand scraped browsers lucrative in the first place.
Bearer out, bound in
Three parts, one line. Part 1: A JWT is just a format, the danger is the bearer token in localStorage that you can’t revoke. “Look in Your Own Browser”: Check for yourself, most of what’s lying there is junk, the dangerous thing is the bearer token in plain text, and the secure logins are invisible to the scan. This part: They’re invisible because they sit in the vault, and the real fix is not a better vault, but a token bound to the device.
Where you store the token, in localStorage, in the cookie or in the TPM, was the downstream question all along. The upstream one is whether the token is even a plain bearer card that works in any foreign hand, or hangs on something that can’t be copied along with it. Building a better drawer for the bearer card was always just a delay. As long as the card itself works everywhere a copy of it turns up, the theft stays worthwhile, and all the effort for the vault only makes it harder instead of making it worthless.
The levers are therefore the same three the fight already turned on in the first part: bind the token to the device, give it a short lifetime, make it revocable. None of the three aims at hiding the token better, but at making a stolen copy useless quickly or instantly. Bearer out, bound in.
This is the third and final part of the series. It builds on Part 1: “A JWT Is a Format, Not a Login System” and Look in Your Own Browser.