A browser-encrypted text link separates the stored message from its decryption key. The browser encrypts the text, the server stores an envelope of ciphertext and metadata, and the recipient's browser obtains the key from the link fragment.
This tutorial supplies a complete encryption helper, not a production one-time-link service. Its tests ran on Node.js 20.11.1 using Web Crypto. A browser UI, HTTPS deployment, abuse controls and a durable atomic reveal endpoint still need integration and testing. The server-side consumption design is covered in the companion transaction article.
State the threat model first
The scheme can reduce exposure from a database-only leak when the attacker does not also obtain the link key. It does not protect against a malicious site operator changing JavaScript, compromised dependencies, XSS, a hostile browser extension or an infected endpoint. Anyone receiving the complete link can decrypt and copy the content.
A URL fragment is not sent as part of the ordinary HTTP request target. However, page JavaScript can read it, and the full URL may appear in clipboard history, messaging systems, browser history or screenshots. “The server normally stores ciphertext” is accurate; “the server can never read this” is not. URI fragments.
Create a small authenticated envelope
The downloadable module generates a fresh 256-bit AES-GCM key for every message and a fresh 12-byte IV. It binds the ciphertext to a versioned application context through additional authenticated data, and enforces a 64 KiB plaintext limit.
build-browser-encrypted-one-time-text-links/
├── README.md
├── envelope.mjs
└── test.mjsAES-GCM needs a unique IV for every encryption under a given key. A fresh random key per message avoids intentionally reusing an encryption key across messages; never add a “reuse key” optimization without revisiting nonce management. Authentication rejects tampering but does not identify who originally wrote the message. AES-GCM parameters.
import { sealText } from "./envelope.mjs";
const { envelope, fragmentKey } = await sealText(textarea.value);
const response = await fetch("/api/secrets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(envelope)
// Apply the application's authentication/CSRF policy as appropriate.
});
if (!response.ok) throw new Error("Secret was not saved");
const { id } = await response.json(); // Server-generated random identifier.
if (!/^[A-Za-z0-9_-]{43}$/.test(id)) throw new Error("Invalid server ID");
const shareURL = new URL("/secret/" + id, location.origin);
shareURL.hash = fragmentKey;Send only the envelope to storage. Do not send fragmentKey in JSON, telemetry or error reports. The server must independently validate envelope version, IV length, encoded size, expiry policy and creation quotas; client-side checks are not an abuse boundary.
Reveal only on a deliberate action
The landing GET should display a Reveal button without retrieving ciphertext. On click, read and validate the fragment, then POST to the atomic consume endpoint. Pass the returned envelope and key to openText. Render the plaintext using a textarea value or textContent, never innerHTML.
Keep third-party scripts off this page. Apply a restrictive content security policy, no-store responses, a no-referrer policy and dependency review. These measures reduce exposure but do not turn a browser-delivered app into an independently trusted client.
The server should mark consumption and commit before returning ciphertext. That means at most one successful server claim, not guaranteed delivery: if the response is lost, the message may be unavailable to everyone. Retrying plaintext decryption is safe locally; automatically retrying a consumed retrieval is a different protocol decision.
Verify both cryptography and product behaviour
Run node --test test.mjs. Tests cover Unicode round trips, independent keys, wrong keys, altered ciphertext, unsupported versions and size limits. They test the module, not recipient identity, a browser deployment or MySQL concurrency.
For the integrated service, confirm that storage and logs contain no plaintext or fragment key. Test two simultaneous reveals, an expired link, a missing key and a lost successful response. Ensure a preview crawler can fetch the landing page repeatedly without consuming the record.
Deleting a row does not erase backups or prevent recipient copies. Explain retention and one-time-delivery limits before the sender shares anything important.
Continue with preview-safe reveal actions and MySQL read-once transactions. CodeKits can help build a carefully scoped Next.js private-sharing workflow.
