Skip to content
HexaTransfer
Back to blog
Technical Deep Dives

Web Crypto API: Complete Tutorial for File Encryption

Master the Web Crypto API for browser-based file encryption. AES-GCM, RSA-OAEP, and key management in JavaScript applications.

The Web Crypto API lets you encrypt files directly in the browser using native window.crypto.subtle methods, with no external libraries required. For file encryption, you'll typically derive a key from a password via PBKDF2 (210,000 iterations, SHA-256), then encrypt the file bytes with AES-GCM using a 96-bit IV and 128-bit auth tag. Public-key workflows use RSA-OAEP with 4096-bit keys for wrapping the symmetric key. The API is available over HTTPS in every modern browser and runs inside the native crypto backend rather than JavaScript.

Why SubtleCrypto Beats Pure-JS Libraries

window.crypto.subtle calls into the browser's audited native crypto backend, usually BoringSSL in Chromium or CommonCrypto on Safari. Compared to pure-JS options like CryptoJS or sjcl, SubtleCrypto runs 30-80x faster for AES-GCM, avoids timing side-channels in JavaScript interpreters, and ships zero bytes to users. The tradeoff is a Promise-based API that only operates on ArrayBuffer and CryptoKey objects, so you spend a lot of time shuffling between Uint8Array, Blob, and ReadableStream. For file sizes above 100 MB, that plumbing matters more than the raw crypto speed.

Deriving a Key From a Password With PBKDF2

Never use a password directly as an AES key. Instead, import the password as raw material, then derive a 256-bit key:

async function deriveKey(password, salt) {
  const enc = new TextEncoder();
  const material = await crypto.subtle.importKey(
    'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 210000, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

OWASP's 2026 guidance calls for at least 600,000 iterations with SHA-256, though 210,000 remains acceptable for low-risk contexts. Generate a fresh 16-byte salt per file with crypto.getRandomValues and store it alongside the ciphertext. Argon2id would be stronger but isn't yet exposed by SubtleCrypto.

Encrypting a File With AES-GCM

AES-GCM gives you confidentiality and authenticity in one pass. The critical rule is never reuse a (key, IV) pair:

async function encryptFile(file, key) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const plaintext = await file.arrayBuffer();
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv, tagLength: 128 },
    key,
    plaintext
  );
  return { iv, ciphertext };
}

For a 2 GB file, file.arrayBuffer() will allocate the entire buffer, which often crashes mobile Safari. Split the file into 4 MB chunks, encrypt each with a unique IV derived from a counter concatenated with a random prefix, and prepend a version byte and salt so the decryptor knows what it's dealing with.

Streaming Large Files Through TransformStream

To avoid the memory blowup, wrap encryption in a TransformStream and pipe the file through:

const chunkSize = 4 * 1024 * 1024;
const encryptor = new TransformStream({
  async transform(chunk, controller) {
    const iv = nextIV(counter++);
    const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, chunk);
    controller.enqueue(new Uint8Array([...iv, ...new Uint8Array(ct)]));
  }
});
await file.stream()
  .pipeThrough(sliceByChunks(chunkSize))
  .pipeThrough(encryptor)
  .pipeTo(uploadSink);

file.stream() returns a ReadableStream<Uint8Array> that reads from disk lazily. The slicer produces fixed-size chunks so GCM tags align predictably. Peak memory stays under 20 MB even for a 10 GB upload.

Wrapping the Symmetric Key With RSA-OAEP

When you need to share a file with a specific recipient, generate their RSA keypair once and publish the public key:

const keypair = await crypto.subtle.generateKey(
  { name: 'RSA-OAEP', modulusLength: 4096,
    publicExponent: new Uint8Array([1,0,1]), hash: 'SHA-256' },
  true, ['wrapKey', 'unwrapKey']
);

Generate an AES-GCM key for the file, then wrap it:

const wrapped = await crypto.subtle.wrapKey(
  'raw', fileKey, keypair.publicKey,
  { name: 'RSA-OAEP' }
);

4096-bit RSA keys give roughly 150-bit security through 2030 per NIST SP 800-57. If you need forward secrecy or post-quantum resistance, pair RSA-OAEP with ECDH over P-384 or migrate to ML-KEM (Kyber) once the WebCrypto working group lands it.

Storing Keys Safely in IndexedDB

CryptoKey objects are non-extractable by default, which means you can persist them in IndexedDB without ever exposing the raw bytes to JavaScript:

const db = await openDB('keystore', 1);
await db.put('keys', keypair.privateKey, 'user-signing-key');

Browsers serialize the key using the structured clone algorithm and keep the actual bytes in the crypto backend. A compromised script can call encrypt or decrypt with the stored key but can't read its material out. This is a meaningful hardening step compared to stuffing base64 keys into localStorage.

Handling Errors the API Throws at You

SubtleCrypto throws OperationError for authenticated decryption failures, which usually means the ciphertext was tampered with, the IV is wrong, or the user typed the wrong password. It throws DataError when the input buffer has the wrong length, NotSupportedError when the algorithm isn't implemented, and InvalidAccessError when the key wasn't imported with the right usage flags. Always wrap decryption in try/catch, surface a neutral "file couldn't be decrypted" message, and avoid leaking whether the tag or the structure failed.

Real-World Gotchas

Firefox on Android caps deriveKey iterations at around 1 million before the UI thread freezes for several seconds, so run key derivation inside a dedicated Worker. Safari below 16.4 doesn't support crypto.subtle.verify with PSS padding. Chrome throttles getRandomValues calls above 64 KB per invocation, so loop if you need more entropy. And ArrayBuffer transfers through postMessage are zero-copy but detach the original, which catches people off guard.

HexaTransfer uses this exact AES-GCM plus PBKDF2 pipeline for every upload, with keys derived in a Worker and ciphertext streamed to storage without the server ever seeing plaintext. Try it at hexatransfer.com — free, no account, 10 GB max.

Putting It All Together

A minimal encrypted-upload flow: generate salt and IV with getRandomValues, derive an AES-GCM key from the user's password via PBKDF2, stream the file through a TransformStream that encrypts each 4 MB chunk, prepend a small header containing version, salt, and chunk count, and POST the result to your server. On download, reverse the process chunk by chunk, catching OperationError as the signal for wrong password or corruption. The Web Crypto API gives you everything you need, and the browser's native implementation will outrun any JavaScript alternative by an order of magnitude.

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