Security

    How One-Time Secret Links Work

    A one-time secret link reveals its contents once: the browser encrypts it, the server stores only ciphertext, and the first reveal burns the stored copy.

    W. Miller · Updated · About LinkPilot

    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

    1. 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.
    2. Sender shares the URL through any channel — email, chat, SMS.
    3. Recipient opens the URL. A plain GET returns 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.
    4. 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?

    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.

    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 GET on the reveal endpoint returns only status and whether a passphrase is required. A view is recorded only on an explicit POST — 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-store on 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.

    • 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.

    References

    Tools for this article

    Frequently asked questions

    LinkPilot sends a secret link that is gone after it is opened.

    Encrypted in your browser, revealed once, then destroyed. No account needed.

    Create a secret link

    Read next