JavaScript File Encryption: Step-by-Step Tutorial
Encrypt files in the browser using JavaScript. Practical tutorial covering AES encryption, key derivation, and secure file handling.
JavaScript can encrypt a file entirely in the browser using the Web Crypto API, with no server involvement. The standard pipeline: read the file as an ArrayBuffer, derive a 256-bit AES-GCM key from a password via PBKDF2-SHA256 (600,000 iterations), encrypt with a random 12-byte IV, and package the salt, IV, and ciphertext into a downloadable Blob. Every major browser supports this natively through window.crypto.subtle, and for files up to 2-3 GB the whole thing runs in under ten seconds on a modern laptop without touching any third-party library.
The Algorithm Choices That Matter
Pick AES-GCM, not AES-CBC. GCM gives you authenticated encryption in one pass, detecting tampering with a 128-bit tag, while CBC needs a separate HMAC step that most tutorials get wrong. Use a 256-bit key — the performance gap versus 128-bit is negligible on hardware with AES-NI. Pick PBKDF2-SHA256 for password-based key derivation unless you can ship Argon2id via WebAssembly, which is stronger but adds 50 KB of download weight.
Avoid these: ECB mode (fundamentally broken), hand-rolled padding (a decade of CBC padding oracle attacks), MD5 or SHA-1 (collision-broken), and anything from the crypto-js package without understanding that it does CBC with PKCS7 by default.
Reading a File Into Memory
The File API gives you three ways to get bytes:
const buf = await file.arrayBuffer(); // whole file
const stream = file.stream(); // streaming
const text = await file.text(); // UTF-8 decoded
For files above 500 MB, arrayBuffer() often fails on mobile Safari. Stream instead:
async function* chunks(file, size = 4 * 1024 * 1024) {
for (let off = 0; off < file.size; off += size) {
yield new Uint8Array(await file.slice(off, off + size).arrayBuffer());
}
}
Each slice reads lazily from disk, so peak memory stays bounded.
Deriving a Key From a User Password
Never pass a raw password into encrypt. Derive a key first:
async function deriveKey(password, salt) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey(
'raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
material,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
Generate a fresh 16-byte salt per file with crypto.getRandomValues(new Uint8Array(16)). Store the salt alongside the ciphertext — reusing a salt across files defeats the purpose of PBKDF2. OWASP's December 2026 password storage cheat sheet currently recommends 600,000 iterations for PBKDF2-SHA256, which translates to roughly 500 ms of key derivation on a mid-range phone.
Encrypting the File
With a key in hand, encryption is one subtle.encrypt call per buffer:
async function encryptBuffer(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
key,
plaintext
);
return { iv, ciphertext };
}
GCM fails catastrophically if you reuse an (iv, key) pair — the keystream collision leaks both plaintexts. A 96-bit random IV gives ~2^48 safe encryptions with one key, which is fine for file encryption. If you're encrypting many chunks with the same key, derive the IV from a counter plus a random 32-bit prefix.
Packaging the Output
The decryptor needs the salt, IV, and ciphertext. Pack them into a single blob with a tiny header:
function pack(salt, iv, ciphertext) {
const magic = new TextEncoder().encode('ENC1');
return new Blob([magic, salt, iv, new Uint8Array(ciphertext)]);
}
Version the header (ENC1, ENC2…) so you can migrate algorithms later without breaking old files. Offer a download via:
const url = URL.createObjectURL(packed);
const a = document.createElement('a');
a.href = url; a.download = `${file.name}.enc`; a.click();
URL.revokeObjectURL(url);
Decrypting Files
Decryption reverses the process and throws OperationError if the password is wrong or the file was tampered with:
async function decryptFile(blob, password) {
const buf = await blob.arrayBuffer();
const view = new Uint8Array(buf);
const magic = new TextDecoder().decode(view.slice(0, 4));
if (magic !== 'ENC1') throw new Error('Unknown format');
const salt = view.slice(4, 20);
const iv = view.slice(20, 32);
const ct = view.slice(32);
const key = await deriveKey(password, salt);
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new Blob([pt]);
}
Surface a single error message — "couldn't decrypt, wrong password or corrupted file" — rather than distinguishing between tag failure and structural failure. That removes the padding-oracle-adjacent leak of information to attackers.
Encrypting Large Files Without Running Out of RAM
For anything above 500 MB, do not call arrayBuffer() on the whole file. Encrypt chunks independently with unique IVs derived from a counter:
function ivForChunk(baseIV, index) {
const iv = new Uint8Array(baseIV);
const view = new DataView(iv.buffer);
view.setUint32(8, index, false);
return iv;
}
Pipe the file through a TransformStream, encrypt each 4 MB chunk, and write the results to a WritableStream pointing at disk via the File System Access API. Peak memory stays near 10 MB even for a 20 GB file. The chunked format needs to record chunk size and count in its header so the decryptor can reassemble correctly.
Common Mistakes That Ship to Production
Three patterns show up in real code reviews of JavaScript crypto:
First, storing the raw key in localStorage for convenience. localStorage is synchronous, origin-scoped, and readable by any XSS. Use a non-extractable CryptoKey in IndexedDB instead.
Second, using Math.random() for IVs or salts. Math.random() is predictable; always use crypto.getRandomValues.
Third, assuming subtle.encrypt is constant-time. It is in native browser implementations, but any JavaScript wrapper around it almost certainly isn't. Keep your own code out of the hot path.
HexaTransfer applies this exact pipeline — PBKDF2 at 600k iterations, AES-256-GCM, streamed chunks — so the server only stores opaque ciphertext. Try it at hexatransfer.com — free, no account, 10 GB max.
Testing the Implementation
Write a test harness that round-trips a random 10 MB blob, mutates a byte, and asserts decryption throws. Add fuzzing against malformed headers and truncated ciphertexts — the common bug is missing bounds checks on slice() that crash instead of rejecting. Benchmark on an iPhone SE, a mid-range Android, and a Chromebook; anything that takes more than 2 seconds of key derivation is too slow for mobile users. Audit with a second pair of eyes before going to production — crypto code looks simple and breaks in subtle ways that tests miss.
Send large files securely with end-to-end encryption
Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.
Send a file