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 like peekEmailToken(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: peekEmailToken called N times in a row must never mark used_at.
  • Unit test: consumeEmailToken after 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.