feat(auth): email-verification flow — close account squatting (security PR2b)#80
Open
Omarear wants to merge 1 commit into
Open
feat(auth): email-verification flow — close account squatting (security PR2b)#80Omarear wants to merge 1 commit into
Omarear wants to merge 1 commit into
Conversation
…ty PR2b)
The identity half of the auth hardening. Email/password sign-up must now prove
ownership before getting a session; OAuth (live in prod) is untouched.
- API (auth.ts): emailAndPassword.requireEmailVerification + emailVerification
{ sendOnSignUp, autoSignInAfterVerification, sendVerificationEmail }. The
verification link is built deterministically with a fixed
callbackURL=${DASHBOARD_URL}/verify-email (one trusted landing for success +
?error). Reuses lib/email.ts (Resend, workerd-safe) — no new secret.
- OAuth unchanged: the callback issues a session with no verify step; the gate
is email/password-scoped. Proven by an explicit handleOAuthUserInfo test.
- Dashboard surfaces (ck/ds): sign-up success → inline "check your email" +
resend (replaces the now-broken /onboarding nav); public /verify-email landing
(success → /onboarding, ?error → resend); sign-in EMAIL_NOT_VERIFIED → inline
resend notice (matched on the stable error CODE, never status 403).
- use-resend-verification hook (30s cooldown, 429-safe); auth-client gains the
native sendVerificationEmail.
No migration (emailVerified exists); dev seed already sets it for admin. No prod
backfill (post-reset: OAuth accounts verified; credential test accounts self-heal
via sign-in auto-resend).
Tests (the deliverable): API e2e — sign-up unverified + no session; link verify
round-trip flips verified + mints a session; unverified sign-in 403; OAuth
session + no gate; config assertion. Dashboard — the three surfaces + the
invalid-credentials (401) negative case. Fixed PR2a's "sessions stay in D1" test
for the new no-auto-session-on-signup behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✅ All deployments successful!
Deployed with Ploy |
This was referenced Jun 24, 2026
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.
Security Hardening — PR 2b (#111): email-verification flow
The identity half of the auth hardening (2a closed the account-takeover; this closes account squatting). Email/password sign-up must prove ownership before getting a session. OAuth (live in prod) is untouched — the gate is email/password-scoped.
Two paths
emailVerified:true+ session (autoSignInAfterVerification) →/verify-email→/onboarding→ paywall. Unverified sign-in →403 EMAIL_NOT_VERIFIED(BA auto-resends).callback.mjshas norequireEmailVerificationcheck; a provider-verified user gets a session with no verify step. Proven by an explicithandleOAuthUserInfotest.API
emailAndPassword.requireEmailVerification: true+emailVerification: { sendOnSignUp, autoSignInAfterVerification, sendVerificationEmail }.sendVerificationEmailbuilds the link deterministically with a fixedcallbackURL=${DASHBOARD_URL}/verify-email(one trusted landing for success +?error), reusinglib/email.ts(Resend, workerd-safe). No new secret.Dashboard (ck/ds, "support agent" never "chatbot")
/onboardingnav)./verify-emaillanding: no?error→ success → hard-nav/onboarding(4s stall fallback to sign-in);?error=CODE→ friendly "link expired" + resend form.EMAIL_NOT_VERIFIED→ inline "we re-sent the link" + resend. Matched on the stable errorcode, never status 403 (adversarial-review fix — bad credentials are 401, so 403-matching was fragile).use-resend-verificationhook (30s cooldown, 429-safe);auth-clientgains the nativesendVerificationEmail.Migration / seed / backfill
No migration (
emailVerifiedexists). Dev seed already setsemail_verified=1for admin. No prod backfill (post-reset: OAuth accounts already verified; credential test accounts self-heal via sign-in auto-resend) — the approved hard-gate decision.Tests (the deliverable)
Process
Plan-first → pre-build design review (OAuth no-regression confirmed 12 ways) → build → adversarial review (OAuth + verify round-trip + regression sweep + frontend) → 1 confirmed finding fixed (the 403 breadth) → PR.
Deploy note
2b touches the dashboard frontend → its preview runs through the
kind: nextjsbuilder that flaked transiently before. If a preview fails withNo executable pnpm found, it's the known transient Ploy flake — re-trigger once.🤖 Generated with Claude Code