refactor(verify-email): one decision core for both implementations - #752
Merged
lakhansamani merged 1 commit intoAug 7, 2026
Merged
Conversation
Email verification has two implementations over the same token table: the
GraphQL/gRPC mutation (service.VerifyEmail) and the REST handler behind
GET /verify_email, which is where the button in every verification and
magic-link email actually points. They have drifted twice:
- the MFA gate was added to the mutation and missed the handler, so the
emailed link bypassed MFA entirely;
- the email_verified_at write was moved above that gate in the mutation and
missed the handler, so users who clicked the emailed button were never
marked verified.
The second one shipped and was caught by a user. It hid because only passkey
login reads the column - password login reads it too but self-heals via an
email-OTP detour, so TOTP and email-OTP users silently completed a second,
redundant verification round trip and looked fine.
service.ConsumeEmailVerificationToken now owns every decision the two must
agree on: token validation, purpose binding, subject handling, user lookup,
the revoked check, and the email_verified_at write - in that order, with the
write deliberately BEFORE the caller can withhold tokens. Presentation stays
with the callers, which is the part that legitimately differs.
The MFA branches did NOT move. An earlier cut of this refactor deleted the
email-OTP and SMS-OTP challenges from the mutation, which compiles cleanly and
silently drops MFA for users enrolled in those factors; the boundary is now
drawn above them.
Security review of the extracted core found one issue, fixed here: `sub` is
now required to be a non-empty string before it selects an account.
ValidateJWTClaims compares `sub` against User.ID *or* User.Email, and this
path sets only Email - so User.ID is "" and a token with an empty-string
subject passes. The lookup would then run as GetUserByEmail(ctx, ""), and the
only thing preventing a match was whether a backend stores a missing email as
NULL or as "". SQL keeps it NULL, so nothing matched; that is one backend's
representation, not a declared property, and six implementations is too many
places for a security boundary to be implicit. Verified: disabling the guard
makes the request reach account selection.
Adds tests for expired links (deterministic - a signed token with exp in the
past, not a 30-minute sleep), superseded links whose nonce was rotated by a
resend, the empty-subject forgery, and the recovery path. Documents the
30-minute validity and how to get a new link without an administrator.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #751, which fixed a symptom. This removes the cause.
Why
Email verification has two implementations over the same token table:
service.VerifyEmailservice.VerifyEmailGET /verify_emailThey have now drifted twice:
link bypassed MFA entirely. (Recorded in that handler's own comment.)
email_verified_atwrite was moved above that gate in the mutation andmissed the handler, so users who clicked the emailed button were never marked
verified.
The second one shipped and was found by a user testing #751. It hid because only
passkey login reads the column — password login reads it too but self-heals
via an email-OTP detour, so TOTP and email-OTP users silently completed a second,
redundant verification round-trip and appeared unaffected. Passkey was not the
broken path; it was the only honest one.
Two drifts in the same place is a pattern, not bad luck.
What changed
service.ConsumeEmailVerificationTokennow owns every decision the two callersmust agree on:
email_verified_atwrite…in that order, with the write deliberately before a caller can withhold
tokens. Presentation stays with the callers — the mutation returns an
AuthResponse, the handler redirects with tokens in the query string — becausethat part legitimately differs.
One thing that nearly went very wrong
An earlier cut of this refactor deleted the email-OTP and SMS-OTP MFA branches
from the mutation. It compiled cleanly. Users enrolled in those factors would
have silently stopped being challenged on email verification — an MFA bypass
introduced by a refactor whose entire purpose was to prevent silent divergence.
Caught by diffing the removed decisions against the extracted core rather than
trusting the build. The extraction boundary is now drawn above those branches,
and the review is reproduced in the commit message.
Security review of the extracted core — one issue found and fixed
subis now required to be a non-empty string before it selects an account.ValidateJWTClaimschecks the subject as:This path sets only
Email, soUser.IDis""— and a token whosesubisthe empty string satisfies the first comparison and passes. The lookup would
then run as
GetUserByEmail(ctx, ""), and the only thing preventing a match waswhether a given backend stores a missing email as
NULLor as"". SQL keeps itNULL, so nothing matched — but that is one backend's representation, not adeclared property, and six storage implementations is too many places for a
security boundary to be implicit.
Verified by disabling the guard: the request then reaches account selection and
fails only on SQL's
record not found.Tests
expin the past,attached to the stored row so it genuinely reaches the expiry check. No
30-minute sleep, and it cannot pass by failing earlier as an unknown token.
validating even before its
exp.sub."your link expired" is never a dead end.
email_verified_atinvariant on both transports.
Docs
docs/email-verification-contract.mdnow states the 30-minute validity, thatexpiry is enforced by JWT validation rather than the row's
expires_at, thatissuing a new link invalidates the previous one immediately, and the three ways
to recover —
resend_verify_email, password login's OTP detour, or adminforce-verify — with the first preferred because it has the user prove control
rather than asserting it for them.
Verified
make lint(0 issues),go build,go vet, and the full Go suite green.