Pricing P2P Encrypted Chat Desktop App Browser Extension
Upload a file
← Back to Blog

Client-Side File Encryption in JavaScript: How It Works and Why It Matters

— Written by Brendan, Founder of FileShot.io

Client-side file encryption in JavaScript using the Web Crypto API

Client-side file encryption in JavaScript means your files are encrypted inside the browser, before they ever leave the user's device. The server receives only encrypted bytes and never possesses the decryption key. This is the architecture behind zero-knowledge file sharing services like FileShot — and it's built entirely on the browser's native Web Crypto API.

This guide walks through exactly how it works: key generation, the AES-256-GCM cipher, encrypting file bytes, embedding keys in URL fragments, and why this design is fundamentally safer than server-side encryption.

Why Client-Side Encryption?

Traditional file sharing services encrypt files on their servers using keys they control. That means:

  • The provider can technically read your files
  • A compromised server exposes all stored files
  • Compelled disclosure (legal order) can force the provider to hand over keys
  • An insider threat at the company presents a real risk

Client-side encryption eliminates all four. The encryption key never touches the server. Even if the server is compromised start-to-finish, attackers only get ciphertext they cannot decrypt. This is the definition of zero-knowledge architecture: the service knows nothing about your file's contents.

The Web Crypto API: JavaScript's Built-In Cryptography Engine

Modern browsers expose the window.crypto.subtle interface — a native, hardware-accelerated cryptographic API available in all major browsers since 2017. It supports:

  • AES-GCM — authenticated symmetric encryption
  • PBKDF2 / HKDF — key derivation functions
  • RSA-OAEP — asymmetric encryption (key wrapping)
  • ECDH / ECDSA — elliptic curve operations
  • SHA-256/384/512 — hashing

Unlike older approaches using third-party JavaScript libraries (which introduce supply-chain risk), Web Crypto is built into the browser engine itself. It runs in a sandboxed environment and is not accessible to page scripts in an unsafe way.

AES-256-GCM: The Algorithm Used for File Encryption

AES-256-GCM (Advanced Encryption Standard, 256-bit key, Galois/Counter Mode) is the industry standard for symmetric file encryption. Here's why it's the right choice:

  • 256-bit key — 2256 possible keys. Brute-force is computationally impossible.
  • GCM mode — provides both confidentiality (encryption) and integrity (authentication tag). If the ciphertext is tampered with, decryption fails and you know it.
  • Streaming-capable — can encrypt large files in chunks without loading everything into memory at once.
  • NIST-approved — used in TLS 1.3, HTTPS, disk encryption (FileVault, BitLocker), and government systems.

Step-by-Step: How Client-Side File Encryption Works in JavaScript

Step 1 — Generate a Cryptographic Key

A random 256-bit key is generated using the browser's cryptographically secure random number generator:

const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,        // extractable — so we can export it to share
  ["encrypt", "decrypt"]
);

This key never existed anywhere except in the browser's memory and will be exported and placed entirely in the URL fragment — it never travels to the server.

Step 2 — Generate a Random IV (Initialization Vector)

GCM mode requires a unique 96-bit IV for every encryption operation. Reusing an IV with the same key is catastrophic, so it's always freshly generated:

const iv = crypto.getRandomValues(new Uint8Array(12)); // 96 bits

Step 3 — Read the File into an ArrayBuffer

The FileReader API (or file.arrayBuffer() in modern browsers) converts the raw file into binary data that Web Crypto can process:

const fileBuffer = await file.arrayBuffer();

Step 4 — Encrypt the File Bytes

const encryptedBuffer = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv: iv },
  key,
  fileBuffer
);
// encryptedBuffer is the ciphertext (original bytes + 16-byte auth tag)

The output is the encrypted file bytes plus a 16-byte authentication tag appended automatically by GCM. The auth tag ensures that any tampering with the ciphertext will cause decryption to throw an error.

Step 5 — Export the Key to Raw Bytes

const rawKey = await crypto.subtle.exportKey("raw", key);
// rawKey is a 32-byte (256-bit) ArrayBuffer

Step 6 — Encode Key + IV for the URL Fragment

Both the key and IV are Base64url-encoded and combined into a URL fragment string:

function toBase64url(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

const fragment = toBase64url(rawKey) + '.' + toBase64url(iv);
const shareUrl = `https://fileshot.io/d/${uploadId}#${fragment}`;

The #fragment part of a URL is never sent to the server by the browser in HTTP requests. It exists only client-side. This is the architectural guarantee: even if someone intercepted every single HTTP request made to the server, they would never see the decryption key.

Step 7 — Upload the Encrypted Bytes + IV to the Server

The server receives:

  • The encrypted file bytes (ciphertext)
  • The IV (required for decryption, but not secret on its own)
  • File metadata if any (name, size — these can also be encrypted)

It does not receive the key. The server stores encrypted blobs it genuinely cannot decrypt.

Step 8 — Decryption on the Recipient's Side

When the recipient opens the share link, their browser parses the URL fragment to extract the key and IV, downloads the ciphertext, and decrypts locally:

// Parse the fragment
const [keyB64, ivB64] = location.hash.slice(1).split('.');

function fromBase64url(str) {
  const b64 = str.replace(/-/g,'+').replace(/_/g,'/');
  const bin = atob(b64);
  return Uint8Array.from(bin, c => c.charCodeAt(0));
}

const rawKey = fromBase64url(keyB64);
const iv = fromBase64url(ivB64);

// Import the key
const key = await crypto.subtle.importKey(
  "raw", rawKey,
  { name: "AES-GCM", length: 256 },
  false, ["decrypt"]
);

// Decrypt
const decryptedBuffer = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv: iv },
  key,
  encryptedBytes  // downloaded from server
);

// decryptedBuffer is the original file bytes

Decryption happens entirely in the recipient's browser. The server is never involved in the decryption process at all.

Optional: Password-Protected Keys

For an extra layer of security, the encryption key can itself be encrypted with a password-derived key using PBKDF2:

// Derive a key-wrapping key from the user's password
const passwordKey = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(password),
  "PBKDF2", false, ["deriveKey"]
);

const wrappingKey = await crypto.subtle.deriveKey(
  { name: "PBKDF2", salt: salt, iterations: 310000, hash: "SHA-256" },
  passwordKey,
  { name: "AES-GCM", length: 256 },
  false, ["wrapKey", "unwrapKey"]
);

// Wrap (encrypt) the file encryption key with the password-derived key
const wrappedKey = await crypto.subtle.wrapKey(
  "raw", fileKey, wrappingKey,
  { name: "AES-GCM", iv: wrapIv }
);

Now neither the server nor someone who intercepts the URL can decrypt the file without also knowing the password. FileShot's password protection feature uses exactly this pattern.

Performance: What About Large Files?

For very large files (hundreds of megabytes or multiple gigabytes), encrypting the entire file in a single crypto.subtle.encrypt() call loads everything into memory at once. A more practical approach is to split the file into chunks and encrypt each chunk individually, or to use the Streams API combined with a streaming cipher wrapper.

FileShot handles large files (up to 10 GB per file on the free tier, up to 300 GB on paid plans) by using chunked upload architecture — the file is split into encrypted segments, each uploaded independently, and reassembled server-side in encrypted form. No single call needs to hold the entire file in memory.

Security Considerations

Key Length

Always use 256-bit keys for AES-GCM. The 128-bit variant is technically secure today, but 256-bit provides a larger security margin against future advances.

IV Uniqueness

The IV must be unique per encryption. Reusing an IV with the same key in GCM mode leaks the authentication key and can expose plaintext. Always generate a fresh IV with crypto.getRandomValues().

Secure Contexts

The Web Crypto API is only available in secure contexts (HTTPS or localhost). This is enforced by the browser — you cannot use crypto.subtle over plain HTTP. This is a feature, not a limitation: it guarantees encryption happens over a secure connection.

Key Storage

The URL fragment approach (storing the key in #fragment) is simple and effective for one-time shares. For persistent keys that need to survive browser sessions, the IndexedDB or the Web Crypto key storage APIs can be used to store CryptoKey objects that cannot be extracted from the browser.

Side Channels

Web Crypto's implementation in browser engines uses constant-time operations to prevent timing attacks. Unlike pure-JavaScript crypto libraries, the native implementation handles these low-level concerns correctly.

How FileShot Implements This

FileShot.io is built end-to-end on this architecture. When you upload a file:

  1. A 256-bit AES-GCM key is generated in your browser
  2. The file is encrypted locally before the upload even starts
  3. The encrypted bytes are uploaded — the server receives only ciphertext
  4. The decryption key is embedded in the shareable link's URL fragment
  5. When a recipient opens the link, their browser extracts the key from the fragment and decrypts locally

The FileShot server cannot decrypt your files. It doesn't have the keys. This isn't a policy — it's a technical impossibility by design.

The full client-side encryption code is open source on GitHub — you can verify every step of the encryption and decryption logic yourself.

Comparison: Client-Side vs. Server-Side Encryption

Property Client-Side Server-Side
Provider can read files? No Yes
Secure if server is breached? Yes No
Compelled disclosure risk? None (no keys to hand over) Yes
Insider threat risk? None Yes
Server can run virus scans? Not on ciphertext Yes

Conclusion

Client-side file encryption in JavaScript — using the Web Crypto API with AES-256-GCM — is mature, performant, and gives users a genuinely stronger privacy guarantee than any server-side encryption approach. The browser handles the cryptography natively, the key stays in the URL fragment and never reaches the server, and the result is a file sharing architecture where the service provider is cryptographically incapable of reading your files.

It's not magic — it's well-understood engineering. And it's exactly what FileShot uses on every upload.

Try FileShot's zero-knowledge encryption ?  |  Zero-Knowledge Encryption Guide  |  View the source code