Skip to content

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.py is unchanged, so the authz hot path stays untouched.
  • The login check keys on role == b2c_user, never on org_unit_id (every B2C user has an Individual workspace org_unit). B2B accounts are never gated.
  • A migration backfills is_verified=True + email_verified_at for 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 (default True, in the pydantic-settings singleton). When False, register-individual reverts 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_otpEmailService); 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):

  1. /signup, Email tab. Enters name, email, password → POST /auth/register-individual.
  2. Backend creates the user (is_verified=False), sends the OTP, returns email_verification_required=true + otp_id, no cookies.
  3. Frontend swaps to the inline OTP form.
  4. Priya enters the code → POST /auth/email-otp/verify → backend sets is_verified=True, issues tokens + cookies.
  5. 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/me returns 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 → 403 email_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).