B2C Email Verification Gate¶
This makes the email OTP a real gate for B2C self-signup (learner, trainer, parent; role == b2c_user). Before this, register-individual issued a full session before the email was verified, so reloading the OTP screen silently logged the user into the dashboard unverified. Scope is B2C only: B2B/admin-provisioned accounts are untouched.
How it works¶
A b2c_user holds a session if and only if their email is verified (or they signed in via Google, which pre-verifies). register-individual no longer sets session cookies; a session is created only after a successful verify_email_otp; and B2C login rejects unverified accounts.
register-individual
├── create user (is_verified=False)
├── send email OTP
└── return { email_verification_required: true, otp_id } ← NO session cookie
verify-email-otp (the ONLY B2C session-creation point for email signup)
├── validate code → is_verified=True, email_verified_at=now
└── create_tokens + set cookies ← session starts HERE
login (B2C)
└── if role == b2c_user AND not is_verified → 403 email_not_verified
Rules and behavior:
- Gate is at session creation, not on every request:
deps.pyis unchanged, so the authz hot path stays untouched. - The login check keys on
role == b2c_user, never onorg_unit_id(every B2C user has an Individual workspace org_unit). B2B accounts are never gated. - A migration backfills
is_verified=True+email_verified_atfor existing active B2C users, so the flip doesn't lock anyone out. - Google OAuth signups already set
email_verified_at, so no gate applies. - Kill switch:
B2C_EMAIL_VERIFICATION_REQUIRED(defaultTrue, in thepydantic-settingssingleton). WhenFalse,register-individualreverts to auto-login and login skips the check. Used only if delivery breaks and the funnel must stay open. - This reuses the existing 6-digit OTP (
send_email_otp→_deliver_email_otp→EmailService); it does not build a verification link. It also does not fix SMTP delivery, which is the secret rotation in #1075 and a prerequisite: rotate the SMTP password and verify delivery on staging before deploying the gate.
Flows¶
Happy path (Priya signs up with email):
/signup, Email tab. Enters name, email, password →POST /auth/register-individual.- Backend creates the user (
is_verified=False), sends the OTP, returnsemail_verification_required=true+otp_id, no cookies. - Frontend swaps to the inline OTP form.
- Priya enters the code →
POST /auth/email-otp/verify→ backend setsis_verified=True, issues tokens + cookies. - Frontend invalidates
/auth/me, navigates to the post-signup route. Verified and in.
Edge cases:
- Reload during the OTP step: no session cookie exists yet →
/auth/mereturns 401 → user stays on signup/OTP. - OTP not received: resend via
POST /auth/email-otp/send. If delivery is down the user is correctly blocked. - Unverified user logs in later:
POST /login→ 403email_not_verified→ frontend routes to OTP entry + offers resend. - Existing B2C user: backfilled to verified, logs in normally.
- Google OAuth / B2B login: unchanged.
Where it lives¶
Routes: /signup, /login, POST /auth/register-individual, POST /auth/email-otp/verify, POST /login. Web entry is apps/web only (no mobile in this phase).