Decrypting Firefox passwords: NSS, key4.db and logins.json
2026-06-17 · 3 min
Chrome leans on the OS keystore — DPAPI on Windows, the Keychain on macOS. Firefox does its own thing: saved passwords are protected by NSS (Network Security Services), the same crypto library Firefox uses for TLS. Nothing is tied to your Windows account, which makes Firefox passwords more recoverable offline than Chrome's — you just need two files and (sometimes) the primary password.
The two files
| File | Holds | Format |
|---|---|---|
logins.json | the encrypted usernames & passwords | JSON |
key4.db | the key that decrypts them | SQLite |
Both sit in the profile root (see Firefox file locations).
Cookies are a separate story — cookies.sqlite is plaintext, so it needs no
key at all (unlike Chrome cookies).
logins.json is an array of entries:
{
"logins": [
{
"hostname": "https://example.com",
"encryptedUsername": "MDIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECC4...",
"encryptedPassword": "MEIEEPgAAAAAAAAAAAAAAAAAAAEwFAYIKoZIhvcNAwcECF...",
"timeCreated": 1700000000000,
"timeLastUsed": 1701234567890
}
]
}
Each encrypted* value is base64 of an ASN.1 structure: a key id, a cipher
- IV, and the ciphertext. The cipher for the entries themselves is always
3DES-CBC — but the key comes from
key4.db.
Inside key4.db
key4.db (SQLite; it replaced the old Berkeley-DB key3.db around 2018)
has two tables that matter:
metadata— the rowid = 'password'carriesitem1(the global salt) anditem2(a password-check blob).nssPrivate— the row whosea102equals the well-known idf8000000000000000000000000000001carriesa11, the encrypted key.
item2 and a11 are both wrapped the same way, by a key derived from the
global salt and your primary password (empty by default). Which wrapping
is used is announced by the algorithm OID in the ASN.1:
| OID | Scheme |
|---|---|
1.2.840.113549.1.12.5.1.3 | pbeWithSha1And3KeyTripleDES-CBC (legacy 3DES) |
1.2.840.113549.1.5.13 | PBES2 → PBKDF2-HMAC-SHA256 + AES-256-CBC (modern) |
Key derivation
Legacy 3DES PBE. A SHA-1/HMAC-SHA1 ladder over the salts:
hp = SHA1(globalSalt || primaryPassword)
chp = SHA1(hp || entrySalt)
k1 = HMAC-SHA1(chp, pes || entrySalt) # pes = entrySalt padded to 20 bytes
tk = HMAC-SHA1(chp, pes)
k2 = HMAC-SHA1(chp, tk || entrySalt)
k = k1 || k2 ; key = k[:24] ; iv = k[-8:]
Modern PBES2. Cleaner, and the common case today:
k = SHA1(globalSalt || primaryPassword)
key = PBKDF2-HMAC-SHA256(k, entrySalt, iterations, 32)
iv = 0x04 0x0e || <14-byte stored IV> # an NSS quirk: real IV is 16 bytes
Either way the primary password is mixed in only via that first
SHA1(globalSalt || password). No password set → the password is the empty
string, and everything decrypts with no secret at all.
The self-check that makes it trustworthy
You never have to guess whether the derivation worked. Decrypt item2 and
the plaintext must be exactly:
password-check\x02\x02
(the literal string password-check plus its two PKCS#7 padding bytes). If
it matches, the primary password and the whole derivation are correct — so a
wrong password fails loudly instead of silently producing garbage. This is
the same "decrypt-proves-itself" property as the
DPAPI masterkey HMAC.
Once verified, decrypt a11 to recover the 24-byte 3DES key, then decrypt
each logins.json entry with it (3DES-CBC, strip PKCS#7) to get the
cleartext username and password.
Why it runs in a browser tab
The whole chain is SHA-1, HMAC-SHA1, PBKDF2, 3DES and AES — no OS calls, no
keystore. That makes it a clean fit for a WebAssembly module: our decryptor
is a small Rust crate (RustCrypto + a tiny ASN.1 reader) compiled to WASM.
The page reads key4.db and logins.json, hands the raw blobs to the WASM,
and shows the decrypted logins — with nothing uploaded. Drop both files into
the in-browser parser,
enter the primary password (or leave it blank), and decrypt.
Pitfalls
- Primary password. If the user set one and you don't have it, the
password-checkfails and there is no shortcut — it gates everything. - key3.db. Very old profiles use the Berkeley-DB
key3.dbinstead ofkey4.db; the scheme is similar but the container differs. - Don't forget the salt source. The global salt is in
key4.db, not inlogins.json— you need both files, every time.