Encrypting Files in Your Browser with Native Web Crypto: A Working AES-GCM Guide
Encrypt and decrypt binary data in the browser using only native Web Crypto: PBKDF2 key derivation, AES-GCM envelopes, IV discipline, tamper detection.
Passphrase-protecting a file used to mean picking a crypto library, auditing it, and shipping it forever. Every modern browser now ships a cryptographic engine of its own—window.crypto.subtle—capable of the entire job: turning a human passphrase into a strong key, encrypting megabytes with built-in tamper detection, and handing back a single portable file. This guide builds that module end to end in roughly forty lines, with zero dependencies.
If the vocabulary feels unfamiliar, start with our plain-language companion on how zero-knowledge AES encryption works, which explains these ideas without math. This article is the working implementation: what each parameter means, which mistakes are catastrophic, and where the honest limits sit.
Why the browser’s built-in crypto is the right default
window.crypto.subtle is not a JavaScript reimplementation of ciphers. It lives inside the browser’s security-critical core—the same code path trusted for TLS—and inherits everything that implies. Engine vendors audit it continuously because platform features depend on it. Bulk AES runs through dedicated CPU instruction paths on most modern hardware, so throughput is typically excellent without any optimization effort on your part.
Compare that with pulling a cryptography package from a registry: you inherit its dependency tree, its update cadence, and the possibility that this month’s supply-chain incident becomes your incident. For the standard jobs—derive a key from a passphrase, encrypt with authentication—native APIs cover the need completely, and every byte of the security story stays inside an environment already hardened by millions of users attacking it daily.
One environmental requirement matters: crypto.subtle exists only in secure contexts—HTTPS pages or localhost. That is a feature. A cryptographic API served over plaintext HTTP would be a trapdoor, since anything injected into the page could simply steal passphrases as they were typed.
Libraries still earn their keep at the edges: memory-hard key-derivation functions such as Argon2 (which browsers do not yet offer natively), streaming formats beyond GCM’s scope, or codebases that must behave identically across runtimes. For everything else, native wins on audit surface, performance, and longevity.
Key derivation: PBKDF2 done properly
Passphrases make poor keys. They vary in length, carry far less entropy than their character count suggests, and humans choose them badly. A key-derivation function bridges that gap, and PBKDF2 is the one browsers ship natively: it hashes the passphrase together with a salt, repeatedly—hundreds of thousands of times—and hands back bits suitable for use as an AES key.
The repetition is the point. An attacker who steals your envelope can test guesses offline at full machine speed; each guess must also pay the full iteration cost. Stretch a 10-millisecond derivation into 400 milliseconds and every guessed passphrase costs 40 times more—a multiplier applied to every attempt against every user forever. OWASP’s password storage guidance currently lands at 600,000 iterations for PBKDF2-HMAC-SHA256, and the trend line only moves upward as hardware improves.
The trade-off is honest and symmetrical: iterations multiply the defender’s unlock wait exactly as they multiply the attacker’s guessing bill. Pick the slowest derivation your users will tolerate on low-end mobile hardware—measure it, do not estimate it—then encode that number in your format so old files stay readable as guidance evolves.
Two parameters deserve precision:
- The salt should come from
crypto.getRandomValues(), 16 bytes, generated once per key. Its job is defeating precomputed tables: with a unique salt, an attacker cannot amortize cracking work across users or reuse rainbow-table effort. Salts are not secrets—they are stored beside the ciphertext in plain sight. - Extractability should be
false. WebCrypto then treats the derived key as internal state: it can encrypt and decrypt but refuses to export the raw bits back to JavaScript, shrinking the window where key material exists as inspectable data.
AES-GCM: encryption with a built-in tamper seal
AES-GCM does two jobs in one primitive. It encrypts (counter mode over AES) and it authenticates (a tag computed across the ciphertext plus whatever additional data you choose). Decrypt with the right key and untouched bytes and you get the original data; flip a single bit anywhere—or derive the wrong key—and decryption fails outright instead of returning plausible garbage. That fail-loudly property, called authenticated encryption, is why GCM is the default recommendation for new designs.
The nonce rule that ends careers
GCM’s Achilles’ heel has a name: initialization-vector reuse. Every call needs a fresh 96-bit IV—a nonce—unique per encryption under a given key. Encrypt two messages with the same key and the same IV and the identical keystream cancels out: XOR the two ciphertexts and the encryption vanishes, revealing relationships between the plaintexts directly. Worse, GCM’s authentication machinery itself degrades when nonces collide, to the point where attackers can forge valid tags for new messages. NIST’s specification bounds random-IV usage at roughly four billion encryptions per key precisely because collision risk compounds with volume; systems exceeding such budgets switch to structured counter nonces.
For everyday file encryption the discipline is short: generate 12 fresh random bytes via getRandomValues() for every encrypt() call, and never construct an IV from predictable inputs like timestamps or counters shared across devices. One repeated IV does not weaken one file—it undermines every message ever sent under that key pair.
Assembling a self-describing envelope
Decryption later needs the exact salt and IV used at encryption time. Rather than managing side files, embed them in a fixed layout:
[ version: 1 byte ][ salt: 16 bytes ][ IV: 12 bytes ][ ciphertext + tag ]
The leading version byte looks trivial and earns its place the first time you migrate algorithms—say, to a post-quantum construction or a memory-hard KDF years from now. Old files declare themselves; new code routes them appropriately. WebCrypto appends GCM’s 128-bit authentication tag to the ciphertext automatically, so the envelope is just three headers and an opaque body.
Decrypting and validating integrity
Decryption mirrors assembly. Read the version byte, slice out the salt and IV at their fixed offsets, re-run the same derivation with identical parameters, and hand the body to subtle.decrypt. Any drift—iterations changed, hash changed, salt corrupted—produces a different key, and GCM answers with the same outcome it gives an attacker: an OperationError.
That uniformity is useful design material. Wrong passphrase and tampered file are deliberately indistinguishable, so the user-facing error can be generic—“decryption failed”—without leaking which case occurred. There is no oracle to interrogate, no differential behavior to probe.
One mechanical detail saves headaches: TypedArray.prototype.slice() returns copies rather than views. Slicing the salt, IV, and body out of the envelope means later mutation of the original buffer cannot race your in-flight decryption—a small guarantee that matters once envelopes flow through queues and workers.
Code walkthrough: a complete envelope module
Everything above compresses into a module small enough to read in one sitting:
// crypto-envelope.js — educational module built entirely on native Web Crypto.
const ITERATIONS = 600_000;
async function deriveKey(passphrase, salt) {
const material = new TextEncoder().encode(passphrase);
const base = await crypto.subtle.importKey(
'raw', material, 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS },
base,
{ name: 'AES-GCM', length: 256 },
false, // non-extractable: raw key never leaves subtle
['encrypt', 'decrypt']);
}
export async function encryptBlob(data, passphrase) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12)); // fresh EVERY call
const key = await deriveKey(passphrase, salt);
// subtle.encrypt returns ciphertext with the 128-bit auth tag appended.
const body = new Uint8Array(
await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data));
// Envelope: [version 1B][salt 16B][iv 12B][ciphertext+tag]
const envelope = new Uint8Array(29 + body.length);
envelope[0] = 1;
envelope.set(salt, 1);
envelope.set(iv, 17);
envelope.set(body, 29);
return envelope;
}
export async function decryptBlob(envelope, passphrase) {
if (envelope[0] !== 1) throw new Error('unsupported envelope version');
const salt = envelope.slice(1, 17); // slices copy — safe from races
const iv = envelope.slice(17, 29);
const body = envelope.slice(29);
const key = await deriveKey(passphrase, salt);
try {
return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, body);
} catch {
throw new Error('decryption failed: wrong passphrase or altered file');
}
}
Three decisions in that code are doing heavy lifting. Non-extractable keys mean the passphrase’s only lasting representation lives where JavaScript inspection cannot reach. The version byte makes the format evolvable instead of frozen. And the blanket catch converts WebCrypto’s terse OperationError into an error message that tells users what to try next rather than what the engine felt.
Usage is two awaits:
import { encryptBlob, decryptBlob } from './crypto-envelope.js';
const sealed = await encryptBlob(await file.arrayBuffer(), passphrase);
saveExport(sealed.buffer); // one portable file — backup drives, USB, email
const restored = await decryptBlob(sealed, passphrase); // ArrayBuffer returned
This is, structurally, how single-file encrypted vaults work: metadata headers around an authenticated body, keyed by nothing but the passphrase. Our offline digital diary guide walks the daily-habit side of that workflow.
Memory hygiene and honest limits
JavaScript will not let you promise cryptographic cleanliness about memory, and pretending otherwise breeds false confidence. Strings are immutable, so a secret that ever became one lingers until garbage collection notices; the runtime moves objects freely; and neither subtle nor the spec offers a wipe API for key material. The practical mitigations are behavioral: keep secrets as ArrayBuffers or Uint8Arrays rather than strings, drop references promptly, scope plaintext lifetime to the operation that needs it, and prefer non-extractable keys so the most sensitive state never appears in inspectable form at all.
Set expectations accordingly: envelope encryption defeats the stolen-backup scenario, the borrowed laptop, the cloud folder that syncs more than it should. It does not defeat a compromised device. Those are different threats requiring different tools—and knowing which threat you are actually defending against is most of the security work.
Questions people often ask
Can I store the salt next to the ciphertext?
Not only can you—you should. The salt carries no secrecy requirement; its job is uniqueness, not confidentiality. Embedding it in the envelope header keeps every file self-contained and decryptable decades later by code that knows only the format.
Six hundred thousand iterations feel slow on my phone. Can I lower them?
Measure before deciding, on the slowest hardware you support. Derivation happens once per unlock, not continuously; a one-time half-second pause is usually acceptable where a five-second one is not. Lowering iterations lowers the attacker’s cost identically—if the wait genuinely hurts, consider a memory-hard KDF like Argon2 implemented in WebAssembly instead of weakening PBKDF2.
Why AES-GCM instead of AES-CBC?
CBC encrypts but authenticates nothing; pairing it correctly requires a separate MAC applied in exactly the right order, a combination developers have gotten wrong often enough that the pattern earned its own warning literature. GCM folds integrity into the same call and fails loudly. Choose CBC only when a legacy format forces it—and then add HMAC with professional care.
Can I encrypt many files under one passphrase?
Yes—that is the normal shape. Derive one key from the passphrase, then give each file its own fresh IV and its own salt if you want independent re-derivations. Uniqueness per encryption is the invariant that must hold; sharing the key across files is fine and expected.
The takeaway
Native Web Crypto closes the gap between “we should encrypt this” and shipping something real: PBKDF2 stretches human passphrases into keys, AES-GCM encrypts while refusing silently corrupted results, and a versioned envelope makes the output a durable artifact rather than a science experiment. Respect the nonce rule, budget your iterations honestly, and remember the boundary—at-rest protection is a layer, not a shield against everything.
Share this article
Link, preview card, or your favorite app
Instagram has no web share link — save the card, copy the caption, post them together.