Encrypting Sensitive Data in Chrome Storage Before Sync
Use SubtleCrypto AES-GCM to encrypt extension secrets before writing to chrome.storage.sync, with key derivation, IV rotation, and safe key storage patterns for MV3.
Use SubtleCrypto AES-GCM to encrypt extension secrets before writing to chrome.storage.sync, with key derivation, IV rotation, and safe key storage patterns for MV3.
chrome.storage.sync replicates data through a user’s Google account on its way to every signed-in device. Any token, API key, or personal identifier you write there in plaintext is visible to Google’s infrastructure and to anyone who gains read access to that account. The fix is symmetric encryption using the Web Crypto API — encrypt the secret inside the extension before it ever touches the storage call. This page walks through a complete, production-ready pattern built on SubtleCrypto AES-GCM. For a broader look at what belongs in sync versus local storage, see Chrome Storage API Sync.
MV3 extensions cannot use Node.js’s crypto module. The only cryptographic primitive available is self.crypto.subtle (SubtleCrypto), which is present in all extension contexts — service workers, popup pages, options pages, and content scripts. AES-GCM with a 256-bit key is the right choice: it provides authenticated encryption, meaning any tampering with the ciphertext causes decryption to fail rather than silently returning garbage.
The deeper problem is key management. If you encrypt a secret with a key and then store that key in chrome.storage.sync alongside the ciphertext, you have locked a box and left the key inside it. The key must live somewhere the attacker cannot reach through the same sync channel.
Two viable strategies exist:
chrome.storage.local. The key never leaves the device. Ciphertext syncs; the key does not.The steps below implement the device-bound approach, which is simpler and appropriate when you control the secret (not the user). The FAQ covers the passphrase route.
On first run, generate a 256-bit AES-GCM key, export its raw bytes, and write them to chrome.storage.local. On subsequent startups, import the stored bytes back into a CryptoKey. Keep the live CryptoKey in a module-level variable so the service worker does not re-import it on every operation.
1// crypto-key.ts — runs in the MV3 service worker
2const KEY_STORAGE_NAME = "aes_gcm_raw_key";
3
4let _cachedKey: CryptoKey | null = null;
5
6async function generateKey(): Promise<CryptoKey> {
7 return self.crypto.subtle.generateKey(
8 { name: "AES-GCM", length: 256 },
9 true, // extractable — needed to export raw bytes for storage
10 ["encrypt", "decrypt"]
11 );
12}
13
14async function exportKeyBytes(key: CryptoKey): Promise<number[]> {
15 const raw = await self.crypto.subtle.exportKey("raw", key);
16 return Array.from(new Uint8Array(raw));
17}
18
19async function importKeyBytes(bytes: number[]): Promise<CryptoKey> {
20 const buffer = new Uint8Array(bytes).buffer;
21 return self.crypto.subtle.importKey(
22 "raw",
23 buffer,
24 { name: "AES-GCM" },
25 false, // not re-exportable once imported from storage
26 ["encrypt", "decrypt"]
27 );
28}
29
30export async function getCryptoKey(): Promise<CryptoKey> {
31 if (_cachedKey) return _cachedKey;
32
33 const stored = await chrome.storage.local.get(KEY_STORAGE_NAME);
34 if (stored[KEY_STORAGE_NAME]) {
35 _cachedKey = await importKeyBytes(stored[KEY_STORAGE_NAME] as number[]);
36 return _cachedKey;
37 }
38
39 // First run — generate, persist, cache
40 const newKey = await generateKey();
41 const bytes = await exportKeyBytes(newKey);
42 await chrome.storage.local.set({ [KEY_STORAGE_NAME]: bytes });
43 _cachedKey = newKey;
44 return _cachedKey;
45}
Execution context: Service worker (background.js) and any extension page (popup, options). self.crypto.subtle is available in all of these under Chrome, Edge, and Firefox. Safari 15.4+ supports exportKey("raw") for AES-GCM keys — earlier versions had a WebKit bug that silently returned an empty buffer; if you must support older Safari, test (await exportKeyBytes(k)).length === 32 after generation and surface a user-facing error if it returns 0.*
A critical constraint: never reuse an IV with the same key. AES-GCM IV reuse can expose both the key and both plaintexts. Generate a fresh 12-byte IV for every encryption call. Bundle the IV with the ciphertext into a single base64 string so that decryption is self-contained.
1// crypto-utils.ts
2function arrayBufferToBase64(buf: ArrayBuffer): string {
3 return btoa(String.fromCharCode(...new Uint8Array(buf)));
4}
5
6function base64ToArrayBuffer(b64: string): ArrayBuffer {
7 const binary = atob(b64);
8 const bytes = new Uint8Array(binary.length);
9 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
10 return bytes.buffer;
11}
12
13const IV_LENGTH = 12; // bytes — standard for AES-GCM
14
15export async function encryptValue(plaintext: string): Promise<string> {
16 const key = await getCryptoKey();
17 const iv = self.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
18 const encoded = new TextEncoder().encode(plaintext);
19
20 const ciphertext = await self.crypto.subtle.encrypt(
21 { name: "AES-GCM", iv },
22 key,
23 encoded
24 );
25
26 // Pack: [12-byte IV][N-byte ciphertext] → base64
27 const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
28 combined.set(iv, 0);
29 combined.set(new Uint8Array(ciphertext), IV_LENGTH);
30
31 return arrayBufferToBase64(combined.buffer);
32}
33
34export async function writeEncryptedToSync(
35 storageKey: string,
36 plaintext: string
37): Promise<void> {
38 const blob = await encryptValue(plaintext);
39 await chrome.storage.sync.set({ [storageKey]: blob });
40}
Execution context: Service worker and extension pages under Chrome and Edge. Firefox uses the identical self.crypto.subtle API; swap chrome.storage for browser.storage (or use the WebExtension polyfill). The btoa/atob helpers are available in all MV3 contexts including service workers — no import needed.*
The decryption function extracts the IV from the first 12 bytes of the decoded buffer, then passes the remainder to subtle.decrypt. Wrap it in a try/catch: if the key has changed (e.g., local storage was cleared) or the stored blob was corrupted, subtle.decrypt throws a DOMException with name "OperationError". Handle that explicitly rather than letting it bubble uncontrolled.
1// crypto-utils.ts (continued)
2export async function readDecryptedFromSync(
3 storageKey: string
4): Promise<string | null> {
5 const stored = await chrome.storage.sync.get(storageKey);
6 const blob: string | undefined = stored[storageKey];
7 if (!blob) return null;
8
9 try {
10 const key = await getCryptoKey();
11 const combined = new Uint8Array(base64ToArrayBuffer(blob));
12 const iv = combined.slice(0, IV_LENGTH);
13 const ciphertext = combined.slice(IV_LENGTH).buffer;
14
15 const plainBuffer = await self.crypto.subtle.decrypt(
16 { name: "AES-GCM", iv },
17 key,
18 ciphertext
19 );
20
21 return new TextDecoder().decode(plainBuffer);
22 } catch (err) {
23 if (err instanceof DOMException && err.name === "OperationError") {
24 // Key mismatch or tampered ciphertext — do not expose raw error to UI
25 console.error("[crypto] Decryption failed — key may have rotated:", err.message);
26 return null;
27 }
28 throw err; // Re-throw unexpected errors
29 }
30}
Execution context: Same as encryption — service worker, popup, options page. Note that in Firefox the DOMException name for decryption failure is also "OperationError", matching Chrome. Safari behaves identically from Safari 15.4 onward.*
chrome.storage is the native namespace. No polyfill required.self.crypto.subtle is identical to Chrome’s implementation. Use browser.storage.sync (or the webextension-polyfill package). Firefox Manifest V2 background pages also have SubtleCrypto, so this pattern works across MV2 and MV3 for Firefox.exportKey("raw") path for AES-GCM keys had a WebKit bug in Safari 15.0–15.3 that returned an empty buffer without throwing. Safari 15.4 fixed this. If you need to support older Safari, test the exported key length immediately after generation and display a hard error rather than silently storing a zero-length key.Confirm the stored value is opaque:
writeEncryptedToSync("mySecret", "hunter2")).chrome.storage.sync."mySecret" should be a long base64 string starting with random characters — never the literal word hunter2.Confirm round-trip correctness from the service worker console:
1// Paste into the service worker DevTools console (Inspect service worker)
2const result = await readDecryptedFromSync("mySecret");
3console.assert(result === "hunter2", "Round-trip failed:", result);
4console.log("Decrypted:", result);
Execution context: Evaluated in the service worker DevTools console. Requires that readDecryptedFromSync is exported from the background module or available on globalThis.*
Simulate key loss:
1// In the service worker console — deletes the local key, simulates reinstall
2await chrome.storage.local.remove("aes_gcm_raw_key");
3// Force the cached key reference to reset (reload the SW or invalidate the cache)
4// Then attempt to decrypt:
5const result = await readDecryptedFromSync("mySecret");
6console.assert(result === null, "Expected null on key loss, got:", result);
After clearing the key, readDecryptedFromSync should return null and log a [crypto] Decryption failed message — not throw to the caller, and definitely not return garbled text.
The AES-GCM key lives only in chrome.storage.local. A reinstall or a manual clear of extension data destroys it permanently. Any ciphertext synced to other devices or stored in chrome.storage.sync becomes unreadable — there is no recovery path without the original key. Always display a warning before any operation that clears local data, and consider prompting users to export a backup passphrase at setup time.
Yes, and it is the better option when users own the secret. Use PBKDF2: store a random per-user salt in chrome.storage.sync (salt is not secret), prompt the user for their passphrase on startup, and derive the key with subtle.deriveBits + subtle.importKey. The key is never written anywhere — re-derive it each time the service worker starts. This means synced ciphertext is readable on any device where the user enters their passphrase, with no key-loss problem.
Always use AES-GCM. It is an authenticated encryption mode: decryption fails loudly if the ciphertext was tampered with, rather than returning wrong plaintext. AES-CBC is unauthenticated and requires you to add a separate HMAC, which is easy to implement incorrectly. SubtleCrypto supports both, but there is no reason to choose CBC for new code.
Yes. Base64 encoding the combined IV and ciphertext adds approximately 33% overhead plus the 16-byte AES-GCM authentication tag. A 5 KB plaintext value becomes roughly 6.9 KB when encrypted and base64-encoded — leaving less than 1.2 KB of headroom in a single sync item. Plan your data model around this and see the quota error guide if you hit QUOTA_BYTES_PER_ITEM limits.
Chrome vs Firefox vs Safari Manifest V3 API compatibility reference — namespace differences, Promise vs callback, storage quotas, declarativeNetRequest, and the webextension-polyfill.
Use chrome.scripting in Manifest V3 to dynamically inject JavaScript and CSS — executeScript, registerContentScripts, world isolation, and activeTab patterns.
Persist and synchronise extension state across devices with chrome.storage.sync — quotas, async patterns, change events, encryption and cross-browser adapters for Manifest V3.
Master chrome.declarativeNetRequest static, dynamic, and session rulesets in MV3 — rule schema, modifyHeaders, redirect, block actions, quota limits, and getMatchedRules debugging.