Skip to main content

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:

  • FirstLoginGuard needs req.user populated by SessionGuard
  • RolesGuard must come after FirstLoginGuard — 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 admin role 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):

ConditionError CodeHTTP
user.mustChangePassword === trueMUST_CHANGE_PASSWORD403
!user.emailVerifiedEMAIL_NOT_VERIFIED403
!user.twoFactorEnabledTOTP_SETUP_REQUIRED403

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:

  1. Reads @Roles(...) metadata from the handler (falls back to controller-level decorator)
  2. If no @Roles decorator → passes (endpoint is session-only, not role-restricted)
  3. Checks req.user.role against allowed roles
  4. Additional check: If the required role is admin or superadmin, verifies isStaffDomain(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 });