Here is a failure mode that almost every burn-after-reading tool shares, and almost nobody warns you about.
You generate a one-time note β a password, an API key, a private address. You paste the link into Slack and hit enter. Your colleague clicks it thirty seconds later and sees "this note has already been read." Nobody read it. Nobody intercepted anything. The note was destroyed by Slack itself.
What actually happened
When you post a URL in a chat app, that app fetches the page to build the little preview card β the title, the description, the thumbnail. Slack does it, so do Discord, WhatsApp, Telegram, LinkedIn, Facebook Messenger, and iMessage. The fetch happens the moment you press enter, from the platform's servers, before any human clicks anything.
For an ordinary link that is harmless. For a note whose entire contract is "destroy this the first time it is read," the preview bot is the first read. The tool cannot tell the difference between a crawler grabbing an unfurl card and a person opening the link, so it does what it promised: it burns the note.
The tell: if a recipient reports that a one-time link was already consumed and you are certain nobody else had it, look at where you sent it. A chat app with link previews turned on is the usual culprit β not an attacker.
Why it is not an easy fix
The obvious answer is "detect bots and don't burn for them." The problem is which way you fail when you are wrong.
If you try to detect humans and treat everything else as a bot, then any unusual browser, privacy tool, or stripped user agent gets classified as a crawler β and the note is never consumed at all. That is worse than the original bug: the tool quietly stops keeping its central promise, and you have no way to know.
So the safe direction is the opposite one. Maintain an explicit list of known preview crawlers, treat everything you do not recognise as a human, and accept that a new or obscure preview bot will occasionally slip through and burn a note. Failing toward "consume the note" keeps the security guarantee intact; failing toward "never consume it" does not.
What that looks like
Check the user agent against known preview crawlers. If it matches, return the page metadata with placeholder content and leave the note untouched:
const PREVIEW_BOTS = [
'slackbot', 'discordbot', 'facebookexternalhit', 'twitterbot',
'whatsapp', 'telegrambot', 'linkedinbot', 'applebot',
'linkpresentation', // iMessage / macOS rich links
// ...
];
function isLinkPreviewBot(userAgent) {
if (!userAgent) return false; // unknown β treat as human
const ua = userAgent.toLowerCase();
return PREVIEW_BOTS.some(b => ua.includes(b));
}
Then, in the read path:
if (note.burnAfterRead) {
if (isLinkPreviewBot(req.get('user-agent'))) {
return res.json({ ...metadata, content: '', previewOnly: true });
}
const content = await burnNote(slug); // consumes it, exactly once
// ...
}
The other half: burning exactly once
There is a second, quieter bug in the same area. If two people open a one-time link at the same instant, a naive implementation reads the note, sees it is unburned, and serves the content β twice β before either request has written anything back. Both readers get the secret. The note was supposed to be single-use.
Reading and destroying have to be a single atomic step. In Postgres, that means locking the row and re-checking the condition inside a transaction:
BEGIN;
SELECT content FROM notes
WHERE slug = $1 AND burn_after_read = TRUE AND burned_at IS NULL
FOR UPDATE;
-- second concurrent reader blocks here, then matches zero rows
UPDATE notes SET content = '', burned_at = NOW() WHERE slug = $1;
COMMIT;
The loser of the race blocks on the lock, and when it is released the row no longer
matches the burned_at IS NULL condition β so it returns nothing and the
reader gets a clean "already read" response. Worth testing with real concurrency rather
than assuming: fire ten simultaneous requests at one note and confirm that exactly one
of them comes back with content.
Deleted, or just hidden?
One more thing worth checking in any tool you rely on: what "destroyed" means. There is a real difference between flagging a row as read and actually removing the content.
A reasonable approach is to wipe the content immediately on read, but keep a small tombstone row for a while so that later visitors get an explicit "this note was already read" instead of a generic 404 β which is ambiguous and looks like a typo. The tombstone gets hard-deleted on a timer afterwards.
What to do about it today
- Test before you trust. Send yourself a one-time link through the chat app you actually use, and see whether it survives to your click.
- Prefer a timer when you can. A note that expires in an hour is immune to this problem entirely, and for most "here is the wifi password" cases it is enough.
- Warn the recipient out of band. If you must use burn-after-read in a channel with previews, say so, so a dead link reads as expected rather than alarming.
- Remember what it is not. Burn-after-read is a convenience against lingering copies, not a defence against someone who already has the link. Anything genuinely high-value belongs in a secrets manager.
Where ShareNotes stands
ShareNotes ships view-once notes with the preview-bot exception described above: Slack, Discord, WhatsApp, iMessage and the other known unfurlers receive a placeholder and never consume the note. The burn itself is a locked transaction, so exactly one reader gets the content no matter how many arrive at once. Content is wiped on read, and the tombstone is hard-deleted after 24 hours.
You can turn it on by ticking "Delete after first read" when you
generate a link, or by sending {"burnAfterRead": true} to the
public API. It is free and needs no account, like
everything else on the self-destructing notes
page.