email-link-flows-require-peek-on-get-consume-on-post
Rule
Any verification / magic-link / confirm-link flow sent by email MUST be side-effect-free on GET and perform the state mutation (token consume, account activation, password change acceptance) only on an explicit POST from a user-clicked button. GET is a show-a-button page; POST consumes.
Why
Email security scanners — Gmail Safe Browsing, Outlook Safe Links, corporate email filters (Proofpoint, Mimecast, etc.) — follow every link in delivered email within seconds of arrival to pre-scan for phishing. A GET handler that consumes the token on render burns the token before the real recipient ever clicks. The user sees “link already used / invalid” on their first click even though they did nothing wrong.
Pattern
- GET
/verify?token=X→ read-only peek of token validity (new helper likepeekEmailToken(raw, kind)that does a SELECT without marking used) → render either “Click to activate” button inside a<form action={POST}>with token as hidden input, or “invalid/expired” view. Zero DB mutation. - POST → consume the token, flip the user state (e.g.
emailVerifiedAt=NOW()), create session, redirect to app. If consume fails, redirect back to GET with?err=<reason>so the GET re-renders the error without re-running consume. - This applies equally to password-reset links (usually naturally safe because the user submits a form with a new password), magic-link login, email-change confirmation, any one-time-token flow.
Anti-pattern to watch for
A server component / page render that invokes consumeEmailToken / consumeOneTimeToken / consume*() directly from the GET path. Any state mutation inside a GET page is vulnerable.
Industry context
Standard pattern for SaaS apps. Rails has respond_to :get { render_button } :post { consume }. Django / Next.js need explicit implementation. A naive implementation (consume-on-GET) passes local dev tests but fails in production the moment email scanners touch it.
Test discipline
- Unit test:
peekEmailTokencalled N times in a row must never markused_at. - Unit test:
consumeEmailTokenafter arbitrary peeks still succeeds, still marks used exactly once. - Integration smoke: hit the GET endpoint with the same token 3+ times → all return 200, token still consumable via POST.