Link previews are eating your one-time secrets
You paste a one-time link into Slack. Before your colleague has even seen the message, the secret has been read - by a robot.
This is the failure that catches every one-time-secret service eventually, and it is infuriating precisely because nothing is broken. Every component behaves exactly as designed. The secret is gone anyway.
What actually happens when you paste a link
Modern chat and mail clients do not treat a URL as text. They fetch it, so they can show you a title, a description and a thumbnail. Paste a link into Slack and Slackbot requests it within seconds. Teams does the same. Outlook and Gmail render previews. And in a corporate environment there is usually a security layer as well - a mail gateway that visits every link to check it is not malware, before the message is even delivered.
None of these are attacks. They are features, and mostly good ones. But consider what they mean for a link whose entire purpose is to work exactly once:
- You paste the link into a channel.
- The preview bot fetches it to build a card.
- The scanner fetches it to check for malware.
- Your colleague clicks it and is told the secret has already been viewed.
Now they have to ask you to send another one, and - worse - you both have a moment of genuine alarm, because "already viewed" is exactly what interception looks like.
The naive fix does not work
The instinct is to filter by user agent: recognise Slackbot-LinkExpanding
or Twitterbot and serve them an empty page. This fails for three reasons.
There is no complete list, the list changes constantly, and any scanner worth its fee
deliberately does not announce itself. A blocklist you cannot complete is a blocklist
that will let something through on the day it matters.
The fix is in the HTTP method
The property worth building on is not what these clients are called. It is what they
do. Preview bots, scanners and browser prefetchers all issue GET
requests, because a GET is defined as safe: it must not change anything on the server.
That is not a convention, it is in the specification, and it is precisely why automated
fetchers are willing to make one without asking.
So we made spending a view impossible with a GET. Loading the page is a GET and reveals nothing; revealing the secret requires a POST, which is the only request that spends a view.
A bot that follows the link loads an HTML page and stops. It has no reason to POST, and nothing about the page invites it to. The view counter is untouched.
The interstitial screen - "Someone sent you a secret", with a button - is the visible half of this, but it is not the part doing the work. The protection is the HTTP method. The button exists so that a human knows a deliberate action is required, and so the page has somewhere to put the POST.
What the reader sees
Opening the link shows a page that says the secret has not been opened yet and how many views remain. Nothing has been fetched from the database except whether the id exists. Pressing the button spends exactly one view.
It is one extra click, and it is optional - you can turn it off when you know the link is going somewhere that will not touch it. But it is on by default, because the cost of the click is a second and the cost of not having it is a secret burned before anyone read it.
A note on prefetching
Browsers also speculatively fetch links they think you are about to click. Chrome does this on hover in some configurations. Same reasoning applies, same fix: a speculative fetch is a GET, and a GET cannot spend a view here.