DEV Community

Cover image for How to Share Encrypted Data with a Link (Without the Server Seeing the Key)
Aniruddha
Aniruddha

Posted on Fully Autonomous

How to Share Encrypted Data with a Link (Without the Server Seeing the Key)

In my previous post we encrypted user data in the browser with the Web Crypto API, so the server only ever stores bytes it can't read.

That raises the next question fast. A user wants to share one item with a friend who has no account. The friend needs the key to decrypt it. How do you hand them the key without your server ever seeing it?

The answer is a tiny browser rule most of us use every day without thinking: the part of a URL after # is never sent to the server. Put the key there and the link carries its own key.

This is how Excalidraw's shareable links work (excalidraw.com/#json=<id>,<key>), and how PrivateBin and the old Firefox Send worked too.

In this post we'll build it from scratch:

  • a "create secret link" page
  • a viewer page that decrypts from the link alone
  • a 50-line Node server that logs every request it gets, so you can see with your own eyes that the key never arrives

All the code runs in a modern browser and Node 20+, with no dependencies.

1. What the URL fragment is

Take this URL apart:

https://example.com/s/abc123?utm_source=x#KEY
Enter fullscreen mode Exit fullscreen mode
  • https://example.com is the origin
  • /s/abc123 is the path
  • ?utm_source=x is the query
  • #KEY is the fragment

The fragment was designed for jumping to a spot on a page, like #installation in a README. Because it only means something to the page itself, the browser keeps it to itself. When you open that URL, the HTTP request line is just:

GET /s/abc123?utm_source=x
Enter fullscreen mode Exit fullscreen mode

The fragment is also stripped from the Referer header the browser sends when that page loads scripts or links to other sites.

Don't take my word for it. Here is the log from the demo server below after I opened http://localhost:3000/index.html?ref=demo#topsecret in Chrome, created a link, and opened it in a second, fresh browser context (favicon and repeat requests trimmed):

GET /index.html?ref=demo  referer=-
GET /share-crypto.js  referer=http://localhost:3000/index.html?ref=demo
POST /api/shares  referer=http://localhost:3000/index.html?ref=demo
GET /s/427bad662ff77df8  referer=-
GET /share-crypto.js  referer=-
GET /api/shares/427bad662ff77df8  referer=-

key in server log? false | "topsecret" in log? false
Enter fullscreen mode Exit fullscreen mode

The query string ?ref=demo shows up everywhere. The word topsecret shows up nowhere. Neither does the real share key. That is the whole trick.

2. The plan

Sender's browser                    Server                   Recipient's browser
----------------                    ------                   -------------------
make a fresh key
encrypt the text
POST ciphertext  ----------------> store bytes under an id
                 <---------------- { id }
link = /s/<id>#<key>
send link to a friend  ... (chat, email) ...  ---------->    open /s/<id>#<key>
                                    GET /api/shares/<id> <-- fetch ciphertext
                                    ciphertext --------->    read key from location.hash
                                                             decrypt locally
Enter fullscreen mode Exit fullscreen mode

The server only ever holds two things: a random id and bytes it can't read.

3. One fresh key per link

Create share-crypto.js:

const IV_BYTES = 12;

export async function newLinkKey() {
  // extractable: true because the key has to be turned into text for the URL
  const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 128 }, true, [
    "encrypt",
    "decrypt",
  ]);
  const raw = new Uint8Array(await crypto.subtle.exportKey("raw", key));
  return { key, keyText: toBase64Url(raw) };
}

export function importLinkKey(keyText) {
  return crypto.subtle.importKey("raw", fromBase64Url(keyText), { name: "AES-GCM" }, false, [
    "decrypt",
  ]);
}
Enter fullscreen mode Exit fullscreen mode

A few decisions here are worth explaining.

Why a new key for every link, instead of the user's main key? If a link leaks, it exposes that one item and nothing else. You never put your main key in a URL.

Why 128 bits and not 256? AES-128 is still considered secure, and it keeps the link short. 16 bytes become 22 characters in the URL.

Why extractable: true on the sender side? The key has to be exported to become text. On the recipient side we import it with extractable: false and only the decrypt usage, because the viewer has no reason to do anything else with it.

Why base64url instead of plain base64? Plain base64 uses +, / and =, and all three mean something in URLs. base64url swaps them for - and _ and drops the padding:

export function toBase64Url(bytes) {
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export function fromBase64Url(text) {
  const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}
Enter fullscreen mode Exit fullscreen mode

4. Encrypt and upload

Encryption is the same AES-GCM pattern from the previous post: a fresh random 12-byte IV for every message, stored in front of the ciphertext.

// Layout: [iv (12 bytes)] [ciphertext + 16-byte auth tag]
export async function seal(key, text) {
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    new TextEncoder().encode(text),
  );
  const out = new Uint8Array(IV_BYTES + ct.byteLength);
  out.set(iv, 0);
  out.set(new Uint8Array(ct), IV_BYTES);
  return out;
}

export async function open(key, bytes) {
  const iv = bytes.slice(0, IV_BYTES);
  const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, bytes.slice(IV_BYTES));
  return new TextDecoder().decode(pt);
}
Enter fullscreen mode Exit fullscreen mode

Now the "create" page, index.html:

<h1>Create a secret link</h1>
<textarea id="text">The Wi-Fi password is hunter2</textarea>
<p><button id="share">Create link</button></p>
<p id="result"></p>

<script type="module">
  import { newLinkKey, seal, buildShareUrl } from "./share-crypto.js";

  document.getElementById("share").onclick = async () => {
    const { key, keyText } = await newLinkKey();
    const ciphertext = await seal(key, document.getElementById("text").value);

    // The server receives only the encrypted bytes, never keyText
    const res = await fetch("/api/shares", { method: "POST", body: ciphertext });
    const { id } = await res.json();

    const url = buildShareUrl(location.origin, id, keyText);
    document.getElementById("result").innerHTML = `Your link: <a id="link" href="${url}">${url}</a>`;
  };
</script>
Enter fullscreen mode Exit fullscreen mode

The server side is deliberately dumb. It stores whatever bytes it gets under a random id and hands them back later. It can't do anything else with them, because it has no key. Here is all of server.mjs:

import http from "node:http";
import { readFile } from "node:fs/promises";
import { randomBytes } from "node:crypto";

const shares = new Map(); // id -> Buffer (ciphertext only)
const types = { ".html": "text/html", ".js": "text/javascript" };

async function sendFile(res, file) {
  const ext = file.slice(file.lastIndexOf("."));
  res.writeHead(200, { "Content-Type": types[ext] ?? "text/plain" });
  res.end(await readFile(new URL(file, import.meta.url)));
}

const server = http.createServer(async (req, res) => {
  // This is everything the server can ever know about a request
  console.log(`${req.method} ${req.url}  referer=${req.headers.referer ?? "-"}`);
  const path = new URL(req.url, "http://localhost").pathname;

  if (req.method === "POST" && path === "/api/shares") {
    const chunks = [];
    for await (const c of req) chunks.push(c);
    const id = randomBytes(8).toString("hex");
    shares.set(id, Buffer.concat(chunks));
    res.writeHead(201, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ id }));
  }

  const blob = path.match(/^\/api\/shares\/([a-f0-9]+)$/);
  if (req.method === "GET" && blob) {
    const data = shares.get(blob[1]);
    if (!data) return res.writeHead(404).end();
    res.writeHead(200, { "Content-Type": "application/octet-stream" });
    return res.end(data);
  }

  if (/^\/s\/[a-f0-9]+$/.test(path)) return sendFile(res, "./view.html");
  if (path === "/" || path === "/index.html") return sendFile(res, "./index.html");
  if (path === "/share-crypto.js") return sendFile(res, "./share-crypto.js");
  res.writeHead(404).end();
});

server.listen(3000, () => console.log("http://localhost:3000"));
Enter fullscreen mode Exit fullscreen mode

Run node server.mjs, open http://localhost:3000 and click the button:

The create page showing a generated secret link

5. Build the link

export function buildShareUrl(origin, id, keyText) {
  // Query params (utm tags etc.) must go BEFORE the "#", or they become part of the key
  return `${origin}/s/${id}#${keyText}`;
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: everything after # is the fragment, including anything that looks like a query string. /s/abc123?utm_source=x#KEY works. /s/abc123#KEY?utm_source=x gives the viewer a "key" of KEY?utm_source=x, and decryption fails. If marketing wants tracking tags on share links, they go before the #.

6. Open the link

The viewer, view.html, reads the key with location.hash.slice(1), fetches the ciphertext by id, and decrypts:

<meta name="referrer" content="no-referrer" />

<h1>Someone shared this with you</h1>
<pre id="out">Decrypting...</pre>

<script type="module">
  import { importLinkKey, open } from "/share-crypto.js";

  const out = document.getElementById("out");
  const id = location.pathname.split("/").pop();
  const keyText = location.hash.slice(1); // drop the "#"

  try {
    const res = await fetch(`/api/shares/${id}`);
    if (!res.ok) throw new Error("not found");
    const bytes = new Uint8Array(await res.arrayBuffer());
    out.textContent = await open(await importLinkKey(keyText), bytes);
  } catch {
    out.textContent = "This link is broken, was turned off, or is missing its key.";
  }
</script>
Enter fullscreen mode Exit fullscreen mode

One catch covers every failure, and that's on purpose. A missing key, a wrong key, a truncated key and a deleted link all end up there. AES-GCM checks an authentication tag, so a wrong key doesn't give you garbage text. It throws.

The viewer page showing the decrypted message

7. Where the key can still leak

The fragment keeps the key away from your server. It doesn't make the link magic. Be honest with your users about this:

  • The link is the secret. Anyone who has the full URL can read the data. If it's pasted into a group chat, the whole group has it. That is the deal with any "secret link" feature.
  • Browser history. The full URL, fragment included, is saved in history, and in synced history if the user has sync on.
  • JavaScript on the viewer page can read location.hash. This one matters most for you as a developer. A third-party analytics or session replay script that records full URLs will capture the key and send it to someone else's server. Keep the viewer page free of third-party scripts.
  • Browser extensions with access to the page can read it too.

Two cheap extra protections:

  1. <meta name="referrer" content="no-referrer"> on the viewer, so the page URL is never sent to other sites. Browsers already strip the fragment from Referer, but this costs nothing.
  2. After reading the key, you can remove it from the address bar with history.replaceState(null, "", location.pathname). The trade-off: refreshing the page no longer works, because the key is gone.

8. Features you can add next

  • Revoke a link: delete the ciphertext on the server. The link now gets a 404 and shows the "turned off" message. You can't un-share data someone already opened, but you can stop new opens.
  • Update a link: encrypt new content with the same link key and replace the stored blob. Same URL, new content.
  • Let owners see their links later: the sender needs the link key again to rebuild the URL. Store it wrapped (encrypted) with the user's main key, using the key-wrapping approach from the previous post.
  • Limits: your server can't see what it's storing, so cap the size and how many links a user can create per day.

Wrapping up

A browser rule written for "jump to section 2" turns out to give you end-to-end encrypted sharing with no key exchange, no accounts for the recipient, and a server that can't read what it stores. Fresh key per link, base64url in the fragment, AES-GCM to fail loudly on a bad key, and keep third-party scripts off the viewer page.

I built this for CanvasKeep, a free diagramming tool with end-to-end diagram encryption, where anyone can view a shared diagram from a link and the key never reaches the server.

Top comments (0)