Auth & Permissions
How the server locks down every route by default — four global guards, handler decorators, CASL, and the JWT that carries your school.
The single most important thing to understand about auth on this server is that it is opt-out, not opt-in. You don't add security to a route — security is already there, and you remove it where you genuinely don't need it. Every request must carry a valid token and clear a chain of checks before your handler ever runs. A brand-new controller, with no decorators at all, is locked down by default.
That design choice has a happy consequence: forgetting to protect an endpoint is impossible. The failure mode is the safe one — you get a 401 and go "oh, right, I need to say who's allowed" — instead of accidentally shipping an open door.
Everything in this page lives under apps/server/src/app/auth/. Let's build the mental model from the outside in: the guards that gate every request, the decorators you sprinkle on handlers to tune them, and the token that ties a user to their school.
Two words you'll see constantly
Authentication is "who are you?" — proving identity with a token. Authorization is "are you allowed to do this?" — checking permissions. The first guard does authentication; the other three do authorization. Keep the two ideas separate in your head and the rest of this page clicks.
The four global guards
A guard in NestJS is a small class that runs before your route handler and decides whether the request may proceed. Returning true lets it through; throwing (or returning false) stops it cold. Most apps attach guards route by route. This server attaches four of them globally — registered once as APP_GUARD providers — so they wrap every controller in the app.
You can see all four in the auth module's provider list, and the order is deliberate:
providers: [
// ...
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 1. who are you?
{ provide: APP_GUARD, useClass: UserTypeGuard }, // 2. right kind of user?
{ provide: APP_GUARD, useClass: SystemRoleGuard }, // 3. right system role?
{ provide: APP_GUARD, useClass: PermissionsGuard }, // 4. allowed this action?
],They run top to bottom, and that order matters: each layer assumes the one before it already passed. There's no point checking your role before we know who you are. Here's what each one enforces.
1. JwtAuthGuard
This is the front door. It validates the Bearer token on the request (AuthGuard('jwt')) and, on success, attaches the decoded user to the request. It also returns friendly, specific messages — "Session has expired" versus "Authentication required" — so the frontend can react sensibly.
Two special behaviours live here. First, it skips the whole check when the route is marked @Public(). Second, once the token checks out, it writes the user's tenant into the per-request context (more on that below). Everything downstream depends on this guard having run.
2. UserTypeGuard
The system has distinct user types (think staff vs. student vs. guardian vs. platform operator). This guard enforces @RequireUserType(...): a route decorated for staff simply rejects a student's token before any finer-grained check runs. If a handler has no @RequireUserType, this guard waves it through.
3. SystemRoleGuard
Some capabilities are gated by system-level roles — global roles that aren't tied to a particular campus. This guard enforces @RequireSystemRole(...). Keep this distinct from the campus-scoped roles you'll meet in the RBAC layer; system roles are the broad, school-wide kind.
4. PermissionsGuard
The last and most powerful layer. It does CASL-based RBAC (covered in the next section): it builds the user's abilities and checks @RequirePermissions(...). If the user lacks even one required permission, the request is denied — and the denial is audit-logged with the IP and user-agent, because a blocked attempt is exactly the kind of thing you want a record of.
Order is a safety property, not a detail
The guards run JwtAuthGuard → UserTypeGuard → SystemRoleGuard → PermissionsGuard. Because each step trusts that the previous one succeeded, reordering them would let a check run against an unauthenticated request. If you ever touch the APP_GUARD registration, preserve this order.
Decorators
The guards are generic. Decorators are how an individual handler tells them what it wants — they attach metadata that the guards read. You'll reach for five of them constantly.
| Decorator | What it does |
|---|---|
@Public() | Skip auth/permission guards entirely — login, OTP, webhooks, health checks |
@RequirePermissions({ action, resource }) | Require a CASL ability; repeat it for AND logic |
@RequireUserType(...types) | Restrict the route to specific UserTypes |
@RequireSystemRole(...) | Require a system-level role |
@CurrentUser(property?) | Inject the decoded JWT payload, or one field of it |
The two you'll use most are @RequirePermissions to gate the handler and @CurrentUser to read the caller. A typical protected handler reads like a sentence — "to create a student, you must have CREATE on STUDENT":
@Post()
@RequirePermissions({ action: Action.CREATE, resource: Resource.STUDENT })
create(@Body() dto: CreateStudentDto, @CurrentUser() user: JwtPayload) {
// `user` is the decoded token — user.sub, user.schoolId, etc.
return this.studentService.create(dto, user);
}@CurrentUser() with no argument hands you the whole payload; pass a property name — @CurrentUser('sub') — to inject just the user id. The decorator itself is tiny; it's defined in apps/server/src/app/auth/decorators/.
@Public() is the one with teeth
@Public() turns the guards off for that route. It exists for the handful of endpoints that genuinely can't require a token — logging in, the OTP exchange, health probes. Treat every new @Public() as a security decision, not a convenience: a public route still has to defend itself (throttling, input validation, no data leaks).
CASL permissions
The PermissionsGuard is powered by CASL, a small authorization library. Its model is wonderfully simple: a permission is an (action, resource) pair. Can this user CREATE a STUDENT? Can they READ an AUDIT_LOG? For each request, an AbilityFactoryService assembles the user's full set of abilities, and the guard checks that the user can(action, resource) for every permission the route requires.
Both halves of the pair are enums, defined in libs/shared/src/permission/permission.enum.ts:
enum Action { READ, LIST, CREATE, UPDATE, DELETE, MANAGE, IMPERSONATE }
enum Resource { ALL, USER, STAFF, STUDENT, GUARDIAN, GRADE, CLASS, APPLICATION,
ACADEMIC_REPORT, ROLE, CAMPUS, DEPARTMENT, SUBJECT, /* ... */ }Import them from the permission subpath so you get the right symbols:
import { Action, Resource } from '@app/shared/permission';Two actions deserve a footnote. MANAGE is CASL's wildcard — it means "every action on this resource". And ALL is the wildcard resource. Together, MANAGE + ALL is effectively "can do anything", which is why it's handed out sparingly.
This is the surface, not the depth. How abilities are computed per user type, how roles map to permission sets, and how access is narrowed to a single campus or even a single row, all belong to the RBAC subsystem — that detailed material, along with role administration, is documented later in the API domains section, so we won't dive into it here.
schoolId in the JWT
This server is multi-tenant: every ordinary user belongs to exactly one school, and a request must never leak data across schools. The mechanism that makes that cheap and reliable is putting the tenant inside the token. The payload shape lives in auth/dto/auth.types.ts:
interface JwtPayload {
sub: string; // user id
email: string;
type: UserType;
profileId: string;
schoolId?: string; // owning school (tenant) — absent for platform admins
campusId?: string;
act?: string; // real admin's id during an impersonation session
iat: number;
exp: number;
}Notice schoolId is optional. It's stamped on the token for every tenant user, but omitted for platform_admin operators, who run the platform itself and belong to no single school. The token service sets it conditionally — present only when the user actually has one:
...(user.schoolId && { schoolId: user.schoolId }),Because the school travels inside a signed token, no handler has to ask "which tenant is this?" — the answer is already proven and tamper-proof.
Tenant context
A token tells you the tenant, but your repository code, buried several layers deep, also needs it — and you don't want to thread schoolId through every function call. The solution is AsyncLocalStorage: a Node feature that holds a value for the lifetime of one request, readable anywhere downstream without passing it around. The server keeps the current tenant there.
It's set up in two moves. First, a global middleware opens the context with a fail-closed default before the request even reaches NestJS — schoolId: null, no bypass:
tenantContextStore.run(
{ schoolId: null, isPlatformOperator: false, bypass: false },
() => next(),
);Then, once JwtAuthGuard validates the token, it mutates that context in place — writing the real schoolId for a tenant user, or setting bypass: true for a platform operator who is allowed to read across tenants.
Why mutate instead of re-entering the context?
Passport's verify callback runs on an async branch, and starting a fresh context there (enterWith) wouldn't propagate back to your handler. Mutating the object the middleware already run()-bound does reach the handler. Hence the two-step dance: a fail-closed default up front, an in-place update once identity is known.
The payoff of fail-closed: a route that never establishes a real tenant — say a @Public() one — has schoolId: null, so the data layer can't silently hand back another school's rows. To read across tenants you must opt in explicitly. The repository-scoping side of this story (@TenantScoped, runUnscoped(), withSchool()) is covered in The Database.
Logging in
Authentication produces an access token (short-lived) and a refresh token (long-lived). There are three ways to obtain that pair.
Password login is the classic path — email plus password, checked against a bcrypt hash. OTP login is passwordless: you hand over an access code or email, a 6-digit code is emailed, and you exchange it for tokens. OTP is the only way platform_admin operators can sign in, because they're seeded with no password at all. Microsoft 365 login is a tenant-scoped OAuth round-trip — GET auth/microsoft?schoolId=<id> kicks it off and the callback exchanges the code for a profile. Microsoft is the one path that doesn't use a Passport strategy; it's wired up by hand.
The OTP endpoints are all @Public() and throttled, and they're carefully built to never leak whether an account exists:
| Route | Operation | Notes |
|---|---|---|
POST auth/otp/lookup | lookupLoginOtp | Type-ahead match; returns a masked email or found: false — never errors on a miss |
POST auth/otp/request | requestLoginOtp | Emails a 6-digit code; invalidates any prior live code |
POST auth/otp/verify | verifyLoginOtp | Exchanges a valid, single-use code for a token pair |
OTP codes are hashed and single-use
A login code is never stored in plain text — only a SHA-256 hash, alongside an expiresAt, a consumedAt, and an attempts counter. Codes expire, can be used once, and have a capped attempt count. If you work on this flow, preserve those properties.
Refresh-token rotation
Access tokens expire fast (default 15 minutes), so the client trades a refresh token for a fresh pair when one lapses. The refresh token is rotated on every use, and the old one is revoked before new tokens are minted. Refresh tokens are stored only as a SHA-256 hash, each tagged with a familyId.
That familyId enables replay detection: if someone presents an already-revoked token, the server assumes it was stolen and revokes the entire family, forcing a clean re-login. One more guard rail — if the user's school is suspended, both login and refresh are rejected with 403 before any token is touched, so a denied refresh never burns the family.
Where to go next
The Database
The repository-scoping side of multi-tenancy — how schoolId filters every query.
Module Anatomy
Where controllers, guards, and decorators sit inside a feature module.
API Conventions
How routes, operationIds, and DTOs are shaped across the server.
Add an Endpoint
Put it together: add a guarded route with the right decorators.