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_tokenandrefresh_token(B2B) pluskwilo_access/kwilo_refreshfor 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:
- By
google_id(canonical, schema-unique). If found, log them in. - By
email, scoped to B2C self-registered roles (b2c_user,external_educator), ordered bycreated_at DESC, limited to 2 rows. - 0 matches: fall through to step 3.
- ≥1 match: raise
EmailAlreadyRegisteredError. The endpoint converts this into302 → /login?error=email_already_registeredandLoginPageshows a "sign in with password or reset it" banner. - No match: create a fresh B2C account keyed by
google_id(B2C_USER, promoted toexternal_educatorifuser_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/profilemirrors the Pydantic schema inapps/backend/src/schemas/onboarding.pyexactly: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 auseEffectwatching mutation state. The mutation'sonSuccessawaitsqueryClient.invalidateQueriesforhome-context, so the next page's query refetches fresh data. Effects that fire on(goal !== null && !isPending)race the refetch and act on staleonboarding_required=true.StudentHomePage's redirect-to-onboarding is gated on!isFetchingas 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.