Skip to content

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

Merged
tnsardesai merged 1 commit into
mainfrom
hypeship/verify-scanner-safe-consume
Jul 27, 2026
Merged

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

Conversation

@tnsardesai

@tnsardesai tnsardesai commented Jul 25, 2026

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.


Note

Medium Risk
Changes how magic-link verify is rendered and adds cookie gating on a security-sensitive login path; POST consume logic is unchanged but wrong cookie handling could affect legitimate cross-browser opens.

Overview
Stops JS mail scanners from burning single-use magic links by only serving the invisible auto-submit verify page to the browser that just requested the link.

After a successful POST /login, the response sets a short-lived jh_login_intent cookie (HttpOnly, Path=/login, same 15‑min TTL as the token). GET /login/verify checks hasLoginIntent: with the cookie → existing signingInPage auto-submit; without it (scanners, share links, another browser) → new confirmSignInPage with a manual sign in button and no <script>. Token-bearing verify GETs also get Cache-Control: no-store.

The POST consume path is unchanged. Unit tests cover confirmSignInPage (form, no auto-submit, escaping).

Reviewed by Cursor Bugbot for commit 8ba49db. Bugbot is set up for automated code reviews on this repo. Configure here.

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

@tnsardesai
tnsardesai marked this pull request as ready for review July 27, 2026 18:49
@tnsardesai
tnsardesai merged commit 6803d8a into main Jul 27, 2026
5 checks passed
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