Answers · web security
Why are my one-time links already used when people open them?
Short answer: Something opened the link before your user did. Chat apps fetch links to build previews (Slack's Slackbot-LinkExpanding, for example), and email security scanners such as Safe Links in Microsoft Defender for Office 365 check links in incoming mail, so a link that is consumed on the first GET request is used up by a bot. Supabase's docs describe this for magic links, where prefetching makes the link show as expired. The fix is to stop letting a GET request consume anything: the link opens a page with a confirm button, and only the button's POST spends the token. Ignoring HEAD requests and known bot user agents helps, but treat it as a filter, not the fix, because scanners don't all identify themselves. If the link carries a secret for the page itself, as in a share link to encrypted content, put it in the URL fragment (after #), which browsers don't send to the server, and keep it from leaking from the page: Referrer-Policy: no-referrer, a strict Content-Security-Policy and no third-party scripts.
Checklist
- Make GET safe: opening the link shows a page that says what will happen, with a button. Clicking it sends a POST that checks and consumes the token. HTTP defines GET and HEAD as safe methods, and automated clients rely on that.
- Don't consume on HEAD or OPTIONS, and don't consume on GET from known preview user agents (Slackbot-LinkExpanding, Twitterbot, facebookexternalhit, Discordbot and the like). Expect scanners with browser-like user agents too, which is why the button matters.
- Make the token single-use on the server and short-lived, and bind the session it creates to the browser that pressed the button, not to whoever fetched the URL first.
- Give people a way back: if a link was already used or expired, show a clear page with a button to send a new one, not a dead end. For sign-in, offer a one-time code as an alternative to the link.
- For secrets meant for the page itself (decryption keys for shared content), use the fragment: https://example.com/s/abc#key. The fragment stays in the browser; read it with JavaScript after the user confirms.
- Set Referrer-Policy: no-referrer on these pages, so a token in the path or query is never sent to other sites in the Referer header (browsers never include the fragment there).
- Serve a strict Content-Security-Policy and no third-party scripts (analytics, chat widgets, tag managers) on the page: any script running there can read location.hash and the page's contents.
- Keep tokens out of logs and analytics: strip them from access logs and error reports, and don't log full URLs on these routes.
Who fetches your links
- Chat and social apps: when a link is pasted, the app or the sender's device fetches it to show a title and image. Slack documents that its link-expanding robot fetches as little of the page as it can.
- Email security: Safe Links rewrites links in incoming mail and can scan them in real time when they are clicked; other mail filters fetch links on delivery to check for malware or phishing.
- Browsers and antivirus software can also prefetch or check links. None of these is a person deciding to use the link, so none should change state.
Why the fragment and the headers matter
- The fragment is not sent in the HTTP request, so a bot fetching the URL never receives the key, and the key doesn't land in your server's access logs.
- Scripts on the page can still read it, so the page must load only your own code: a third-party analytics or chat script on that page has the same access as your code.
- Referrer-Policy: no-referrer stops the browser from sending the page's URL to sites it links to or loads resources from, which matters when the token is in the path or query string.
Ask Rightshold (free, no key)
Add the MCP server https://www.rightshold.com/mcp to Claude, Cursor, ChatGPT, Grok or VS Code, then ask: “This request hit my one-time link. Does it carry a valid Web Bot Auth signature from a known agent, and does my policy allow it?”
{
"tool": "gate_check",
"arguments": {
"policy": {
"require_web_bot_auth": true
},
"request": {
"method": "GET",
"url": "<the requested URL>",
"headers": {
"signature-agent": "<as received>",
"signature-input": "<as received>",
"signature": "<as received>"
}
}
}
}- A valid signature identifies an agent that signs its requests. Most link-preview bots and mail scanners don't sign, so an unsigned request is not proof of a human; the confirm button is still the fix.
- kya_check checks an agent that presents an RS-KYA handle against your policy before you let it act on a link.
Rightshold checks agent signatures and identities you pass it. It does not sit in front of your links or block bots, and it cannot tell you who fetched a link that carried no signature.
Sources
- RFC 9110: HTTP Semantics, safe methods
- Slack: Meet the robots (Slackbot-LinkExpanding)
- Microsoft Defender for Office 365: Safe Links overview
- Supabase Auth: email templates, email prefetching
- MDN: Referrer-Policy
- MDN: Content Security Policy
Related
- How do I verify an AI agent before doing business with it?
- Can you fingerprint an AI agent?
- How to stop Log-To-Leak style prompt injection through MCP tools
Not legal advice. Updated 2026-10-29. JSON · Markdown · All answers
Get a free API key in one call
No card and no email needed. 100 free credits; the license and policy checks stay free without a key.
curl -s -X POST https://www.rightshold.com/v1/accounts/signup -H "Content-Type: application/json" -d '{"accept_terms":true}'
export RIGHTSHOLD_API_KEY=rs_... # the api_key from the reply (shown once)
curl -s https://www.rightshold.com/v1/licenses/check -H "Authorization: Bearer $RIGHTSHOLD_API_KEY" \
-H "Content-Type: application/json" -d '{"license":"MIT","intended_use":{"commercial_use":true}}'
