Authentication¶
1. Overview¶
SIS uses custom, self-hosted authentication built on Passport.js + JWT. There is no third-party auth provider (Auth0, Cognito, etc.). This gives the team full control over token structure and lifetime, which is a hard requirement for the Entity-Scope permission model — roles and scopes live inside the JWT payload and are interpreted by guards on every request.
Key design choices:
- Password-first, up-to-3-step login (credentials → optional tenant selection → optional profile selection). The user supplies email + password; the system finds all matching accounts across all active tenants and verifies the password against each. The tenant is resolved by credentials, not by a subdomain or header. See chapter 02 for tenant resolution details, and §9 for the full state machine.
- HttpOnly cookie delivery. Tokens are sent as
HttpOnly; Secure; SameSite=Nonecookies so JavaScript cannot read them.SameSite=Noneis deliberate — the FE and BE live on different domains in every environment, andNonerequiresSecure(which is also set). Bearer header auth is also supported for non-browser clients. - Self-hosted refresh token rotation. Refresh tokens are stored (hashed) in the database, rotated on every use, and support replay detection and family revocation.
2. Login Flow¶
Step 1 — Credential Validation¶
AuthService.validateCredentials() runs cross-tenant:
- Find all active
Userrecords matching the email across all active tenants. - Verify the supplied password against each match using
argon2.verify()— all checks run in parallel to keep latency flat regardless of tenant count. - Collect valid matches (userId + tenantId pairs where password verification passes).
| Matches | Response |
|---|---|
| 0 | 401 — "Invalid credentials". A dummy argon2.verify() is always run even when no users are found, keeping response time consistent and preventing timing-based email enumeration. |
| 1 | Auto-login: issue tokens, set cookies, return { user }. |
| 2+ | 200 with { requiresTenantSelection: true, tenants: [...], selectionToken }. The client must prompt the user to pick a tenant. |
The tenant list is only returned after password verification, so an attacker cannot enumerate which tenants an email belongs to without knowing the correct password.
Each entry's display name is Tenant.name, which SchoolService keeps in sync with School.operationalName on every school write (see the school/ module in REFERENCE.md / spec 2026-06-26-school-tenant-name-sync-design.md) — so the picker reflects the configured school name, not the provisioning default.
Step 2 — Tenant Selection (multi-tenant accounts only)¶
The selectionToken is a short-lived JWT (10-minute TTL) signed by the server, containing:
The server verifies the token, confirms the requested tenantId is in the pre-validated list, then proceeds to token issuance.
Token Issuance (both paths)¶
AuthService.login():
1. Sign a JWT access token (1h TTL, driven by JWT_EXPIRATION)
2. Generate a random refresh token (64 hex chars)
3. Hash the refresh token (SHA-256), store hash in DB
4. Set HttpOnly cookies: access_token + refresh_token
5. Return { user: { id, email, firstName, lastName } }
3. JWT & Token Lifecycle¶
Payload¶
Every JWT access token carries:
{
"sub": "<userId>",
"tenantId": "<tenantId>",
"roles": ["admin"],
"activeProfile": "teacher",
"isPlatformAdmin": false
}
The tenantId embedded in the token is the single source of truth for tenant context on subsequent requests — no Host header dependency. activeProfile is the narrowed active-session profile (PersonProfileKey | 'platform'); the roles array is narrowed to that profile's role set — see §9.
Token Delivery¶
| Channel | Cookie name | Notes |
|---|---|---|
| HttpOnly cookie | access_token |
Primary channel for browser clients |
| HttpOnly cookie | refresh_token |
Stored separately; sent only to /auth/refresh |
Authorization: Bearer header |
— | Supported for non-browser / API clients |
Request Pipeline¶
After login, every authenticated request flows through:
Request
→ JwtAuthGuard Extract token from cookie or Bearer header
→ JwtStrategy.validate() Decode and verify JWT, attach { userId, tenantId, roles } to request.user
→ ScopeGuard Check @RequireScopes() entity-level access
→ ActionGuard Check @RequireAction() operation-level access (opt-in)
→ FieldWriteGuard Reject writes to fields outside the user's writable scopes
→ Controller → Service Business logic; tenantId filtering in every query
→ FieldFilterInterceptor Strip unauthorized scope groups from the response
Platform admins (isPlatformAdmin: true) bypass ScopeGuard, ActionGuard, FieldWriteGuard, and FieldFilterInterceptor.
Token Lifetimes¶
| Token | TTL |
|---|---|
| Access token | 1 hour |
| Refresh token | 7 days |
| Selection token | 10 minutes |
4. Refresh & Rotation¶
Access tokens expire after 1 hour. The client uses the refresh token to obtain a new pair:
Server-side refresh logic:
- Hash lookup — SHA-256 hash of the supplied token is looked up in the database.
- Replay detection — if the token hash is found but already marked revoked, the system treats this as a replay attack and revokes the entire refresh token family for that user, forcing re-authentication.
- Tenant + user validation — confirm the tenant is still
ACTIVEorTRIAL, and the user is stillisActive. - IP change logging — if the request IP differs from the IP that last used this token, a warning is logged (not blocked, but auditable).
- Rotation — the old refresh token is revoked, a new access token + refresh token are issued in the same family.
This "use once and rotate" model limits the blast radius if a refresh token is stolen: any attempt to reuse a consumed token immediately locks down the entire token family.
isActive is also the lever for archiving a person: setting a Teacher/Staff to PeopleStatus.ARCHIVED deactivates the linked User (isActive=false) and revokes their refresh tokens in the same transaction, so login (which filters isActive: true) and refresh both stop. Un-archiving flips isActive back. The binding is kept (not detached), so reactivation needs no re-invitation. See docs/superpowers/specs/2026-06-22-people-status-archive-and-contract-validation-design.md.
5. Rate Limiting¶
Rate limiting is applied via @nestjs/throttler:
| Scope | Limit |
|---|---|
| Global (all routes) | 120 requests / 60 seconds |
Login (POST /auth/login) |
5 requests / 60 seconds |
Tenant selection (POST /auth/login/select-tenant) |
5 requests / 60 seconds |
Profile selection (POST /auth/login/select-profile) |
5 requests / 60 seconds |
Profile switch (POST /auth/switch-profile) |
10 requests / 60 seconds |
Refresh (POST /auth/refresh) |
10 requests / 60 seconds |
The backend reads the real client IP from req.ip via Express trust proxy (configured in main.ts), so rate limiting works correctly behind Railway's load balancer and Cloudflare's proxy.
6. Trade-offs¶
We own the security implementation. Using a managed auth provider would outsource password hashing, token rotation, brute-force protection, and CSRF defence. By staying self-hosted we carry that responsibility — mitigated by using battle-tested libraries (argon2, passport-jwt, helmet, @nestjs/throttler) and following established patterns (SHA-256 token hashing, family-based revocation, timing-safe dummy verifies).
Why it's worth it. The Entity-Scope permission model requires tenant ID and role data inside the JWT payload, refreshed on every rotation. No off-the-shelf provider gives us clean control over the payload structure, token family semantics, and per-refresh tenant/user re-validation without significant workarounds. Full ownership is the pragmatic choice here.
7. Profile endpoints — /me vs /profile¶
Two reads expose the authenticated identity, with different surface areas:
GET /auth/me— lean. Returns the User row's identity columns (id, email, firstName, lastName, avatarUrl) plus JWT-derivedtenantId / roles[] / isPlatformAdminand the access-token expiry. Owned byAuthModule. Used by the navbar / session-validity probe.GET /auth/profile— full. Sameuserpayload plus the caller's Teacher / Staff / Student / Referent rows joined viauserId. Year-snapshotted tables (Teacher/Staff/Student) resolve to the tenant's active academic year viaAcademicYearsService.getActiveYear; Referent is global. Eachprofiles.*field isnullwhen no row exists. The response also carries a top-levelacademicYearId— the active academic year the year-snapshotted profiles resolved against, ornullwhen no year is active. Self-service: noFieldFilterInterceptor— the user always reads their own data in full. Owned byProfileModule(src/profile/), notAuthModule, to break aAuthModule → {Teachers,Staff,Referents}Module → InvitationsModule → AuthModuleimport cycle. The route still sits at/auth/profilebecause Nest allows multiple controllers to share a route prefix. Used by the dashboard / profile page.
Both endpoints sit behind JwtAuthGuard and inherit the global throttler.
8. Key Files¶
| File | Purpose |
|---|---|
src/auth/auth.controller.ts |
HTTP endpoints: login, select-tenant, select-profile, switch-profile, refresh, logout, /me |
src/auth/auth.service.ts |
Cross-tenant credential validation, token generation, refresh logic |
src/profile/profile.controller.ts |
GET /auth/profile — sibling route, separate module |
src/profile/profile.service.ts |
Composes /auth/profile payload from the four role services + AcademicYearsService |
src/auth/strategies/jwt.strategy.ts |
Passport strategy — validates JWT from cookie or Bearer header |
src/auth/guards/jwt-auth.guard.ts |
Guard that requires a valid JWT on protected routes |
src/auth/dto/select-tenant.dto.ts |
DTO for the tenant selection step |
src/auth/interfaces/authenticated-user.interface.ts |
Shape of request.user after JWT validation |
src/auth/interfaces/jwt-payload.interface.ts |
Shape of the JWT payload |
src/auth/auth-config.helper.ts |
Parses REFRESH_TOKEN_EXPIRATION_DAYS env var with a default fallback |
src/auth/utils/to-user-profile.ts |
Shared mapper between /auth/me and /auth/profile response shapes |
9. Active Profile — 3-step login state machine¶
Why it exists¶
A user can be linked to more than one person-entity row in the same tenant (Teacher + Referent, or Staff + Referent — Teacher + Staff is prohibited). Without a per-session active profile, the JWT would carry both role keys and downstream helpers that branch on role membership (e.g. studentsForAccessContext) would silently pick whichever branch evaluates first, leaking cross-profile record access. The frontend would also have no signal for which dashboard to render.
activeProfile makes the session context explicit: one profile is chosen at login (or switched mid-session), the JWT's roles array is narrowed to that profile's role set, and record-level helpers see a single coherent context.
Login state machine¶
Login extends the existing 2-step tenant-selection into a 3-step flow:
POST /auth/login {email, password}
├─ 0 matches → 401 INVALID_CREDENTIALS
├─ 1 tenant + 1 profile → 200 AuthUserDto (cookies set — unchanged path)
├─ 1 tenant + 2 profiles → 200 ProfileSelectionResponseDto (NEW)
└─ 2+ tenants → 200 TenantSelectionResponseDto (unchanged)
POST /auth/login/select-tenant {selectionToken, tenantId}
├─ 1 profile in chosen tenant → 200 AuthUserDto (unchanged)
└─ 2 profiles in chosen tenant → 200 ProfileSelectionResponseDto (NEW)
POST /auth/login/select-profile {selectionToken, activeProfile}
├─ valid → 200 AuthUserDto (cookies set)
└─ invalid → 400 ACTIVE_PROFILE_NOT_AVAILABLE | token errors
Each step returns either a terminal session (AuthUserDto) or a selection prompt carrying a fresh 10-minute selection token (SELECTION_TOKEN_TTL). The frontend treats the response shape as the next state — if requiresProfileSelection: true is present, it renders a chooser. If a selection step is rejected with AUTH_TOKEN_INVALID (the token is invalid or expired), the backend clears the auth cookies on that response — the login flow can't continue, so the client is put back into a clean logged-out state and must restart from the password step. Other selection failures (AUTH_TENANT_INVALID, ACTIVE_PROFILE_NOT_AVAILABLE) leave the still-valid token in place so the user can retry the chooser.
| Endpoint | Body | Terminal response | Selection response |
|---|---|---|---|
POST /auth/login |
{ email, password } |
AuthUserDto |
TenantSelectionResponseDto or ProfileSelectionResponseDto |
POST /auth/login/select-tenant |
{ selectionToken, tenantId } |
AuthUserDto |
ProfileSelectionResponseDto |
POST /auth/login/select-profile |
{ selectionToken, activeProfile } |
AuthUserDto |
— |
The selection token for select-profile uses subject constant 'profile-selection'; the one for select-tenant uses 'tenant-selection'. Each verifier rejects the other's subject, preventing token mix-up.
JWT payload changes¶
type JwtPayload = {
sub: string; // userId
tenantId: string;
roles: string[]; // narrowed to the active session — see §9 Narrowing rule
activeProfile: ActiveProfileKey; // NEW — `PersonProfileKey | 'platform'`
isPlatformAdmin: boolean;
};
request.user (via JwtStrategy) gains activeProfile. AuthenticatedUser and AuthenticatedRequest extend accordingly.
Profile switching¶
POST /auth/switch-profile {activeProfile} (JwtAuthGuard required)
├─ requestedProfile == currentActiveProfile → 200 AuthUserDto (idempotent, no rotation)
├─ valid switch → 200 AuthUserDto (tokens rotated, cookies updated)
└─ invalid → 400 ACTIVE_PROFILE_NOT_AVAILABLE
Validates that the user has a person-entity row of the requested type in the current tenant, revokes the current refresh token, and issues new access + refresh tokens with the updated activeProfile and correspondingly narrowed roles.
Refresh token — activeProfile column¶
The RefreshToken table gains an activeProfile column (nullable — see migration note). On refresh:
- Look up the stored
RefreshTokenrow. - If
row.activeProfileisNULL(legacy row predating this change) → 401ACTIVE_PROFILE_NOT_AVAILABLE— forces re-login. - If
row.activeProfileis no longer in the user's fresh profile list (profile deleted mid-session) → 401ACTIVE_PROFILE_NOT_AVAILABLE. - Otherwise, carry
activeProfileforward into the new access token's narrowedrolesand the newRefreshTokenrow.
New tokens issued by any endpoint (login, select-profile, switch-profile, and refresh itself) always populate activeProfile. After a grace window the column can be tightened to NOT NULL in a follow-up migration.
Error handling¶
| Code | HTTP | Trigger |
|---|---|---|
ACTIVE_PROFILE_NOT_AVAILABLE |
400 | select-profile or switch-profile with a profile the user doesn't have |
ACTIVE_PROFILE_NOT_AVAILABLE |
401 | Refresh on a legacy token (activeProfile = NULL) or profile deleted mid-session |
The response body is generic; debug context (requested, available) is logged server-side only.
Request lifecycle changes¶
After this spec, every authenticated request carries request.user.activeProfile: ActiveProfileKey (i.e. PersonProfileKey | 'platform' — the synthetic 'platform' value covers vendor/superadmin sessions). Controllers and services that need to branch by profile should consult this field directly. The roles[] array on request.user is the narrowed active session set — see "Active-session narrowing" in docs/04-rbac.md.
Soft-enforcement window¶
Access tokens are stateless JWTs with a 1-hour lifetime. If a user's person-entity row (Teacher/Staff/Referent) is hard-deleted while they are logged in as that profile, their existing access token continues to grant the corresponding RBAC scopes until expiry. Refresh-time validation (refresh()) catches the deletion and forces re-login. To shorten the window, the deletion flows in TeachersService.remove, StaffService.remove, and ReferentsService.remove revoke all of the user's refresh tokens — so the user gets bumped on the next refresh attempt rather than after a full token lifetime.
Frontend integration¶
| Response shape | Frontend handles by... |
|---|---|
AuthUserDto (200, with Set-Cookie) |
Authenticated; navigate to dashboard. |
TenantSelectionResponseDto (200, no cookies) |
Show tenant picker; POST /auth/login/select-tenant with {selectionToken, tenantId}. |
ProfileSelectionResponseDto (200, no cookies) |
Show profile picker; POST /auth/login/select-profile with {selectionToken, activeProfile}. |
Switching profiles mid-session uses POST /auth/switch-profile with {activeProfile} and consumes/issues new cookies. GET /auth/me returns availableProfiles: ActiveProfileKey[] so the frontend can render the switch UI without an extra round-trip.
Rollout note¶
The migration that adds refresh_tokens.active_profile is nullable. Existing in-flight refresh tokens issued before this deploy will have active_profile = NULL. The refresh() flow rejects those rows with ACTIVE_PROFILE_NOT_AVAILABLE (401), forcing affected users to log in again. After all in-flight tokens have rotated (default 7 days based on JWT_REFRESH_EXPIRATION_DAYS), a follow-up migration may tighten the column to NOT NULL.
Key files¶
| File | Role |
|---|---|
src/common/constants/person-profiles.ts |
PERSON_PROFILE_KEYS, PersonProfileKey, EMPLOYEE_PROFILES, PERSON_PROFILE_TO_ENTITY |
src/common/utils/narrow-roles.ts |
narrowRolesForActiveProfile pure helper |
src/auth/dto/profile-selection-response.dto.ts |
ProfileSelectionResponseDto (mirrors TenantSelectionResponseDto) |
src/auth/dto/select-profile.dto.ts |
{ selectionToken, activeProfile } |
src/auth/dto/switch-profile.dto.ts |
{ activeProfile } |
src/auth/auth.service.ts |
resolveProfileSelection, completeProfileSelection, switchActiveProfile |
src/auth/auth.controller.ts |
POST /auth/login/select-profile, POST /auth/switch-profile |