Skip to content

refactor(verify-email): one decision core for both implementations - #752

Merged
lakhansamani merged 1 commit into
security/2.4.0-audit-part-2from
security/verify-email-single-source
Aug 7, 2026
Merged

refactor(verify-email): one decision core for both implementations#752
lakhansamani merged 1 commit into
security/2.4.0-audit-part-2from
security/verify-email-single-source

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Follow-up to #751, which fixed a symptom. This removes the cause.

Why

Email verification has two implementations over the same token table:

Transport Implementation
GraphQL delegates to service.VerifyEmail
gRPC delegates to service.VerifyEmail
REST GET /verify_email its own copy — and this is where the button in every verification and magic-link email actually points

They have now drifted twice:

  1. The MFA gate was added to the mutation and missed the handler, so the emailed
    link bypassed MFA entirely. (Recorded in that handler's own comment.)
  2. 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 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.ConsumeEmailVerificationToken now owns every decision the two callers
must agree on:

  • token lookup, signature/claims validation
  • purpose binding (an email-verification token only)
  • subject handling
  • user lookup and the revoked check
  • the email_verified_at write

…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 — because
that 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

sub is now required to be a non-empty string before it selects an account.

ValidateJWTClaims checks the subject as:

claims["sub"] != cfg.User.ID && claims["sub"] != cfg.User.Email

This path sets only Email, so User.ID is "" — and a token whose sub is
the empty string satisfies the first comparison and passes. The lookup would
then run as GetUserByEmail(ctx, ""), and the only thing preventing a match was
whether a given backend stores a missing email as NULL or as "". SQL keeps it
NULL, so nothing matched — but that is one backend's representation, not a
declared 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.

Worth a separate look: ValidateJWTClaims passing on an empty-string sub
affects any caller that builds an AuthTokenConfig without a User.ID.
Not changed here — it has many callers and deserves its own change.

Tests

  • Expired link — deterministic: a signed token with exp in 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.
  • Superseded link — a resend rotates the nonce, so the older link stops
    validating even before its exp.
  • Empty-subject forgery — everything valid except sub.
  • Recovery path — the resent link actually completes verification, so
    "your link expired" is never a dead end.
  • Existing REST and service matrices continue to pin the email_verified_at
    invariant on both transports.

Docs

docs/email-verification-contract.md now states the 30-minute validity, that
expiry is enforced by JWT validation rather than the row's expires_at, that
issuing a new link invalidates the previous one immediately, and the three ways
to recover — resend_verify_email, password login's OTP detour, or admin
force-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.

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.
@lakhansamani
lakhansamani merged commit 01e0f76 into security/2.4.0-audit-part-2 Aug 7, 2026
4 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