Insights

Building Self-Destructing Links with Web Crypto

Developer tutorial for creating one-time links using the Web Crypto API.

By Quickburn Team · · 3 min read

tutorialweb

Self‑destructing links are a handy pattern for transferring sensitive data. This tutorial shows how to build a simplified version of Quickburn using the Web Crypto API and a tiny Next.js API route. You’ll learn the core cryptographic operations and the lifecycle of a one‑time secret.

Generating and encoding a key

Start by generating a random key and encoding it for transport. AES‑GCM with a 256‑bit key provides a good balance of security and browser support.

const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', true, ['encrypt', 'decrypt']);
const keyB64 = btoa(String.fromCharCode(...keyBytes));

The keyB64 string will later be appended to the URL fragment so only the recipient can decrypt the message.

Encrypting the message

const iv = crypto.getRandomValues(new Uint8Array(12));
const cipherBuffer = await crypto.subtle.encrypt(
  { name: 'AES-GCM', iv },
  key,
  new TextEncoder().encode(secret)
);

You now have the ciphertext and initialization vector (nonce). Send them to a server that stores the pair alongside an expiration timestamp. In Next.js, an API route might look like this:

// app/api/store/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function POST(req: Request) {
  const { cipher, iv, expires } = await req.json();
  const id = crypto.randomUUID();
  await db.secret.create({ data: { id, cipher, iv, expires } });
  return NextResponse.json({ id });
}

The response returns an identifier that you combine with the key fragment to form the shareable URL: https://yourapp.example/b/${id}#${keyB64}.

Decrypting and burning the secret

When the recipient visits the link, fetch the stored data and decrypt it using the key from location.hash:

const res = await fetch(`/api/secret?id=${id}`);
const { cipher, iv } = await res.json();
const keyBytes = Uint8Array.from(atob(hashFragment), c => c.charCodeAt(0));
const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', true, ['decrypt']);
const plaintext = await crypto.subtle.decrypt(
  { name: 'AES-GCM', iv: Uint8Array.from(iv) },
  key,
  Uint8Array.from(cipher)
);

After displaying the secret, issue a delete request so the data cannot be fetched again:

await fetch(`/api/secret?id=${id}`, { method: 'DELETE' });

Handling expiration

A cron job or scheduled serverless function should periodically remove expired secrets. Quickburn uses a simple SQL query, but any storage layer works as long as you limit retention.

UX considerations

Inform users that the link burns after reading and provide a countdown for the expiration. Build accessibility into the interface with semantic HTML and focus management so keyboard users can navigate easily.

Next steps

This tutorial barely scratches the surface. Production deployments need rate limiting, input validation, and robust logging. Still, the core idea — encrypt on the client, store only ciphertext, burn after use — can be implemented in a few dozen lines of code thanks to the Web Crypto API.

Keep exploring

Create a secret