How to Send a Password Without Leaving It in Your Chat History
You need to give a coworker the Wi-Fi password, or a family member the login for a shared account. The fastest way is to paste it into a chat. The trouble is that chat messages are built to last. They sync to every devi
You need to give a coworker the Wi-Fi password, or a family member the login for a shared account. The fastest way is to paste it into a chat.
The trouble is that chat messages are built to last. They sync to every device you're signed in on, they get backed up, they show up in search, and they're still there years later when someone borrows your laptop or a backup leaks. A password pasted into a chat lives as long as the chat does.
This post walks through a simple fix, the one-time secret link: how it works, the browser crypto that makes it possible, and the limits worth knowing before you rely on it.
The idea: a link that works once
A one-time secret link (also called burn-after-reading) turns a piece of text into a link that the recipient can open exactly once. After that it's destroyed, and if nobody opens it before it expires, it's destroyed too.
What's left in the chat history is a dead link. Anyone scrolling back later finds a link that no longer opens anything.
Plenty of tools offer this. The interesting part is how you build one so that the service in the middle can't read the secret either.
How it works under the hood
A good design has four pieces.
1. Encrypt in the browser, before anything is uploaded
The sender's browser generates a random key and encrypts the text locally, using AES-GCM via the Web Crypto API. Only the encrypted bytes (the ciphertext) go to the server.
2. Put the key in the URL fragment
The key goes into the link, after the #:
https://example.com/s/abc123#k=Q3VyaW91cyBhcmVuJ3QgeW91
The part after # is the fragment. Browsers don't send the fragment to the server when they request the page, and they leave it out of the Referer header too. So the server stores ciphertext it has no key for, and the key travels only inside the link you send.
The fragment does stay in the browser's address bar and history, and in the chat as part of the link. That's exactly the copy that goes dead once the secret is burned.
3. Burn on read, and expire
When the recipient opens the link, the server hands over the ciphertext once and deletes it. If the link is never opened, a timer deletes it anyway.
4. Don't burn on page load
Chat apps often fetch a link in the background to build a preview card. If merely loading the page reveals and burns the secret, a preview bot can use up the one view before your recipient ever sees it. The fix is to load the page with zero requests to the secret, and fetch the ciphertext only when a person presses an "open" button.
A minimal sketch in JavaScript
Here's the core of both sides using the Web Crypto API. upload and fetchAndBurn stand in for your own server calls.
// helpers: bytes <-> URL-safe base64
const toB64Url = (bytes) =>
btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromB64Url = (s) =>
Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
// Sender: encrypt locally, upload only ciphertext, keep the key for the link
async function createLink(secret) {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(secret));
const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
const id = await upload(iv, new Uint8Array(ciphertext)); // server sees iv + ciphertext only
return `https://example.com/s/${id}#k=${toB64Url(rawKey)}`;
}
// Recipient: runs only after the person clicks "open"
async function openLink(id) {
// base64url has no & or =, so URLSearchParams can parse the fragment safely
const k = new URLSearchParams(location.hash.slice(1)).get("k");
const key = await crypto.subtle.importKey("raw", fromB64Url(k), "AES-GCM", false, ["decrypt"]);
const { iv, ciphertext } = await fetchAndBurn(id); // server returns once, then deletes
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(plain);
}
AES-GCM is authenticated encryption, so if the ciphertext was altered on the way, decrypt throws instead of returning garbage.
This sketch fetches before it decrypts, so a link with a wrong or truncated key would still burn the secret. A stricter design checks the link locally before contacting the server, so a broken link never reaches it. The implementation below works that way.
One implementation: tacet's one-time secret
tacet's one-time secret follows this pattern, and its explainer page documents the details:
- The key is generated in your browser and never leaves the device; it lives only in the link fragment (
#k=). - The content is encrypted with AES-GCM before upload. The server receives only a truncated hash as a locator, plus the ciphertext.
- It's burned on one open. If the expiry (1, 3 or 7 days) passes first, it's destroyed automatically, with no backup and no recovery.
- Loading the page makes zero requests; the server is only asked when you press open, so a preview bot can't burn it.
- If the locator and the key in the link don't match, no request is made, so a link that couldn't be opened anyway doesn't get burned.
The page also lists its own limits: there's no native app, only a web version; the crypto core is open source but the main program is not; and expiry comes in three fixed tiers. Details: tacet.ink/one-time-secret/en/
What one-time links protect, and what they don't
They help with:
- The chat history problem. After the secret is opened, or expires, the link in the chat is dead.
- The server in the middle. The server only ever holds ciphertext, and the key never reaches it. A breach of the server's storage exposes encrypted bytes, not your password.
They don't help with:
- Someone who gets the link first. Until it's opened, the link is the secret. If someone else opens it first, they read it, but your recipient will then see "already destroyed". Treat that as a signal to change the password.
- A compromised device on either end, or a recipient who copies the secret somewhere unsafe.
- The code you're served. Encryption in the browser only helps if the JavaScript doing it is honest. A malicious operator could in theory serve tampered code. Open-source crypto code and pages that let you check the behavior yourself reduce that risk, but they don't remove it.
- Getting it back. Once it's opened or expired, it's gone. Don't use a one-time link for the only copy of something you need to keep.
A quick checklist
- Use the shortest expiry that works for the recipient.
- Send the link in a different channel from the username or context it belongs to.
- If the recipient says the link was "already used", assume it leaked and rotate the password.
- For anything long-lived and shared by a team, a shared vault in a password manager is a better home. One-time links are for handing something over, not for storing it.
This article was created with the help of AI and reviewed by a person before publishing.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.