A one-time secret link is a URL that reveals its contents exactly
once and then becomes permanently inert. The sender's browser
encrypts the secret with AES-GCM-256, the server stores only the
ciphertext, the key travels in the URL fragment after #k=, which
browsers never send, and the recipient's first reveal decrypts it and
burns the stored copy in the same operation.
You can make one without an account with the free secret link generator.
The mechanism is simple, but the edge cases are where it gets interesting — and where sloppy implementations quietly leak.
The basic flow
- Sender pastes a secret. The browser generates a random key,
encrypts the payload with AES-GCM-256 through the
Web Crypto API, and sends
only the ciphertext. The server stores it under a random, hard-to-guess
slug and returns a URL; the browser appends the key as a
#k=fragment, which never leaves the sender's device. - Sender shares the URL through any channel — email, chat, SMS.
- Recipient opens the URL. A plain
GETreturns status only. When they click reveal, the server atomically marks the secret as consumed and returns the ciphertext in the same operation; the recipient's browser decrypts it with the key from the fragment. - Any subsequent visit gets a "burned" status page instead of content.
The critical step is step 3. If the read and the status update
happen as two separate queries — SELECT to check, then UPDATE to
mark — two concurrent viewers can both pass the check before either
one writes. Both get the payload. That's a classic TOCTOU
(time-of-check to time-of-use) bug, and it converts your "exactly
once" guarantee into "usually once."
Why must the burn be atomic with the read?
Because two concurrent viewers can otherwise both pass the check
before either one marks the secret consumed. The fix is to collapse
the check, the mark, and the read into one database operation. In
Postgres, using
UPDATE ... RETURNING,
that looks like:
UPDATE secrets
SET burned_at = now()
WHERE slug = $1
AND burned_at IS NULL
AND expires_at > now()
RETURNING payload;
The row-level lock the UPDATE takes means concurrent callers
serialize: the first one matches the WHERE clause, flips
burned_at, and gets the payload back via RETURNING. Every later
caller finds burned_at already set, matches zero rows, and gets
the burned status. There is no window between "check" and "consume"
because they are the same statement.
LinkPilot records views atomically through a database function for exactly this reason — concurrent reveal attempts cannot leak the secret twice. If you're evaluating a tool, this is a fair question to ask the vendor directly: is the burn atomic with the read?
Why do burned links return HTTP 410 and not 404?
Because 410 Gone says the resource existed and is permanently unavailable, which is exactly what a burned secret is. Once consumed, the link should fail loudly and permanently. LinkPilot does two things on burn:
- The stored payload is overwritten with
[REDACTED]in the same write that flips the status — so even a later database compromise can't recover a spent secret. - Subsequent reveal attempts return HTTP 410 Gone with a clear "already viewed and destroyed" page.
410 is the right status code: it means the resource existed and is intentionally, permanently unavailable — as opposed to 404, which says "never heard of it." The distinction matters for humans too: a recipient who sees "this secret was already viewed" knows to tell the sender, who can check the audit trail and rotate if the viewer wasn't them.
Hard-to-guess slugs, and why they aren't enough
The slug is what stands between the public internet and an unread ciphertext (the key is in the fragment, not on the server), so it has to carry real entropy. A slug drawn from a cryptographically secure random source with, say, 22 characters of base62 carries roughly 128 bits — enumeration is not a realistic attack at any request rate a server would survive.
But slug entropy only defends against guessing. It does nothing about the URL being observed: browser history, proxy logs, forwarded emails, screen shares, clipboard managers. That's why serious implementations layer on:
- Expiry, so an observed URL goes dead on schedule.
- A passphrase, so an observed URL alone isn't sufficient.
- Burn-after-read, so an observed URL is worthless after the legitimate read.
Link previews and bots
Chat clients (Slack, Teams, iMessage) and email security scanners
unfurl URLs by fetching them with a GET request the moment a
message is delivered. If your reveal endpoint burns on GET, the
unfurler destroys the secret before the human ever sees it — the
most common real-world failure of naive burn-after-read tools.
The defenses, as LinkPilot implements them:
- Separate metadata and reveal actions. A
GETon the reveal endpoint returns only status and whether a passphrase is required. A view is recorded only on an explicitPOST— the action a human takes when they click the reveal button. - Passphrase gate (Pro). When a passphrase is set, no payload is returned to anyone — bot or human — without the correct hash.
Cache-Control: no-storeon every reveal response, so intermediaries never cache secret contents.
LinkPilot's Security Architecture page documents this handling end to end.
The lifecycle, state by state
| State | Triggered by | What a visitor sees | Payload recoverable? |
|---|---|---|---|
| Active | Creation | Reveal page (passphrase prompt if set) | Yes — one reveal available |
| Burned | View-count limit reached | "Already viewed and destroyed" — HTTP 410 | No — payload overwritten |
| Expired | Expiry deadline passes | Expired status page | No — redacted on next access |
| Revoked | Sender kills it from the dashboard | Refuses to reveal — HTTP 410 | No — immediate and permanent |
Every transition lands in the audit timeline, a Pro feature in
LinkPilot. The event types
are created, viewed, failed_passphrase, burned, expired,
and revoked, each with a timestamp and a hashed IP (SHA-256 with a
daily-rotating salt — the raw IP is never stored). That trail is
what turns "I think they got it" into "viewed at 10:05 from the
expected country, one view, then burned."
Common extensions
- Passphrase gate — require an out-of-band passphrase before the reveal, so a leaked URL alone is not a leaked secret. In LinkPilot this is a Pro feature ($29/mo), alongside custom expiry and the audit timeline; the no-account generator cannot set one. Rate-limit attempts; LinkPilot allows 5 per IP per minute per secret and logs every failure.
- Time-based expiry — the secret dies after a set window even if never read. LinkPilot's signed-in form offers 1 hour, 24 hours or 7 days on every plan, with a custom date and time on Pro; the no-account generator sets 7 days by default. Covers the forgotten-in-inbox case that burn-after-read can't.
- Manual revoke — the sender can kill an unread secret from the dashboard the moment something feels off. Cheap insurance for "sent it to the wrong Alex."
Edge cases that separate good implementations
The wrong person opens it first
Burn-after-read doesn't stop the wrong viewer — it makes the misdelivery visible. The intended recipient hits a burned page, the audit trail shows one view you didn't expect, and you rotate the credential. Painful, but bounded: exactly one exposure, detected quickly. Compare that with a pasted-in-chat secret, where you'd never know.
The screenshot problem
One-time means one reveal, not one copy. The recipient can screenshot, copy, or retype the secret — nothing about the link prevents that, and vendors who imply otherwise are selling fiction. The honest framing: burn-after-read controls the delivery channel, not the recipient. Trust in the recipient is an input, not an output.
Browser prefetch and speculative loading
Some browsers and apps prefetch likely-clicked URLs. Because a
well-built tool only burns on an explicit POST, prefetch behaves
exactly like an unfurler: it can fetch the page shell, never the
payload.
When not to use a one-time link
- The value will be needed again. Recoverability is the whole design goal of a password manager and the anti-goal of a one-time link. Use a vault item.
- Multiple recipients need it. One link, one read. Fan-out means either creating one link per person (fine, auditable) or admitting you need shared access control instead.
- A machine is the consumer. Services and CI should read from a secrets manager, not click links.
If you want to watch the mechanism in action, create one with the free secret link generator — no account needed, encrypted in your browser, gone after the first reveal.
Related reading
- Secure password sharing: 5 methods compared
- Best OneTimeSecret alternatives
- OneTimeSecret vs Privnote vs Password Pusher
- Expiring links: the secret is destroyed, not just redirected
- Free secret link generator
References
- W3C, Web Cryptography API.
- IETF, RFC 3986, section 3.5 Fragment.
- IETF, RFC 9110: HTTP Semantics, section 15.5.11 410 Gone.
- PostgreSQL Documentation, UPDATE.