October 19, 2026
Your Browser Is Keeping Years of Your WhatsApp.
A forensic deep-dive into WhatsApp Web: where the data lives on disk, what sits there in plaintext, the one thing that’s encrypted, and how…
By Mihir Singh Choudhary
14 min read
Your Browser Is Keeping Years of Your WhatsApp. Here's What It Stores and How the Encryption Really Works
A forensic deep-dive into WhatsApp Web: where the data lives on disk, what sits there in plaintext, the one thing that's encrypted, and how I reverse-engineered the rest from WhatsApp's own code.
When you open WhatsApp Web, your browser eventually asks: "Use persistent storage?" Most people click yes without a second thought.
I wanted to know what that actually costs you. So I took my own WhatsApp Web sessions apart (Firefox and Chrome, my own account) and followed the data all the way down: where it's stored, what's readable with no keys, what's encrypted, and whether that encryption can be undone from a disk image alone.
The short version: your browser keeps a large, mostly-plaintext record of your WhatsApp life on disk. Contacts, chat and group names, message metadata going back years, media thumbnails, media-decryption keys, and your device's crypto identity are all readable with no key at all. Exactly one thing is encrypted at rest: the message text. And the way WhatsApp protects that text is a genuinely elegant piece of engineering, one I ended up reversing twice because they changed it midway through.
Ethics note._ Everything here was done on my own accounts and machines, and every real key, phone number, chat ID and message has been redacted. This is defensive/forensic research: understanding what a device leaks so investigators and users can reason about it. It is not a guide to snooping on anyone else. WhatsApp's server-side and end-to-end guarantees are not affected; this is strictly about data at rest in_ your own browser.
"Persistent storage" is about retention, not confidentiality
First misconception to kill: that prompt has nothing to do with security. WhatsApp Web is a companion device. Your phone remains the source of truth, and the browser mirrors data locally using three standard web-storage mechanisms:
- IndexedDB: the bulk of it. Messages, contacts, groups, media metadata, crypto keys.
- localStorage: small key/value settings, including some crypto salts.
- CacheStorage: fetched content and cached media blobs (and, as we'll see, a hiding place for something important).
"Persistent storage" exempts the origin from the browser's quota eviction. It decides how long the data survives, not whether it's protected. On my test profile, web.whatsapp.com was one of only three origins granted persistence out of 268, and it was holding hundreds of megabytes.
Where it actually lives on disk
The crypto is the same everywhere. Only the storage container differs by browser engine.
- Firefox (Gecko): IndexedDB is SQLite files, with values Snappy-compressed and encoded as Mozilla structured-clone. localStorage is
ls/data.sqlite. Path:.../<profile>.default/storage/default/https+++web.whatsapp.com/ - Chromium (Chrome / Edge / Brave, Blink): IndexedDB is LevelDB plus an external
.blobdirectory, with values in V8/Blink serialization. localStorage isLocal Storage/leveldb/. Path:.../<profile>/IndexedDB/https_web.whatsapp.com_0.indexeddb.leveldb
Neither browser encrypts IndexedDB or localStorage at rest. (Cookies get OS-keychain wrapping; IndexedDB does not.) Firefox obfuscates IndexedDB filenames by interleaving characters and Snappy-compresses the values, but that's compression and naming, trivially reversible. It is not encryption.
The plaintext trove
Here's what came straight off the disk with no keys at all (one real account, rounded):
- ~3,000 contacts: phone numbers, push names, saved names
- a few hundred chats and dozens of groups: names, membership, settings
- tens of thousands of message-metadata rows: sender, recipient, timestamp, type, spanning roughly three years (and, as I'll show later, still not the whole history)
- hundreds of message thumbnails that decode straight to JPEG
- thousands of media records with a plaintext
mediaKey(plusdirectPathand file hashes) - reactions, poll votes, delivery/read receipts, device lists
- your device's entire crypto identity: Noise static key material, Signal identity, prekeys and sessions
Before touching a single cipher, a dead-disk examiner can reconstruct who you talk to, when, what kind of content you exchanged, your group memberships, and image previews of what you sent. The social graph is wide open.
The one encrypted thing: message text
Every message row carries a field called msgRowOpaqueData:
msgRowOpaqueData = { _data, iv, _keyId, _scheme }msgRowOpaqueData = { _data, iv, _keyId, _scheme }_data is the encrypted message body. It looks exactly like block-cipher output: entropy around 7.3 bits/byte, length always a multiple of 16, and the plaintext body field is empty in 100% of rows. WhatsApp made a deliberate choice: leak the metadata, encrypt the content.
Now the interesting part: how?
Reversing the scheme, from WhatsApp's own JavaScript
You don't have to guess at the crypto. WhatsApp Web ships its logic as JavaScript, and the browser caches it on disk (Snappy-framed, so decompress first). The module that handles database encryption (WAWebDbEncryptionKey) hands you the exact construction:
cipher = AES-CBC, 128-bit
KDF = HKDF-SHA256
root key = NON-EXTRACTABLE WebCrypto CryptoKey in the
"wawc_db_enc" IndexedDB database (non-extractable to
JavaScript, but the raw bytes are still serialized
to disk, so they're recoverable forensically)
scheme 0 (preliminary):
AESkey = HKDF(root, salt = WAWebEncKeySalt, info = 0x00)
scheme 1 (final):
AESkey = HKDF(root, salt = decodeB64(companion_enc_static), info = WAWebEncKeySalt)
per record: random IV stored next to the ciphertext; PKCS7 paddingcipher = AES-CBC, 128-bit
KDF = HKDF-SHA256
root key = NON-EXTRACTABLE WebCrypto CryptoKey in the
"wawc_db_enc" IndexedDB database (non-extractable to
JavaScript, but the raw bytes are still serialized
to disk, so they're recoverable forensically)
scheme 0 (preliminary):
AESkey = HKDF(root, salt = WAWebEncKeySalt, info = 0x00)
scheme 1 (final):
AESkey = HKDF(root, salt = decodeB64(companion_enc_static), info = WAWebEncKeySalt)
per record: random IV stored next to the ciphertext; PKCS7 padding
Two of the three ingredients for scheme 1 are already on disk: the root key (recoverable from wawc_db_enc) and WAWebEncKeySalt (in localStorage). The third, companion_enc_static, is the whole ballgame. And every message body on disk uses scheme 1, so every single one needs it.
A moving target: same scheme, bigger key
Partway through the project, freshly linked profiles stopped decrypting. WhatsApp had changed something, and it's a nice illustration of how to reverse a moving target without panicking.
Re-reversing the current bundle (heavily obfuscated, around 10 MB minified) showed that the on-disk root key had grown, from a compact 32-byte key on older clients to a ~128-byte blob on newer ones. That was the only change. HKDF-SHA256, the two salts, the info parameters, AES-CBC, the two-scheme split: all identical.
HKDF doesn't care how long its input keying material is. Once you stop assuming a fixed key length, the exact same code decrypts both versions. "They changed the format" often means one parameter moved, not the whole design, and reading the source (even obfuscated source) beats guessing every time.
The twist: companion_enc_static is never written to disk
companion_enc_static is a value the server hands your device inside the encrypted Noise login <success> stanza. WhatsApp Web uses it transiently to derive the database key and then throws it away.
I didn't want to take that on faith, so I proved it. After capturing the real value, I searched for it directly across everything on disk:
- every IndexedDB record, decompressed, scanned for the exact raw bytes and the base64 string (well over a hundred thousand records)
- all localStorage values: raw, base64-decoded, and decrypted with the preliminary key
- CacheStorage and browser prefs
It is not present in any form. It genuinely never persists. This is the crux of the design: 100% offline decryption of message text is impossible, by design. From a disk image alone, the bodies stay sealed.
That's the good news for WhatsApp. Here's the twist that reopens the door.
But the Noise key is on disk, and that changes everything
Your browser logs back into WhatsApp Web after a reboot without you re-scanning a QR code. That means the credential to reconnect, the Noise static keypair (WANoiseInfo), must survive on disk. And it does.
It's stored AES-CBC-encrypted at rest, but wrapped with the preliminary key, HKDF(root, salt=WAWebEncKeySalt, info=0x00), which is fully derivable from disk alone. (The proof is behavioral: the browser reconnects at cold boot with no RAM state and no prior server contact, so whatever unwraps that key must be computable purely from disk.) I recovered it and confirmed X25519(privateKey).public == publicKey: a valid, usable device credential sitting right there.
Look at the symmetry once you see both halves:
- Noise device key: wrapped with the preliminary key
HKDF(root, WAWebEncKeySalt, 0x00). Recoverable from disk alone: yes, because it must decrypt at cold boot to reconnect. - Message bodies: wrapped with the final key
HKDF(root, companion_enc_static, WAWebEncKeySalt). Recoverable from disk alone: no, because the salt is server-only, so content stays sealed offline.
The Noise key has to be openable offline, or you couldn't reconnect. The message key must not be, or a stolen laptop would spill your chats. Same root key, two different salts, two completely different security outcomes.
So how do you actually decrypt the messages? Three scenarios
1) Disk only: dead box, no network, no memory. You get all the metadata, contacts, thumbnails, cached media and the Noise key. Message text: impossible. This is the honest limit.
2) Disk + server: reconnect. Recover the Noise key from disk, complete the Noise handshake back to WhatsApp's servers, and the server's <success> stanza hands back a fresh companion_enc_static. Derive the key and decrypt everything.
To really understand the handshake, I reimplemented it in pure Python: the Noise_XX_25519_AESGCM_SHA256 handshake, WhatsApp's binary-node wire protocol, and just enough of the login to read the <success> stanza. I ran it end to end and decrypted every message the browser had cached, zero failures, on two separate devices (Firefox and Chrome), each with its own server-issued key.
The catch: it's networked, and it shows up as linked-device activity on the account. That's an active, account-touching step with real operational and legal weight.
3) Memory: live system, RAM capture, swap, or hibernation image. companion_enc_static (and the derived key) live in the browser's process memory. Scan for candidate values, validate each against a known ciphertext, and decrypt offline. I tested this against a memory image and via a live-process scan.
Takeaway for dead-box forensics:_ capture RAM_ before you pull the plug, or you lose the message text. A hibernation file (
hiberfil.sys, decompress it first) is a full RAM snapshot, the best dead source. A swap/pagefile is only partial, but the secret is small enough to fit in a single 4 KB page, so it's often still findable.
Media decrypts separately, and offline
Media is simpler, because the per-message mediaKey (32 bytes) sits on disk in plaintext. WhatsApp media crypto is the same scheme as Android:
expanded = HKDF-SHA256(mediaKey, salt = 32 x 0x00,
info = "WhatsApp <Image|Video|Audio|Document> Keys", len = 112)
iv, cipherKey, macKey = expanded[0:16], expanded[16:48], expanded[48:80]
plaintext = PKCS7-unpad( AES-CBC-decrypt(cipherKey, iv, blob) )
verify SHA-256(plaintext) == filehashexpanded = HKDF-SHA256(mediaKey, salt = 32 x 0x00,
info = "WhatsApp <Image|Video|Audio|Document> Keys", len = 112)
iv, cipherKey, macKey = expanded[0:16], expanded[16:48], expanded[48:80]
plaintext = PKCS7-unpad( AES-CBC-decrypt(cipherKey, iv, blob) )
verify SHA-256(plaintext) == filehashCached .enc blobs decrypt fully offline, and I verified each recovered file against its stored hash. Media the browser never cached needs a CDN fetch, and that URL is signed and time-limited, so that part needs a live session.
The disk is a floor, not a ceiling: pulling history the browser never had
Here's the finding that reframed the whole exercise. A linked browser does not hold your entire history. It holds a recent window that WhatsApp synced to it. My Firefox and Chrome sessions of the same account each had tens of thousands of messages, but different ones, and neither went all the way back.
But a fully linked device can ask the phone for more. Using each chat's oldest cached message as an anchor, the tool issues WhatsApp's on-demand history-sync requests over the reconnected session. The phone answers with encrypted history blobs. Decrypt them (HKDF "WhatsApp History Keys", AES-CBC, zlib) and out come messages older than anything on disk.
One pass isn't enough, because the phone serves history in chunks. So the sweep is iterative: after each pass, re-anchor every chat on the oldest message recovered so far and ask again, deduplicating and checkpointing to disk as it goes (so a killed run resumes instead of starting over), until the passes stop finding anything new. On my reference account, that pulled roughly 19,000 messages that were on no disk at all, on top of ~23,000 cached. It nearly doubled the recoverable set.
The cost of admission is steep. To decrypt those responses you need the device's full Signal state (identity key, prekeys and sessions), not just the Noise credential, and the identity key is itself an AES-CTR-wrapped WebCrypto key you have to peel open first. The phone also has to be online.
The bug that made history go silent
This one took the better part of two weeks, and it's my favourite debugging story of the project.
At some point on-demand history just stopped working. The tool sent requests, the phone showed receipts, and nothing came back. I chased the obvious suspects, and every one was measured and wrong: phone throttling, a stale session snapshot, an idle phone, retry traffic burying the request, message wrapping differences, passive login, cookies.
The turning point was a user-level observation: clicking "load older messages" in the real WhatsApp Web, on the same linked device, worked fine minutes after my tool reported silence. So the phone was never the problem. I captured a real WhatsApp Web history fetch at the network level and read WhatsApp's own sending code, then compared it step by step with what my client did.
The difference was in how a companion device receives its offline queue. When a linked device connects, the server delivers everything that piled up while it was away, in batches the client has to ask for: an offline_preview with a count, then one offline_batch request per 100 items, and finally an <ib><offline/> marker meaning "you're caught up". WhatsApp Web keeps requesting batches until that marker arrives, and only then sends its history request.
My client (built on the Baileys library) asked for one batch of 100 and stopped. My test device had about 2,000 items queued. The queue stalled after the first hundred, and the phone's receipt and its answer sat behind the backlog, so nothing reached the tool while it was connected. Every earlier run received ~100 stanzas and then silence. The few answers I had ever seen were ones that happened to land inside that first batch on a later connection.
The fix was boring once found: keep pumping offline_batch until the marker, wait for the marker, then ask. In the measured run that was 19 extra batches, ~1,975 items delivered, the marker arriving ~28 seconds after connect (it had never been seen before), and the phone answering history requests about 3 seconds after producing them. That's the same latency as the browser.
The lesson is general. If your client behaves differently from the official one, don't theorise about the server. Capture the official client's traffic, read its code, and diff the sequence, not just the message contents. Every wrong hypothesis I had was about the messages. The bug was in the ordering.
One side effect worth stating plainly: draining the queue acknowledges everything pending for that device slot, so while riding a browser profile's registration, those items are not redelivered to the browser.
The setting that closes the door
Everything above (the reconnect, and the history sync built on it) hinges on one thing: the Noise device key being unwrappable from disk with a key you can derive offline. WhatsApp shipped a feature that breaks exactly that.
Turn on WhatsApp Web's screen lock (a passcode for the web app) and the device credential is re-wrapped. Instead of the disk-derivable preliminary key, WANoiseInfo is sealed with AES-GCM under a key stretched from your passcode with PBKDF2, hundreds of thousands of iterations, and the salt and IV are moved into CacheStorage (the service-worker cache) rather than the localStorage/IndexedDB an examiner reaches for first.
The effect is decisive: from a disk image there is simply nothing to derive. Without the passcode, the reconnect path is gone, and with it the on-demand history sync. It's the one on-disk setting that meaningfully changes the forensic picture. Worth knowing whether you're defending or examining.
Same story on Chrome, Edge and Brave
None of this is Firefox-specific. The crypto and protocol are WhatsApp's JavaScript, identical in every browser. Only the storage reader changes: Chromium keeps IndexedDB in LevelDB with V8/Blink serialization instead of SQLite with structured-clone. I verified the full chain on real Chrome data: the root key's raw bytes are exposed the same way, the Noise key is recovered and X25519-verified, every body is scheme 1, and companion_enc_static is again nowhere on disk. Then I reconnected and decrypted every cached Chrome message, on a completely separate device with its own server-issued key.
What this actually means
- What "persistent storage" really costs you: a durable, on-disk, largely plaintext record of your communication graph, contacts, media previews and device identity. If someone has your unlocked disk, that's all theirs.
- What WhatsApp got right: message content is genuinely sealed against a disk image alone. The decrypting salt is server/RAM-only, and I proved it isn't hiding anywhere on disk.
- Where the boundary actually is: not the crypto, which is sound. The realistic ways past it are capturing memory or reconnecting with the on-disk device credential (which also unlocks history the browser never had). Both are active, deliberate steps with legal and operational weight.
- And there's a switch for it: WhatsApp Web's screen lock re-wraps the device credential behind a passcode and moves its salt off the beaten path.
- The one-line summary: for WhatsApp Web, memory is the choke point for message text; everything else is already on the disk in the clear, unless screen lock is on.
For everyday users: treat a logged-in WhatsApp Web browser profile as sensitive as your phone. Full-disk encryption is what actually protects it at rest. If you want to shut the reconnect/history door, turn on WhatsApp Web's screen lock. For IR teams: if WhatsApp Web is in scope, grab RAM first, and note whether screen lock is enabled, because it changes what's recoverable.
From research to a tool: WhatsApp Web Extractor
All of the above started as a pile of scripts. I turned it into an open-source tool, WhatsApp Web Extractor (wawf), so an examiner doesn't have to repeat the reverse engineering. It's a single self-contained Windows .exe (plus a Linux binary) with a GUI and a CLI, and every step in this article maps onto something it does:
- Browser data. Point it at an acquired Chrome, Edge, Brave or Firefox profile folder. It detects the engine, reads IndexedDB and localStorage (SQLite/structured-clone for Firefox, LevelDB/V8 for Chromium), and recovers the root key and salts.
- Message key. Three choices, matching the three scenarios above: get it from WhatsApp (the Noise reconnect, which signs in once as the linked device and saves the key for later runs), use a saved key (decrypt offline with a key from an earlier run or a memory scan), or don't go online (everything except message text). It validates the key against on-disk ciphertext and refuses to write garbage if it's wrong.
- Outputs. Readable chats plus JSON, and an Android-layout SQLite database (
msgstore.dbandwa.db) so the result drops into existing Android WhatsApp forensic suites. A raw store dump is optional. - Optional online steps, all off by default and clearly labelled as online: download media the browser didn't cache, recover history older than the browser's ~90-day window from the phone (the iterative sync described above), and record what WhatsApp's server is holding for the device without taking anything from it.
- Scoping. Restrict a run to specific chats by name, phone number or ID.
This is what a finished run looks like, on the demo case I use for the screenshots (all values are made up):
Two design choices I'd call out, because they came straight from the research:
- It tells you what it can't do. The media line above says the remaining blobs were never cached by the browser, that their keys are stored, and exactly which flag fetches them. No silent gaps.
- It states its side effects. Anything that goes online is labelled "online", and the message-key option says in plain words that it signs in once as a linked device and that this is recorded as activity on the account.
The tool is aimed at lawful, authorised examination: the window itself says use only on accounts you own or are authorised to examine. It's a tool-only repository. There is no real data in it, and the screenshots above use made-up values.
Repo: https://github.com/Mihir-Choudhary/Whatsapp-Web-Extractor
If you work in DFIR and this saves you time, or you spot something that's changed in the client since I last looked (the key scheme already moved once mid-project), I'd love to compare notes. Issues and PRs are welcome.