Client-Side Encryption Tutorial: Build It From Scratch
Step-by-step tutorial for implementing client-side encryption in a web app. Encrypt files in the browser before they ever leave the user's device.
Client-side file encryption in the browser takes roughly 80 lines of JavaScript using the Web Crypto API. The pattern: generate an AES-256-GCM key in the browser, encrypt the file with a random 96-bit nonce, upload the ciphertext over HTTPS/TLS 1.3, and share the resulting URL with the key embedded in the fragment identifier (#key=...) which browsers never transmit to servers. The recipient decrypts in-browser using that same fragment. This tutorial walks through a working implementation, including chunking for large files, password-derived keys via PBKDF2 at 600,000 iterations, and the gotchas that break first attempts.
The Architecture in One Diagram
[Sender Browser] [Server] [Recipient Browser]
Read File → AES key (random) Accepts POST GET ciphertext
Encrypt with AES-256-GCM Stores ciphertext blob Parse key from URL #fragment
POST ciphertext No key, no plaintext Decrypt in-browser
Build URL with #key=... Returns download URL Save File to disk
The server is a dumb blob store. It sees only ciphertext and cannot decrypt. The decryption key lives in the URL fragment, which browsers treat specially: it's never sent in the HTTP request line. This is the foundation of every zero-knowledge file transfer service including HexaTransfer.
Step 1: Generate a Symmetric Key
async function generateKey() {
return await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable so we can export it into the URL
["encrypt", "decrypt"]
);
}
The extractable: true flag is required because we need to serialize the key into a URL fragment. If you're building a flow where the key lives only in memory (for example, a paste-and-send tool), set it to false.
Step 2: Read the File as an ArrayBuffer
async function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
This loads the whole file into memory. Fine for files under 500 MB. For larger files, skip ahead to the streaming section.
Step 3: Encrypt the Buffer
async function encryptFile(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintext
);
// Prepend IV to ciphertext so the recipient can extract it
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return combined.buffer;
}
The nonce (IV) is 96 bits (12 bytes), per NIST SP 800-38D. It's not secret but must be unique per key. Random nonces are safe here because we generate a fresh key per file. Prepending the IV to the ciphertext is a common convention; the recipient splits it back out before decrypting.
Step 4: Upload the Ciphertext
async function uploadCiphertext(ciphertext) {
const response = await fetch("/api/upload", {
method: "POST",
body: ciphertext,
headers: { "Content-Type": "application/octet-stream" },
});
const { fileId } = await response.json();
return fileId;
}
The server receives a binary blob, assigns it an ID, stores it, and returns that ID. No headers reveal the filename, no query parameters carry the key. If the server's hard drive gets stolen tomorrow, an attacker sees gibberish.
Step 5: Build the Share URL with the Key in the Fragment
async function buildShareURL(fileId, key) {
const rawKey = await crypto.subtle.exportKey("raw", key);
const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return `${location.origin}/f/${fileId}#${keyBase64}`;
}
Base64url encoding (with - and _ instead of + and /) avoids URL escaping issues. The = padding gets stripped for aesthetics.
The fragment (#...) is magic here. When the recipient loads the URL, the browser keeps the fragment client-side. The HTTP GET for /f/{fileId} does not include #keyBase64 in the request line, so the server never learns the key. Verify this yourself by opening browser dev tools on any fragment-containing URL and watching the Network tab.
Step 6: Recipient-Side Decryption
async function downloadAndDecrypt() {
const fileId = location.pathname.split("/").pop();
const keyBase64 = location.hash.slice(1);
const rawKey = Uint8Array.from(
atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
c => c.charCodeAt(0)
);
const key = await crypto.subtle.importKey(
"raw", rawKey, "AES-GCM", false, ["decrypt"]
);
const response = await fetch(`/api/download/${fileId}`);
const combined = new Uint8Array(await response.arrayBuffer());
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv }, key, ciphertext
);
const blob = new Blob([plaintext]);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "downloaded-file";
a.click();
}
GCM's authentication tag is checked during decrypt(). If the ciphertext was tampered with, the call throws OperationError, a clean failure mode.
Password-Derived Keys via PBKDF2
If users supply a password rather than a random key, derive the AES key via PBKDF2:
async function deriveKey(password, salt) {
const passwordKey = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password),
"PBKDF2", false, ["deriveKey"]
);
return await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 600000,
hash: "SHA-256",
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
600,000 iterations of PBKDF2-SHA-256 is OWASP's 2023 baseline. The salt must be 16 random bytes and stored alongside the ciphertext (not secret, just must be unique). For new code, consider Argon2id via a library like argon2-browser — it resists GPU attacks much better than PBKDF2.
Streaming Large Files
Files over 500 MB should be chunked. Read via File.stream(), encrypt each chunk, upload sequentially:
async function encryptStream(file, key) {
const reader = file.stream().getReader();
const chunks = [];
let chunkIndex = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const iv = new Uint8Array(12);
// Encode chunk index into the nonce to guarantee uniqueness
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, value
);
chunks.push({ iv, ct });
}
return chunks;
}
Deriving the nonce from the chunk index guarantees uniqueness without tracking state. Reassembly on the recipient side decrypts chunks in order and concatenates.
For true streaming AEAD, libsodium's crypto_secretstream_xchacha20poly1305 via libsodium.js is cleaner and detects truncation attacks. Web Crypto doesn't have an equivalent primitive in 2026.
Testing and Gotchas
Common mistakes to avoid:
- Using
Math.random()for keys or nonces: alwayscrypto.getRandomValues(). - Reusing a nonce with the same key: breaks GCM security. Per-file random keys make this safe; per-chunk flows need unique nonces per chunk.
- Not checking for HTTPS:
crypto.subtleis undefined on insecure origins. Test on localhost or with a self-signed cert during development. - Storing keys in
localStorage: any XSS on your origin can read it. Use the URL fragment pattern instead, or non-extractable keys. - Forgetting to include the IV with the ciphertext: decryption fails with no useful error. Always prepend or serialize alongside.
- Mishandling the fragment: don't accidentally post the URL (with fragment) to a third-party service. Share only through end-to-end channels if the fragment is sensitive.
Server-Side Responsibilities
The server's job in a client-side encryption architecture is small: accept POST, store blob, return ID, serve GET for the blob, delete on expiration. No crypto. What the server should do beyond storage:
- Enforce file size caps (prevent abuse)
- Rate-limit uploads and downloads
- Set short retention (7 days is a reasonable default, like HexaTransfer)
- Log only what's necessary (upload timestamp, no IPs if privacy-first)
- Serve over TLS 1.3 with HSTS
- CORS headers restricting origins if the API is called from only your domains
Putting It All Together
A minimal working app fits in one HTML file plus a 50-line Express backend. Total dependencies: none on the client (Web Crypto is native), Express plus multer on the server. The encryption is as strong as the AES-256-GCM primitive because that's literally what you're using. There's no secret algorithm to get wrong, only the primitives to use correctly.
The hardest parts are the edge cases: large files, password-to-key flows, recipient UX when decryption fails, handling expired links gracefully. The core cryptography is straightforward.
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