One of the core challenges in building a zero-knowledge file sharing service is key distribution: how do you give the recipient the decryption key without the server ever seeing it?
The answer has been embedded in the HTTP spec since 1994. It's called the URL fragment.
The Problem
In most "encrypted" file sharing services, the workflow looks like this:
- User uploads file
- Service encrypts it server-side (using their own keys)
- Service sends recipient a download link
- Service decrypts the file when the recipient requests it
This is encryption at rest. The service can read every file whenever they want. The encryption protects against disk theft, not against the service itself or its government.
True zero-knowledge means the server is architecturally prevented from decrypting files — not by policy, but by the fact that it never has the key.
The URL Fragment Solution
The HTTP/1.1 RFC 2396 specifies:
"A fragment identifier is separated from the rest of a URI by a hash (#) character and contains additional resource information."
And critically: HTTP clients (browsers) do not include the fragment in requests to servers. The fragment lives entirely in the browser.
This is why anchor links work without a round-trip: when you navigate to page.html#section-3, the browser fetches page.html and then scrolls to #section-3 locally, without telling the server which anchor you navigated to.
Applying This to Key Transport
When a file is shared with zero-knowledge encryption, the share URL looks like:
https://fileshot.io/d/a1b2c3d4e5#AES_KEY_GOES_HERE
When the recipient visits this URL:
- Browser sends
GET /d/a1b2c3d4e5to the server — the fragment is stripped - Server has no idea what
AES_KEY_GOES_HEREis - Server returns the encrypted ciphertext
- Browser extracts the key from
location.hash - Browser decrypts the file locally using the Web Crypto API
You can verify this behavior in your browser's DevTools. Navigate to any URL with a fragment and look at the Network tab — the request URL sent to the server will never include the #... portion.
The Implementation with Web Crypto API
Here's the core of how this works in browser JavaScript:
// Encrypt
async function encryptFile(file) {
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable
['encrypt', 'decrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
await file.arrayBuffer()
);
// Export key as URL-safe base64
const rawKey = await crypto.subtle.exportKey('raw', key);
const keyB64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return { ciphertext, iv, keyFragment: keyB64 };
}
// Decrypt
async function decryptFile(ciphertext, iv, keyFragment) {
// Re-pad base64
const padded = keyFragment.replace(/-/g, '+').replace(/_/g, '/');
const rawKey = Uint8Array.from(atob(padded), c => c.charCodeAt(0));
const key = await crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
return plaintext;
}
The key extracted from location.hash.substring(1) is passed directly to decryptFile. The server never sees it.
The AES-GCM Tag Provides Authentication Too
A detail worth noting: AES-GCM (Galois/Counter Mode) is an authenticated encryption scheme. The 128-bit authentication tag appended to the ciphertext means:
- If the ciphertext is tampered with (even one bit), decryption fails with an integrity error
- The recipient knows the file hasn't been modified in transit
- No separate HMAC is needed
This is why crypto.subtle.decrypt can throw — it's verifying the tag, not just decrypting.
What Can Go Wrong
Shared URLs over insecure channels
If you paste the share URL (including fragment) into an HTTP URL shortener that logs the full URL, or send it over an unencrypted channel that logs messages, the key is exposed. The fragment is only safe because HTTP requests don't include it — any logging of the full URL string exposes the key.
JavaScript injection on the share page
If an attacker can inject JavaScript into the decryption page, they can read location.hash before decryption. This is why zero-knowledge services need careful CSP headers and subresource integrity.
Key derivation vs. raw keys
Passing raw AES keys in URL fragments is fine for ephemeral file sharing. For longer-lived keys, HKDF derivation from a passphrase gives more flexibility and lets you revoke access without sharing the raw key material.
Try It
The implementation described here is the basis of FileShot.io — a zero-knowledge file sharing service with an MIT-licensed backend you can self-host:
git clone https://github.com/FileShot/FileShotZKE.git
cd FileShotZKE/backend && npm install && node server.js
Open DevTools Network tab while using it and watch the upload/download requests — you'll never see a key.
The URL fragment has been part of the web for 30 years. It remains one of the most underused privacy primitives in browser engineering.
Top comments (0)