Skip to content

Authentication & Onboarding

JWT-based authentication for 8 user roles (platform_admin, org_admin, unit_manager, instructor, learner, guardian, external_educator, b2c_user), covering both individual trainers/learners and institutional accounts. Self-serve personas pick their path via the user-type picker on /signup; institutional accounts are admin-provisioned through the onboarding wizard. The 2026-04 rewrite consolidated signup/login/register into a single /signup + /login surface, see auth-entry-consolidation.md.

How it works

Login (/login)

Shared shell (AuthShell), three methods, the tab defaulting to the user's last_login_method:

Email / username + password   → POST /auth/login
Phone OTP                     → POST /auth/phone-otp/{send,verify}
Google OAuth                  → GET  /auth/google/{authorize,callback}

All paths set httpOnly cookies + persist last_login_method.
Post-login routing:
  role == external_educator → /creator/dashboard
  otherwise                 → /dashboard

Signup (/signup)

One URL, with a user-type picker shown when ?as= is missing:

/signup                   → picker: Trainer / Learner / Independent Educator / Institution
/signup?as=trainer        → form, IntentPreviewPanel
/signup?as=learner        → form, IntentPreviewPanel
/signup?as=creator        → form, CreatorValuePropsPanel
/signup?as=institution    → redirect to kwilo.ai/#demo (sales-led)

Methods: Phone OTP (default) · Google · Email + password.
All hit /auth/register-individual or /auth/phone-otp/verify.

Creator signups (user_type='creator') are promoted to role=external_educator and navigate to /creator/onboarding. Everyone else lands on /dashboard (B2C personal workspace). /register was deleted in 2026-04; creators now arrive through /signup?as=creator.

Token lifecycle

  • Storage: httpOnly cookies, access_token and refresh_token (B2B) plus kwilo_access / kwilo_refresh for B2C cross-subdomain auth.
  • Request: cookies sent automatically; the Axios interceptor also attaches Authorization: Bearer {token} for mobile clients.
  • Refresh: on 401, queue pending requests, POST /auth/refresh, retry all.
  • Logout: clear cookies + TanStack Query cache + Zustand auth store.

Role-based routing

Role Post-login Notes
platform_admin /admin Organizations, analytics, feature flags
org_admin /admin Schools, onboarding, analytics
unit_manager /admin Users, classes, attendance (department/school unit scope)
instructor /dashboard Courses, lessons, assignments, attendance
learner /dashboard Courses, AI tutor, assignments, attendance
guardian /dashboard Child's courses, assignments, attendance
external_educator /creator/dashboard Marketplace creator flow
b2c_user /dashboard Personal workspace (self-serve trainer/learner)

<RoleGuard allowedRoles={['instructor', 'org_admin']}> checks auth + role and redirects to /dashboard if unauthorized.

User-type vs role

The users table has two orthogonal columns:

Column Values Written when Used for
role b2c_user, instructor, learner, guardian, unit_manager, org_admin, platform_admin, external_educator at provisioning / signup RBAC, what the user can do
user_type trainer, learner, creator at B2C signup (from ?as=) onboarding forks, analytics, what kind of user they say they are

A self-serve trainer has role=b2c_user AND user_type=trainer. A school-provisioned trainer has role=trainer and no user_type.

Identity rules: uniqueness on users

The users table mixes B2B and B2C, so the uniqueness contract isn't obvious:

Column Constraint Why
username column-level UNIQUE Every user logs in by username (B2B) or email-as-username (B2C)
email NOT unique at column level A parent may share an email with a child
email where role='b2c_user' partial UNIQUE index uq_users_email_b2c (migration 00039) At most one B2C self-serve account per email
phone NOT unique (same parent/child reason)
google_id column-level UNIQUE Google's canonical sub identifier

Any code that looks up a user by email must scope the query, never assume one row. The B2C service exposes _find_user_by_email(email) (scoped to role='b2c_user'); B2B reset-password (auth.py:forgot-password) uses .scalars().all() and walks the matches. A scalar_one_or_none() on a raw User.email == … query is a latent MultipleResultsFound bug.

Google sign-in lookup & collision handling

B2CAuthService.google_callback is the only place that ingests an external identity:

  1. By google_id (canonical, schema-unique). If found, log them in.
  2. By email, scoped to B2C self-registered roles (b2c_user, external_educator), ordered by created_at DESC, limited to 2 rows.
  3. 0 matches: fall through to step 3.
  4. ≥1 match: raise EmailAlreadyRegisteredError. The endpoint converts this into 302 → /login?error=email_already_registered and LoginPage shows a "sign in with password or reset it" banner.
  5. No match: create a fresh B2C account keyed by google_id (B2C_USER, promoted to external_educator if user_type=creator).

We refuse to silently auto-link for two reasons. Restricting the email fallback to B2C roles closes a B2B → B2C role-escalation vector (a trainer's institutional account can't be taken over by control of the email's Google account). And forcing the user through /login with their password (or a reset) keeps the password account's existence as the proof-of-ownership step.

Error contract on /auth/google/callback

error value Meaning Frontend response
email_already_registered Existing B2C account uses this email but has no google_id link Info alert + "Reset password" link
oauth_failed Token exchange or userinfo fetch failed Error alert; user can retry
missing_oauth_nonce / invalid_oauth_state CSRF protection tripped (raised as 403, not a redirect) server-side error page

Naming follows RFC 6749 §4.1.2.1: single ASCII code, no PII in the URL. The frontend snapshots the param into state on first render and navigate('/login', { replace: true }) strips it from history.

Institution onboarding (/admin/onboarding)

Institutional accounts are admin-provisioned; self-serve institutional signup doesn't exist.

K-12 flow: select type → K-12 School → configure classes (default 8-10, sections A, B) → select board (CBSE / State) → multi-select subjects per class → bulk create → dashboard.

Higher Education flow: select college type (Engineering / Degree / University) → select programs from template (BTech, BSc, BA, …) → configure branches per program (CSE, ECE, Mechanical, …) → configure semesters (1-8 auto-generated) → review → create all → dashboard.

B2C learner onboarding (/onboarding)

Separate from institution onboarding. The 3-step wizard (education stage → subjects → goal) is gated by UserLearningProfile.onboarding_completed_at IS NULL. Full rationale is in student-guided-ux.md. Two integration notes bite anyone touching the auth → onboarding handoff:

  • Save endpoint contract. POST /api/v1/onboarding/profile mirrors the Pydantic schema in apps/backend/src/schemas/onboarding.py exactly: education_stage, onboarding_subjects (null when empty; an empty list is rejected by the validator), onboarding_goal, submit (true on full finish, false on skip). Don't invent shorter aliases on the frontend; extra fields are silently ignored by Pydantic, so a typo turns the call into a no-op partial save and the user gets bounced back to step 1.
  • Post-save navigation timing. Navigate AFTER await saveProfile(...) resolves, never via a useEffect watching mutation state. The mutation's onSuccess awaits queryClient.invalidateQueries for home-context, so the next page's query refetches fresh data. Effects that fire on (goal !== null && !isPending) race the refetch and act on stale onboarding_required=true. StudentHomePage's redirect-to-onboarding is gated on !isFetching as a belt-and-braces guard.

API endpoints

Method Endpoint Purpose
POST /auth/login B2B email/username + password login
POST /auth/register-individual B2C email + password signup (returns email-OTP prompt)
POST /auth/email-otp/{send,verify} Email OTP flow
POST /auth/phone-otp/{send,verify} Phone OTP signup + login
GET /auth/google/{authorize,callback} Google OAuth
POST /auth/refresh Rotate access token
POST /auth/logout Clear cookies
GET /auth/me Current user profile (+ user_type, last_login_method)
POST /academic/classes Bulk create K-12 classes
POST /college/programs Create program
POST /college/branches Create branch
POST /college/academic-levels Create semester
GET /college-templates/{type}/programs Template programs

Known UX gaps

  • Onboarding repeats per-school: org admins with multiple schools walk the wizard each time.
  • College templates hardcoded: can't customise before creation.
  • Email-domain auto-link is in progress: auth-domain-matching.md.