Skip to content

Make magic-link verify safe against JS-executing mail scanners#15

Draft
tnsardesai wants to merge 1 commit into
mainfrom
hypeship/verify-scanner-safe-consume
Draft

Make magic-link verify safe against JS-executing mail scanners#15
tnsardesai wants to merge 1 commit into
mainfrom
hypeship/verify-scanner-safe-consume

Conversation

@tnsardesai

Copy link
Copy Markdown
Contributor

Problem

Opening a /login/verify magic link on mobile Safari frequently shows "LINK EXPIRED OR USED" on a link the human never used.

Root cause: the invisible auto-submit verify shim (the "GET confirms, POST consumes" design) assumed link scanners only issue GETs. In production a JavaScript-executing mail link scanner opens the emailed URL and runs the page's auto-submit <script> itself, issuing the consuming POST ~10s after the email is sent — before the human taps. Whoever POSTs first wins the single-use token; the token lands in the scanner's cookie jar (useless to the human), who then gets the 410.

Evidence from audit_log (session.created = token consumed):

  • Scanner consumes at a tight automated cadence: avg ~9.6s after send (7–11s).
  • Of fresh login-link consumes within the 15-min TTL, the scanner (not the human) won 8/14 iOS logins (57%) and 12/47 desktop (26%) — mobile users lose far more because their tap latency exceeds the scanner window.
  • login_link.requested UA is the human's device (e.g. iPhone … AppleWebKit/605.1.15); the matching session.created comes from a headless X11; Linux x86_64 … Chrome/150.

Fix

Gate the invisible auto-submit on a short-lived login-intent cookie set when the link is requested (POST /login, scoped to /login, 15-min TTL):

  • Same browser that requested the link (cookie present) → keeps the invisible auto-submit sign-in. No UX change.
  • Everyone else — a mail scanner opening the URL, a 7-day share/comment link, or a link opened in a different browser (cookie absent) → gets a no-JS confirm button (confirmSignInPage) with no auto-submit script. Only a genuine tap consumes the token, so a JS scanner can render the page but can't burn the link.

The POST consume path is unchanged (still atomic, single-use). Also adds Cache-Control: no-store to the token-bearing verify GET responses.

Trade-off

Share/comment-link recipients and cross-browser opens now tap one button instead of auto-submitting — the only cases where invisible sign-in was never safe anyway. The common self-serve login (request + open in the same browser) stays zero-tap.

Tests

  • lib/auth/verify-pages.test.ts: added confirmSignInPage coverage (POST form, hidden token/next, visible button, no <script>, HTML-escaping).
  • Full suite: 146 passing; tsc --noEmit clean.
  • e2e (scripts/e2e.ts) share-link flow still valid: a share link has no intent cookie, so it renders the confirm page (200) and the POST consume is unchanged.

Fixes KERNEL-1734.

The invisible auto-submit verify shim assumed link scanners only issue
GETs, but a JS-executing mail scanner opens the emailed URL and runs the
auto-submit <script> itself, consuming the single-use token ~10s after
send — before the human taps. Mobile Safari users lose this race the
majority of the time (their tap latency exceeds the scanner window) and
get "expired or used" on a link they never used.

Gate the invisible auto-submit on a short-lived login-intent cookie set
when the link is requested (POST /login). The browser that requested the
link keeps the invisible sign-in; every other opener — a scanner, a
share/comment link, or a link opened in a different browser — gets a
no-JS confirm button, so only a genuine tap consumes the token. Also add
Cache-Control: no-store to the token-bearing verify pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
justhtml Ready Ready Preview, Comment Jul 25, 2026 3:45am

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant