Guards
Three guards protect NestJS endpoints. They are applied in a fixed order and must be combined in that order to function correctly.
Application Order
Request → SessionGuard → FirstLoginGuard → RolesGuard → Handler
Why this order matters:
FirstLoginGuardneedsreq.userpopulated bySessionGuardRolesGuardmust come afterFirstLoginGuard— a staff account mid-first-login-flow has a valid role, but should not have access to resources- Applying them in reverse order would allow
adminrole access before completing the first-login setup
SessionGuard
Location: src/common/guards/session.guard.ts
Purpose: Validates the Better Auth session cookie and populates req.user and req.session.
How it works:
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!session) throw new UnauthorizedException();
req.user = session.user;
req.session = session.session;
Failure: Throws UnauthorizedException (HTTP 401) if no valid session exists.
Applied to: All protected endpoints. Not applied to public routes (/health, /claim, /quotes/*, /payments/webhook, /waitlist).
FirstLoginGuard
Location: src/common/guards/first-login.guard.ts
Purpose: Enforces the staff first-login flow. Only applies to @ctsolutions.gr accounts.
Checks (in order):
| Condition | Error Code | HTTP |
|---|---|---|
user.mustChangePassword === true | MUST_CHANGE_PASSWORD | 403 |
!user.emailVerified | EMAIL_NOT_VERIFIED | 403 |
!user.twoFactorEnabled | TOTP_SETUP_REQUIRED | 403 |
Skipped for non-staff: Customer accounts (@gmail.com, etc.) are not subject to these checks. Only isStaffDomain(user.email) accounts go through first-login enforcement.
Error format:
{ "statusCode": 403, "message": "MUST_CHANGE_PASSWORD", "error": "Forbidden" }
The web's staff first-login pages detect these specific codes and redirect accordingly.
RolesGuard
Location: src/common/guards/roles.guard.ts
Purpose: Enforces the @Roles() decorator on controllers and handlers.
How it works:
- Reads
@Roles(...)metadata from the handler (falls back to controller-level decorator) - If no
@Rolesdecorator → passes (endpoint is session-only, not role-restricted) - Checks
req.user.roleagainst allowed roles - Additional check: If the required role is
adminorsuperadmin, verifiesisStaffDomain(req.user.email)— prevents a customer account that somehow got an elevated role from accessing staff endpoints
Failure: Throws ForbiddenException (HTTP 403).
Usage:
@Controller('orders')
@Roles('admin', 'superadmin') // controller-level default
export class OrdersController {
@Get()
@Roles('admin', 'superadmin', 'user') // overrides for this endpoint
findAll() { ... }
}
Common Gotchas
"My endpoint returns 401 but I'm logged in" — Check that SessionGuard is applied. Ensure the session cookie is being sent (check sameSite, secure, domain config matches the environment).
"Staff user gets 403 after login" — Check FirstLoginGuard. The user may have mustChangePassword: true or emailVerified: false. Look at the error code in the response body.
"Admin user can't access endpoint" — Check that the user's email is @ctsolutions.gr. A role: 'admin' with a non-staff email fails RolesGuard's domain check.
"Guard not applied to endpoint" — Guards can be applied at the controller level (all endpoints) or per-handler. If you add a new endpoint to a controller that has class-level guards, the endpoint inherits them. If a specific endpoint should be unguarded, use @Public() or restructure.
Express-Level Rate Limiting (Not Guards)
Auth paths use Express middleware registered in app.module.ts, not NestJS guards. This bypasses the NestJS interceptor stack entirely for higher throughput on auth endpoints:
consumer
.apply(signInRateLimit)
.forRoutes({ path: "api/auth/sign-in/email", method: RequestMethod.POST })
.apply(signUpRateLimit)
.forRoutes({ path: "api/auth/sign-up/email", method: RequestMethod.POST });