DEV Community

Abdulrahman
Abdulrahman

Posted on

Making "one-time" links actually one-time: fragments, preview bots and an atomic DELETE

Every team has a "temporary" password sitting in a chat history somewhere. Someone pasted a database credential into a DM two years ago, and it is still there, searchable, synced to every device, included in every export.

One-time secret links are the usual fix: encrypt the secret, send a link, and the link dies after the first read. I built B1 Drop to do exactly that. When I started, though, I found that "one-time" is surprisingly easy to get wrong. Two failure modes kept coming up:

  1. The server can read the secret. If the key lives next to the ciphertext, "one-time" only describes the UI. The operator, a log pipeline, or a database backup can still read it.
  2. Link preview bots burn the link. Paste a link into Slack, iMessage, Teams or WhatsApp and a bot fetches it to build a preview card. If a plain GET consumes the secret, the bot reads it first and your recipient sees "already opened".

There's also a third, quieter problem: two requests racing to read the same secret. Here's how B1 Drop handles all three, with excerpts from the actual code.

Keeping the key off the server

Everything is encrypted in the browser with WebCrypto. For each drop, the client generates 32 random bytes with crypto.getRandomValues, plus a 16-byte salt and a 12-byte IV. Those 32 bytes never become the AES key directly. They go through HKDF-SHA256 with the salt and a fixed context string. If the sender sets a passphrase, PBKDF2-SHA256 with 600,000 iterations stretches it, and the result is concatenated with the random key before HKDF:

async function deriveKey(rawKey: Bytes, salt: Bytes, passphrase?: string): Promise<CryptoKey> {
  let ikm = rawKey;
  if (passphrase) {
    const pk = await subtle.importKey("raw", new TextEncoder().encode(passphrase.normalize("NFC")), "PBKDF2", false, ["deriveBits"]);
    const bits = new Uint8Array(await subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: PBKDF2_ITERATIONS }, pk, 256));
    ikm = new Uint8Array(rawKey.length + bits.length);
    ikm.set(rawKey, 0);
    ikm.set(bits, rawKey.length);
  }
  const hk = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
  return subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: INFO }, hk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
}
Enter fullscreen mode Exit fullscreen mode

With this design the passphrase is an extra factor, not a replacement. Without the 32 bytes from the link, the passphrase alone gets you nothing, and there's a unit test for exactly that case.

The data is then encrypted with AES-256-GCM. The envelope starts with a 30-byte header (version, flags, salt, IV), and that header is passed as additional authenticated data. Flip a salt byte or the passphrase flag and decryption fails instead of quietly doing something odd. The tests tamper with both the ciphertext and the header to check this.

The server only ever sees header || ciphertext. The browser then builds the link as ${location.origin}/${j.id}#${key}. The key is base64url-encoded into 43 characters and sits after the #. Browsers don't send the fragment in HTTP requests, so it never shows up in our server's request line or logs. The server only knows the 8-character ID. IDs are drawn from base-62 using rejection sampling (bytes of 248 and above are discarded), so they aren't biased toward some characters.

Two small details on the receiving side. Once the secret is decrypted, the page calls history.replaceState to strip the key out of the address bar and the history entry. Pages and API responses also send Referrer-Policy: no-referrer.

GET never consumes

This was the most important design decision, and it's almost boring: a GET request never changes state. The router makes that explicit:

if (!m[2] && req.method === "GET") return await status(req, env, m[1], now);
if (m[2] && req.method === "POST") return await reveal(req, env, url, m[1], now);
// ...
// Short link page. Serving it never touches the database, so link-preview bots cannot burn a drop.
if (ID_RE.test(p.slice(1))) return asset("/open.html", 200, true, false)!; // never frameable
Enter fullscreen mode Exit fullscreen mode

When a preview bot fetches https://b1.gg/AbCd1234, it gets a static HTML shell. Serving it doesn't run a single database query. The response is marked no-store and noindex, and it can't be framed (frame-ancestors 'none' plus X-Frame-Options: DENY), so another page can't clickjack the reveal button. When a real person opens the page, its script calls GET /api/drops/:id. That endpoint runs a SELECT that only reports whether the drop exists, whether it needs a passphrase, and when it expires. It never reads the ciphertext.

To actually reveal, you press Reveal and delete. That sends a POST to /api/drops/:id/reveal with a custom X-B1-Drop: 1 header, and the server refuses the request without it. Preview bots don't send POSTs with made-up headers. A malicious page can't make your browser do it cross-origin either, because the custom header triggers a CORS preflight that our server never approves. If an Origin header is present and doesn't match, the request is refused too. The test suite checks this directly. It fetches the share page five times with a Slackbot-LinkExpanding user agent and then confirms the drop can still be revealed. Another test confirms that a reveal without the header, or with a foreign Origin, returns 403 and doesn't consume the drop.

Reveal is one atomic read-and-delete

The naive version of "read once" looks like this: SELECT the row, return it, then DELETE it. Between those two statements, a second request can read the same row. Two tabs, a double-click, or a retrying proxy is enough to get two reads of a "one-time" secret.

B1 Drop collapses the read and the delete into a single SQL statement:

const row = await env.DB.prepare("DELETE FROM drops WHERE id = ? RETURNING expires_at, chunks").bind(id).first<{ expires_at: number; chunks: number }>();
if (!row) return null;
const [sel] = await env.DB.batch([
  env.DB.prepare("SELECT data FROM chunks WHERE drop_id = ? ORDER BY n").bind(id),
  env.DB.prepare("DELETE FROM chunks WHERE drop_id = ?").bind(id),
]);
if (row.expires_at <= now) return null;
Enter fullscreen mode Exit fullscreen mode

DELETE ... RETURNING is the gate. Only one request can delete a given row, so only one gets a non-null result. Every other request sees nothing and gets a 404 "gone". Only the winner reads the ciphertext chunks, and it deletes them in the same batch. Files are split into chunks of under 2 MB for storage, and a drop row is only inserted in the same transaction as all of its chunks.

Expiry is checked after the delete. That way, revealing an expired drop also removes it instead of leaving it for the cleanup job.

I didn't want to just trust this, so there's a test that fires 12 concurrent reveals at the same drop:

const res = await Promise.all(Array.from({ length: 12 }, (_, i) => reveal(id, `192.0.2.${i}`)));
const codes = res.map((r) => r.status).sort();
expect(codes.filter((c) => c === 200).length).toBe(1);
expect(codes.filter((c) => c === 404).length).toBe(11);
Enter fullscreen mode Exit fullscreen mode

Exactly one 200 and eleven 404s.

The other limits are deliberately simple. Expiry is one of three fixed values (1 hour, 1 day, 7 days), and the server rejects anything else. Files and messages are capped at 5 MB, checked in the browser and again on the server. Creates are limited to 20 per hour and lookups to 30 per minute per network. The rate limiter stores only a salted hash of the network, not the IP. A scheduled job purges expired drops.

What this doesn't protect against

I'd rather be precise about the limits than oversell. This list also appears on the security page:

  • Whoever opens the link first wins. The link is the key. If it leaks through chat history, a screenshot or a shared clipboard before the recipient opens it, the attacker reads it. The recipient then sees "gone" and knows something is wrong, but it can't be undone.
  • A passphrase doesn't stop burning. The reveal POST deletes the drop before the passphrase is checked in the browser. Someone with the link but not the passphrase can destroy the drop, even though they can't read it. Also, a wrong passphrase doesn't restore the drop. The ciphertext stays in that tab so you can retry, and it's gone once the tab closes.
  • Weak passphrases can be guessed offline by anyone who has both the link and the ciphertext. PBKDF2 slows that down. It doesn't prevent it.
  • You trust the JavaScript we serve. As with every browser-based encryption tool, a compromised server could ship code that leaks the key. A strict Content-Security-Policy and zero third-party scripts reduce the risk but don't remove that trust.
  • Compromised endpoints, malicious browser extensions, or a recipient who simply keeps a copy.
  • Metadata. The server stores the ciphertext size, creation and expiry times, and the salted network hash for rate limiting. Anyone holding a valid ID can also see whether a drop exists and when it expires.
  • Database backups. The database keeps point-in-time recovery snapshots for about 7 days. Any copy there is ciphertext without a key.
  • Sender identity. Drops are anonymous. Confirm who sent one through another channel.

If you're building something similar, these are the three rules I'd keep: put the key in the fragment, never let a GET change state, and make "read" and "delete" the same SQL statement.

Try it: https://b1.gg

Top comments (0)