DEV Community

FileShot
FileShot

Posted on

How I built zero-knowledge file sharing in the browser (AES-256-GCM, keys never leave the client)

Most "end-to-end encrypted" tools still send your key to the server at some point. I wanted to build something where the server is architecturally incapable of decrypting your file — not just policy-based, but mathematically impossible. Here's exactly how I built it for FileShot.io.

The core idea: the URL fragment is your vault

The #fragment part of a URL (everything after the #) is never sent to the server. The browser strips it before making any HTTP request. This is a well-known browser spec behavior, but most developers never exploit it for security.

That single fact powers the entire zero-knowledge model:

  1. Generate a random 256-bit AES key in the browser
  2. Encrypt the file with that key using AES-256-GCM
  3. Upload only the ciphertext — the server gets bytes it cannot read
  4. Put the key in the URL fragment: https://fileshot.io/d/abc123#<base64_key>
  5. Share the full URL — the recipient's browser decrypts locally

The server never sees the key. Not during upload, not during download, not ever.

The encryption code

Here's the actual implementation (simplified from production):

async function encryptFile(file) {
  // Generate a fresh key for every file
  const key = await crypto.subtle.generateKey(
    { name: 'AES-GCM', length: 256 },
    true,        // extractable — we need to put it in the URL
    ['encrypt', 'decrypt']
  );

  // Random 96-bit IV — never reuse an IV with the same key
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const plaintext = await file.arrayBuffer();

  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    plaintext
  );

  // Export key as raw bytes, then base64url-encode it
  const rawKey = await crypto.subtle.exportKey('raw', key);
  const keyB64 = bufferToBase64url(rawKey);

  // IV is prepended to ciphertext so the recipient can extract it
  const blob = new Uint8Array(iv.byteLength + ciphertext.byteLength);
  blob.set(iv, 0);
  blob.set(new Uint8Array(ciphertext), iv.byteLength);

  return { encryptedBlob: blob, keyB64 };
}
Enter fullscreen mode Exit fullscreen mode

After upload, the share URL is assembled as:

const shareUrl = `https://fileshot.io/d/${fileId}#${keyB64}`;
Enter fullscreen mode Exit fullscreen mode

The decryption code

On the recipient's side, the key is read straight from window.location.hash:

async function decryptDownload(encryptedBuffer) {
  const keyB64 = window.location.hash.slice(1); // strip the '#'

  const rawKey = base64urlToBuffer(keyB64);
  const key = await crypto.subtle.importKey(
    'raw', rawKey,
    { name: 'AES-GCM', length: 256 },
    false,       // non-extractable on the recipient side
    ['decrypt']
  );

  // Extract the 12-byte IV prepended during encryption
  const iv = encryptedBuffer.slice(0, 12);
  const ciphertext = encryptedBuffer.slice(12);

  const plaintext = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: new Uint8Array(iv) },
    key,
    ciphertext
  );

  return plaintext;
}
Enter fullscreen mode Exit fullscreen mode

No server calls. No key derivation. Just crypto.subtle doing the work locally.

Why AES-GCM specifically?

AES-GCM gives you authenticated encryption — it detects tampering. If someone on the server modified even one byte of the ciphertext, decryption fails with an error. You get both confidentiality and integrity in one primitive.

GCM also produces a 128-bit authentication tag automatically appended to the ciphertext. crypto.subtle handles all of this transparently.

The alternative (AES-CBC) requires a separate HMAC for integrity, is vulnerable to padding oracle attacks if misimplemented, and doesn't parallelize as well. GCM is the right choice for file encryption in the browser.

What the server actually sees

Here's the full picture of what hits the FileShot backend:

Data Server sees?
File contents No — only encrypted bytes
Encryption key No — never transmitted
Original filename Optional — can be omitted
File size Yes — needed for storage
Uploader IP Yes — standard HTTP
Access count Yes — for link expiry logic

The server is a dumb storage layer. It stores opaque blobs identified by random IDs.

The attack surface I thought hard about

Link interception: If an attacker intercepts the full URL (including fragment), they get the key. This is the fundamental trade-off of fragment-based ZKE — the link IS the key. Mitigations: burn-after-read links, download limits, short expiry times.

Browser history: The fragment appears in browser history. Users should be aware that the shared link persists locally. Open-source desktop apps or Incognito mode address this.

Server-side JS injection: If I served malicious JavaScript that exfiltrated the fragment, ZKE is broken. This is why Subresource Integrity matters — lock your JS with SRI hashes if you want to be rigorous.

Memory attacks: The plaintext lives briefly in the browser's JS heap during encrypt/decrypt. This is unavoidable with in-browser crypto. It's a much smaller window than traditional server-side encryption where plaintext sits at rest.

Performance

Using the WebCrypto API (crypto.subtle) means the browser runs AES-GCM through native code, not a JS polyfill. On modern hardware this saturates network bandwidth:

  • 1 MB file → encrypted and ready in ~8ms
  • 50 MB file → ~400ms encryption time
  • Upload is I/O bound, not CPU bound

In practice, FileShot completes a 1 MB upload (encrypt + transfer + link ready) in around 1.6 seconds on a decent connection.

Streaming large files

One limitation: crypto.subtle.encrypt requires the full plaintext in memory before producing ciphertext. For files above ~500 MB this is a concern on constrained devices.

The workaround for truly large files is chunked encryption — split the file into 64 MB chunks, encrypt each independently with the same key but unique IVs, upload chunks in parallel, and on download decrypt and stream chunks to disk as they arrive. This is on the FileShot roadmap.

The UX challenge

Zero-knowledge means no password recovery. If you lose the link containing the key, the file is gone forever — you cannot ask the server to "reset" it. This is the correct behavior, but it's jarring for users arriving from Dropbox or Google Drive.

The solution is clear UI copy: "The download link IS the decryption key. Save it." FileShot shows this prominently before the link disappears.

Try it

Everything described here is live at FileShot.io — no account required, free for files up to 50 GB. The zero-knowledge model applies to every file uploaded.

If you're building something similar, the two things worth emphasizing: use AES-GCM (not CBC), and prepend the IV to the ciphertext so the download side doesn't need a separate round-trip to get it.

Happy to answer questions in the comments — especially if you've run into the streaming large files problem.

Top comments (0)