Skip to content

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=None cookies so JavaScript cannot read them. SameSite=None is deliberate — the FE and BE live on different domains in every environment, and None requires Secure (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

POST /api/v1/auth/login
{ "email": "admin@demo-school.dev", "password": "changeme123" }

AuthService.validateCredentials() runs cross-tenant:

  1. Find all active User records matching the email across all active tenants.
  2. Verify the supplied password against each match using argon2.verify() — all checks run in parallel to keep latency flat regardless of tenant count.
  3. 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)

POST /api/v1/auth/login/select-tenant
{ "selectionToken": "<jwt>", "tenantId": "<uuid>" }

The selectionToken is a short-lived JWT (10-minute TTL) signed by the server, containing:

{ "sub": "tenant-selection", "matchedUserIds": ["<uuid>", ...] }

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:

POST /api/v1/auth/refresh
{ "refreshToken": "<token>" }

Server-side refresh logic:

  1. Hash lookup — SHA-256 hash of the supplied token is looked up in the database.
  2. 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.
  3. Tenant + user validation — confirm the tenant is still ACTIVE or TRIAL, and the user is still isActive.
  4. IP change logging — if the request IP differs from the IP that last used this token, a warning is logged (not blocked, but auditable).
  5. 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-derived tenantId / roles[] / isPlatformAdmin and the access-token expiry. Owned by AuthModule. Used by the navbar / session-validity probe.
  • GET /auth/profile — full. Same user payload plus the caller's Teacher / Staff / Student / Referent rows joined via userId. Year-snapshotted tables (Teacher/Staff/Student) resolve to the tenant's active academic year via AcademicYearsService.getActiveYear; Referent is global. Each profiles.* field is null when no row exists. The response also carries a top-level academicYearId — the active academic year the year-snapshotted profiles resolved against, or null when no year is active. Self-service: no FieldFilterInterceptor — the user always reads their own data in full. Owned by ProfileModule (src/profile/), not AuthModule, to break a AuthModule → {Teachers,Staff,Referents}Module → InvitationsModule → AuthModule import cycle. The route still sits at /auth/profile because 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:

  1. Look up the stored RefreshToken row.
  2. If row.activeProfile is NULL (legacy row predating this change) → 401 ACTIVE_PROFILE_NOT_AVAILABLE — forces re-login.
  3. If row.activeProfile is no longer in the user's fresh profile list (profile deleted mid-session) → 401 ACTIVE_PROFILE_NOT_AVAILABLE.
  4. Otherwise, carry activeProfile forward into the new access token's narrowed roles and the new RefreshToken row.

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