LevelDB is a key and value store written by Google. It has no tables, no columns and no schema. It holds keys and values, both of them arbitrary bytes, and it is the storage engine underneath Chrome.
It reaches your case through Electron. Every Electron application ships a copy of Chrome, so Discord, Slack, Signal Desktop, Microsoft Teams and Notion all write their local data into LevelDB folders on the user's disk.
You do not find a file. You find a folder full of files with names such as 000123.ldb, MANIFEST-000045 and CURRENT. This page explains what those files are, why deleted records survive inside them, and why two tools can read the same folder and disagree.
Six kinds, and each answers a different question. Knowing which is which is the first skill, because several of them are routinely ignored by tools that only read the current data.
| File | What it is | Why it matters |
|---|---|---|
*.ldb *.sst | Table files. Sorted, immutable blocks of records. | The bulk of the data. Never edited once written. |
*.log | The write-ahead log. Recent changes not yet merged into a table file. | The newest activity lives here, and often only here. |
MANIFEST-* | The catalog. Lists which table files make up the database and their key ranges. | Tells you which files the database considers current, and by omission which it does not. |
CURRENT | A one line file naming the active MANIFEST. | Without it you cannot tell which catalog is authoritative. |
LOCK | A lock held by the process using the database. | Its presence tells you the application was running. |
LOG LOG.old | Human readable diagnostic logs. Not evidence records. | Records compaction activity, which is context for what was merged away and when. |
A table file identifies itself at the end rather than the start. The last bytes of the file are a fixed trailer holding pointers to the file's own index and a magic signature.
57 fb 80 8b 24 75 47 db at the end of every valid .ldb / .sst
If that signature is absent the file is truncated, was never a complete table, or is damaged. A careful parser skips it and says so. A careless one reads whatever it finds and presents the result as data.
Because LevelDB never edits anything in place. It is a log-structured merge tree, which means every change is an append. Update a key and the old value is not overwritten, a new entry is added with a higher sequence number.
Deletion works the same way. LevelDB does not remove the record. It writes a new entry for the same key carrying a type byte that marks it as removed. That entry is called a tombstone.
0x00 tombstone the key was deleted 0x01 value the key was written
Both entries now sit on disk. The original value, and the tombstone that supersedes it. Reading the database normally gives you nothing for that key, because the tombstone wins. Reading the files gives you both.
They stay until a compaction merges the files they live in. Compaction is what finally discards the old value, and it runs on the database's own schedule rather than on the user's intent. A record deleted a month ago can still be present. A record deleted an hour ago can already be gone.
Other app-data formats reach the same result by a different route. Realm releases the space a deleted object used without clearing it, and Realm database forensics covers how that plays out.
A tombstone is positive evidence of a deletion, not an inference from a gap. You can state that a key held a value, that the value was later deleted, and give the sequence numbers that establish the order.
Edit a message five times and five entries exist until compaction merges them.
Sequence numbers establish which entry came first, without relying on timestamps.
How much history you get depends on when the database last merged, not on the case.
These are structured records read from their own format, not fragments recovered from slack.
The MANIFEST names the table files the database currently uses. Files that are not named in it can still be sitting in the folder, holding readable records, because deleting a file and forgetting a file are two different events.
That gives every file in the folder a role, and the roles are not interchangeable.
| Role | What it means |
|---|---|
| Active table | Named by the current MANIFEST. Part of the live database. |
| Obsolete table | Superseded by a compaction, still present on disk. |
| Unreferenced table | Present in the folder and named by nothing. The database has no idea it exists. |
| Current log | The write-ahead log in use at acquisition. |
| Previous log | A log that was rotated out but not removed. |
| Recovery log | Written during a recovery pass after an unclean shutdown. |
| Old log | An earlier log left behind in the folder. |
An unreferenced table file is the LevelDB parallel of a freelist page in SQLite. The engine has released it, the operating system has not reclaimed it, and the records inside are whole. The same principle appears across formats, and it is covered in the SQLite context on SQLite Forensic Analysis.
Did it read the whole folder, or only the files the MANIFEST pointed at? A tool that opens the database through the LevelDB library sees the current view by design, because that is what the library is for. It will not show you an obsolete table file, and it will not tell you one was there.
IndexedDB is not a file format. It is a browser API that web pages and Electron applications call to store structured data locally. What sits underneath it is the browser engine's own choice, and the three engines chose differently.
| Engine | Storage | Value format |
|---|---|---|
| Chromium Chrome, Edge, Electron apps | LevelDB folder | V8 structured clone |
| Gecko Firefox | SQLite database | SpiderMonkey structured clone |
| WebKit Safari, iOS | SQLite database | WebKit SerializedScriptValue |
This table explains a great deal of confusion in practice. Two examiners can both say they handled IndexedDB and mean entirely different work. One opened a folder of table files. The other opened a SQLite database and then had to decode a binary blob column that is not readable text.
It also means the SQLite skills carry directly. For Firefox and Safari the container is a SQLite database, so freelist pages, unallocated space and the write-ahead log all apply exactly as they do anywhere else. The relevant mechanics are on SQLite Forensic Analysis and Write-Ahead Log Viewer.
When Chromium stores IndexedDB in LevelDB, it has to fit a structured database into a flat key and value store. It does that by encoding the structure into the key itself.
Every key begins with a prefix of four parts: a single size byte, then a database id, an object store id and an index id. The size byte does not hold data. It holds the widths of the three ids that follow.
[size] [db_id] [object_store_id] [index_id] [ remainder ] size = ((db_size-1) << 5) | ((os_size-1) << 2) | (idx_size-1) db 1-8 bytes, store 1-4 bytes, index 1-4 bytes, little-endian
The index id then decides what the rest of the key means. It is a routing value, not a number you read.
| Condition | The key holds |
|---|---|
| database id is 0 | Global metadata, including the database name map |
| object store id is 0 | Database metadata, including the origin and store definitions |
| index id is 1 | Object store data. The records themselves. |
| index id is 2 | An exists entry, used to test presence |
| index id is 3 | A blob entry, pointing at externally stored bytes |
| index id is 30 or above | Index data for a secondary index |
Without decoding the prefix, every key in the folder looks like binary noise, and the metadata that names the database and its object stores is indistinguishable from the records. Decoding it turns the folder into a readable structure: this origin, this database, this object store, these records.
The encoding is defined in Chromium's own source, in content/browser/indexed_db/indexed_db_leveldb_coding.cc. It is public, it is stable, and it is the reference any parser should be checked against.
Because IndexedDB stores JavaScript objects, and JSON cannot hold everything JavaScript can. A JSON dump would lose a Date, a Map, a Set, a typed array, a BigInt and a Blob reference. So browsers use structured clone instead, a binary format that preserves them.
A structured clone value is a stream of single byte tags, each followed by its data. There is no text to search for and no delimiter to split on. You read it or you do not.
A Chromium IndexedDB value is two nested envelopes. Blink writes its own version marker, then V8's serializer writes another, and the tag stream follows.
FF varint Blink envelope, wire version FF varint V8 serializer version [ tag stream ]
Blobs and files are a separate problem. The value does not contain the file. It contains a reference to a journal id, and the bytes live elsewhere on disk. Decoding the value tells you a file was attached. Producing the file is a second step.
Both store their IndexedDB in SQLite, and both use a different structured clone dialect. Firefox uses SpiderMonkey's format, a stream of 64 bit pairs where the high half is a tag and the low half is data, except when the value is a float and the whole 64 bits are the number. Safari and iOS use WebKit SerializedScriptValue, a byte tag stream with its own version header.
Three engines, three formats, one API name. A tool that decodes one of them supports IndexedDB. It does not support your case unless it decodes the one in front of you. Nested binary payloads inside those values are their own problem, and Protobuf and varint forensics covers the most common one.
Run two tools over one LevelDB folder and you can get two different record counts. Neither has to be broken. They answered different questions, and usually neither of them said which question it answered.
One tool followed the MANIFEST and read the active database. The other read every file in the folder, including obsolete and unreferenced table files. The second finds more, and some of what it finds is historical.
One tool resolved each key to its current value and dropped the deleted ones. The other listed every entry it found. One list is shorter and correct. The other is longer and correct.
The same record can appear in a log file and again in a table file after a merge. Collapsing those into one row loses the fact that two independent copies exist in two places.
One tool showed the value as a hex blob. Another decoded the structured clone into fields. A search for a name finds nothing in the first and a hit in the second, on identical bytes.
Record counts are not comparable between tools unless you know what each one counted. Before you reconcile two outputs, establish what each read, what each skipped, and whether either applied deletions. A tool that cannot tell you is a tool you cannot reconcile. The wider argument is in why a viewer is not enough.
Firefly answers the two questions separately instead of blending them, because they are different questions and blending them is where disagreement comes from.
Physical history lists every occurrence found in the folder, with its source file, its position inside that file and its sequence number. Byte-identical entries in two different files stay as two rows. Nothing is deduplicated, because a second copy in a second file is a fact about the evidence.
Logical current reconstructs one row per key, applying tombstones and sequence order to say what the value is now. It reports one of three states, and the third one is the important one.
Current value the key holds this value Currently deleted a tombstone supersedes it Indeterminate the evidence does not settle it
Indeterminate appears when the database's own bookkeeping cannot be trusted, for example when the MANIFEST was only partly captured. Firefly says so and sends you to the physical history rather than picking the answer that looks most likely.
A parse finishes as Complete or Partial, and when it is Partial the reason is named. Each issue is written in examiner language with what happened, what it means for the evidence and a likely cause, followed by a stable code, the byte offset, how many bytes went unread and how many records were skipped.
Active, obsolete and unreferenced table files, and current, previous, recovery and old logs.
V8, SpiderMonkey and WebKit structured clone, so Chromium, Firefox and Safari all decode.
Each physical record gets a SHA-256 identity built from its file, location and sequence.
Inside a compressed block, the block offset and the entry offset are reported separately.
The method transfers. Identify the container, read the record header, know what marks a deletion, and establish what the tool read before you rely on what it showed. Two of the three IndexedDB engines store their data in SQLite, so the foundation is the same one.
The foundation everything else stands on, including the SQLite databases that hold Firefox and Safari IndexedDB. Taught on real training files you work on in your own tools.
A shorter course on transaction files. Write-ahead logging is the same idea LevelDB uses, applied to SQLite, and it is a good first step if you want to see how the training works.
Certified SQLite Forensics also runs live online and on-site by arrangement. See the formats.
Last updated 7 September 2026