Skip to content
HexaTransfer
Back to blog
Encryption & Security

Web Crypto API Guide: Browser-Native Encryption for Developers

Master the Web Crypto API for building encrypted file transfer applications. Complete guide to AES-GCM, RSA-OAEP, and key management in the browser.

The Web Crypto API (specified in W3C's Web Cryptography API recommendation, exposed via window.crypto.subtle) is the browser-native way to perform cryptography without shipping a crypto library over the network. It supports AES-GCM, AES-CBC, AES-CTR, AES-KW, HMAC, RSA-OAEP, RSA-PSS, RSASSA-PKCS1-v1_5, ECDH, ECDSA, HKDF, and PBKDF2 across all modern browsers (Chrome 37+, Firefox 34+, Safari 10.1+, Edge 79+). For file transfer applications this matters because every byte of ciphertext can be generated client-side before upload, with the browser providing a constant-time, audited implementation. This guide walks through the primitives that matter for encrypted file transfer and the gotchas that trip up every first implementation.

SubtleCrypto Is Promise-Based and Async

Every method on crypto.subtle returns a Promise. That's deliberate: crypto operations can be offloaded to hardware or background threads, so forcing async prevents the API from being misused in ways that would block the main thread. Code shape:

const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true, // extractable
  ["encrypt", "decrypt"]
);

The second argument (true) marks the key as extractable, meaning it can later be exported via crypto.subtle.exportKey(). For long-lived keys, set this to false to keep the raw bytes unreachable from JavaScript. For keys you need to serialize into a URL fragment (the HexaTransfer pattern), set it to true.

The third argument is a key-usages array. A key generated with ["encrypt"] cannot be used to decrypt, even though AES-GCM is symmetric. This separation prevents a compromised encryption flow from being abused to decrypt historical data.

AES-GCM for Symmetric File Encryption

AES-GCM is the workhorse for file content. It provides authenticated encryption with associated data (AEAD): ciphertext plus authentication tag plus optional associated data that's authenticated but not encrypted. For file transfer, use a 256-bit key and a 96-bit nonce per NIST SP 800-38D recommendations.

const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit nonce
const ciphertext = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv },
  key,
  plaintext
);

The output includes a 128-bit GCM authentication tag appended to the ciphertext. Decryption automatically verifies the tag and throws if it doesn't match. Never reuse a nonce with the same key; GCM's security collapses catastrophically on nonce reuse (attackers can recover the authentication key). For file transfer where each file gets a fresh key, random nonces are safe; for long-lived keys, use a counter.

PBKDF2 for Password-Derived Keys

When users type a password to protect a file, you can't use the password directly as an AES key. Run it through PBKDF2 first:

const passwordKey = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(password),
  "PBKDF2",
  false,
  ["deriveKey"]
);

const aesKey = await crypto.subtle.deriveKey(
  {
    name: "PBKDF2",
    salt: crypto.getRandomValues(new Uint8Array(16)),
    iterations: 600000,
    hash: "SHA-256",
  },
  passwordKey,
  { name: "AES-GCM", length: 256 },
  false,
  ["encrypt", "decrypt"]
);

OWASP's 2023 password-hashing guidance recommends 600,000 iterations for PBKDF2-SHA-256. Anything below 310,000 is below current best practice. The salt must be random and stored alongside the ciphertext (it's not secret, just must be unique).

For new code in 2026, consider Argon2id instead of PBKDF2. Argon2 isn't in the Web Crypto API yet, but libraries like argon2-browser or @noble/hashes provide JavaScript/WASM implementations. Argon2id resists GPU attacks far better than PBKDF2.

RSA-OAEP for Key Wrapping

For scenarios where you want to encrypt a file's AES key with a recipient's public key, use RSA-OAEP. Generate keys:

const keyPair = await crypto.subtle.generateKey(
  {
    name: "RSA-OAEP",
    modulusLength: 4096,
    publicExponent: new Uint8Array([1, 0, 1]), // 65537
    hash: "SHA-256",
  },
  true,
  ["encrypt", "decrypt"]
);

Use modulusLength 4096 for new keys; 2048 is acceptable but will start being deprecated as quantum timelines firm up. RSA-OAEP encrypts small payloads only (at most modulusLength/8 - 2*hashLength - 2 bytes), so wrap a 256-bit AES key rather than encrypting file contents directly.

For performance-sensitive apps, ECDH with P-256 or P-384 is a better alternative to RSA. Key generation is an order of magnitude faster and key sizes are much smaller.

Streaming for Large Files

A 2 GB file doesn't fit in a browser ArrayBuffer comfortably. Chrome, Firefox, and Safari all let you read Files with File.stream() returning a ReadableStream, then process in chunks. The Web Crypto API itself doesn't have streaming encrypt/decrypt methods yet (that's a gap in the spec), so two workarounds:

  1. Split into chunks (64 KB or 1 MB) and encrypt each with a unique nonce. The recipient concatenates in order. This loses true AEAD on the whole file but works for most cases.
  2. Use a WASM crypto library (libsodium.js, @noble/ciphers with WASM backend) which supports streaming AEAD modes like XChaCha20-Poly1305 or AES-GCM-SIV.

For transfers under a few hundred megabytes, buffered AES-GCM works fine and is far simpler. Above that, streaming becomes necessary to avoid memory pressure.

Key Export, Import, and URL Fragments

For HexaTransfer-style flows where the key travels in the URL fragment:

const rawKey = await crypto.subtle.exportKey("raw", aesKey);
const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)));
// Share URL like https://example.com/file/abc123#key=keyBase64

URL fragments never get sent to servers in HTTP requests (the browser strips them). This keeps the key client-side even though the user shares a link. On the recipient side:

const keyBase64 = window.location.hash.slice(5); // strip "#key="
const rawKey = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
const key = await crypto.subtle.importKey(
  "raw", rawKey, "AES-GCM", false, ["decrypt"]
);

Use base64url encoding (replace + with -, / with _, strip padding) to avoid URL encoding issues.

Common Pitfalls

Using Math.random() for salts or IVs. Math.random() is not cryptographically secure. Always use crypto.getRandomValues().

Reusing IVs with the same key. GCM's security properties break completely on nonce reuse. Random 96-bit nonces collide after ~2^48 encryptions under the same key (birthday bound). For file transfer where each file has its own key, safe; for long-lived keys, use a counter.

Forgetting HTTPS. crypto.subtle is only available in secure contexts (HTTPS or localhost). On an insecure origin, crypto.subtle is undefined.

Storing extractable keys in IndexedDB unprotected. If you need to persist keys, wrap them (e.g., with a passphrase-derived key) before storing. Never store raw AES keys in localStorage, which is accessible to any script on the origin.

Trusting user-supplied passwords without PBKDF2. A raw password converted to UTF-8 bytes is not a 256-bit key. Always derive.

Not verifying authentication tags. crypto.subtle.decrypt() does this automatically for AES-GCM, but if you implement custom protocols on top, don't skip the check.

Browser Support Nuances

All major browsers support Web Crypto on HTTPS. Some quirks:

  • Safari's PBKDF2 was slower than Chrome/Firefox for years; gap closed in Safari 15.
  • Firefox enforces stricter input validation; code that runs in Chrome may throw OperationError in Firefox. Test in both.
  • Web Crypto in service workers works but requires the registration scope to be HTTPS.
  • Node.js provides require("crypto").webcrypto with a compatible API since Node 15, useful for isomorphic crypto code.

When to Use a Library Instead

Web Crypto covers the basics well but lacks modern primitives like ChaCha20-Poly1305, Argon2, X25519, and Ed25519 (though Ed25519 is landing). For those, libsodium.js (via WASM) or @noble/ciphers / @noble/curves (pure JavaScript, audited) are the leading options. HexaTransfer uses Web Crypto primitives directly for AES-GCM and PBKDF2 since those cover the file transfer path without dependencies.

For a full encrypted transfer flow, Web Crypto gets you there in under 100 lines of code: generate AES key, derive or random, encrypt file, upload ciphertext, share link with key in fragment, recipient imports key and decrypts. That's the whole thing.

Try it at hexatransfer.com — free, no account, 10 GB max.

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