In the landscape of modern backend engineering, architecting applications that are both resilient and secure requires a confluence of several critical methodologies. The foundation of a robust system lies in Type-Safety, a discipline that leverages compile-time verification to ensure data consistency across the entire stack, catching potential errors long before deployment. Building upon this stable base, sophisticated systems must be Auth-Aware. This is a dual mandate requiring both authentication — verifying _who_ a user is — and Multi-Role-Based Access (standardized industrially as Role-Based Access Control, or RBAC), which strictly determines _what_ resources authenticated users are authorized to interact with based on hierarchical privileges like ‘admin’, ‘standard user’ or ‘guest’. To implement these complex requirements efficiently, we turn to a powerful technology synergy: NestJS, a progressive, architectural framework for building scalable Node.js server-side applications, and Prisma, a next-generation Object-Relational Mapper (ORM) that extends type-safety directly from your code into the database layer.
When scaling a Node.js backend into a large, distributed codebase, loose typing and inconsistent security patterns quickly become your biggest liabilities. In this tutorial, we will architect a production-ready solution that prioritizes strict contracts and granular security by leveraging NestJS*, a framework designed for modular, enterprise-grade architecture, and **Prisma**, an ORM that guarantees end-to-end **Type-Safety** — ensuring that data shapes are validated at compile-time rather than crashing in production. We will go beyond basic CRUD to build an **Auth-Aware** API, a system that doesn’t just verify identity but integrates *Multi-Role-Based Access deep into the business logic. By the end, you will have a scalable pattern for managing complex user hierarchies (like Admins, Moderators, and Users) where every request is authenticated, authorized, and statically typed.
To lay the groundwork for our role-based security, we begin by defining the data model in our schema.prisma file. In a scalable architecture, it is best practice to avoid creating fragmented tables for different user types (e.g., separate Admin and User tables). Instead, we will utilize a native Enum to act as a discriminator. This approach enforces strict data integrity at the database level and allows Prisma to generate a literal TypeScript union type (e.g., ‘ADMIN’ | ‘USER’), ensuring that our access control logic is type-safe from the moment data is fetched.
Here is how we model the User entity with a default USER role, alongside MODERATOR and ADMIN privileges:
// schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // or mysql, sqlite, mongodb etc.
url = env("DATABASE_URL")
}
// 1. Define the Role Enum to enforce strict typing
enum Role {
USER
MODERATOR
ADMIN
}
// 2. The Single Source of Truth for all identities
model User {
id Int @id @default(autoincrement())
email String @unique
password String // Hashed password
// The 'role' field defaults to USER, strictly typed to the Enum above
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users") // Best practice: map to plural table names in the DB
}
With our data layer established, we must now bridge the gap between incoming HTTP requests and our database entities. In NestJS, this is achieved by implementing Passport Strategies*. While a simple application might rely on a single authentication guard, enterprise-grade systems often require distinct strategies to handle different security contexts (e.g., Admins might use a different JWT secret or expiration policy than standard Users). Below, we define three focused strategies: ModeratorJwtStrategy and AdminJwtStrategy for strict, role-isolated endpoints, and a CombinedJwtStrategy. The combined strategy is particularly powerful for *polymorphic endpoints — API routes that serve multiple user types (such as a shared “Notifications” or “Profile” view) — allowing the system to dynamically resolve the user’s identity from the correct database table based on the token’s role claim.
Here is how we implement these strategies using passport-jwt and proper Dependency Injection:
// auth.strategies.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { config } from '../config';
// Define the shape of the data we are selecting to reuse in our decorators
// We map "name" to firstname/lastname based on our schema
export const authSelect = {
id: true,
email: true,
role: true,
firstname: true,
lastname: true,
};
@Injectable()
export class ModeratorJwtStrategy extends PassportStrategy(Strategy, 'jwt-moderator') {
constructor(private prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.jwt.moderator,
});
}
async validate(payload: { id: string; role: string }) {
if (payload.role !== 'MODERATOR') {
throw new UnauthorizedException('Role is not authorized.');
}
const moderator = await this.prisma.moderator.findUnique({
where: { id: payload.id },
select: authSelect, // <--- Only fetch essential fields
});
if (!moderator) throw new UnauthorizedException('Moderator not found.');
return moderator;
}
}
@Injectable()
export class AdminJwtStrategy extends PassportStrategy(Strategy, 'admin-jwt') {
constructor(private prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.jwt.admin,
});
}
async validate(payload: { id: string }) {
const admin = await this.prisma.admin.findUnique({
where: { id: payload.id },
select: authSelect, // <--- Consistent selection
});
if (!admin) throw new UnauthorizedException('Admin access required.');
return admin;
}
}
@Injectable()
export class CombinedJwtStrategy extends PassportStrategy(Strategy, 'jwt-combined') {
constructor(private prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.jwt.user,
});
}
async validate(payload: { id: string; role: string }) {
const { id, role } = payload;
if (role === 'MODERATOR') {
const moderator = await this.prisma.moderator.findUnique({
where: { id },
select: authSelect,
});
if (!moderator) throw new UnauthorizedException();
return moderator;
}
if (role === 'USER') {
const user = await this.prisma.user.findUnique({
where: { id },
select: authSelect,
});
if (!user) throw new UnauthorizedException();
return user;
}
throw new UnauthorizedException('Invalid or unsupported user role in token.');
}
}
In high-throughput APIs, fetching the entire user object (which might eventually include relations, heavy text fields, or metadata) on every single request is a performance bottleneck. To solve this, we optimize our Prisma queries to select _only_ the essential identity fields: `id`, `email`, `role`, and `name`.
Crucially, we must ensure our application remains type-safe. If our database query only returns four fields, our TypeScript interfaces must not claim that the full `User` model is available. We achieve this strict alignment using the TypeScript `Pick` utility type.
In the CombinedJwtStrategy, notice how the validate method acts as a traffic controller. By checking the role property in the JWT payload _before_ querying the database, we ensure we are looking up the user in the correct table (prisma.moderator vs prisma.user). This eliminates the need for “try/catch” logic where we guess which table the ID belongs to, resulting in a cleaner, more performant authentication flow.
Now that our strategies are wired, we need to implement the Guards — the gatekeepers that decide which strategy applies to a given request. While passport handles the _how_ of verification, Guards handle the _when_. In a complex application, you rarely want a “one-size-fits-all” guard. Instead, we create specialized guards that can handle edge cases, such as “Optional Authentication” (where a user _might_ be logged in, but doesn’t have to be) or “Mixed Access” (where different rules apply).
Below, we implement three distinct guards. Notice the sophisticated logic in JwtAuthGuard and ModeratorAuthGuard: they check for metadata keys like IS_PUBLIC_KEY and IS_MIXED_KEY using the NestJS Reflector. This allows us to decorate specific controllers or routes as “Public” or “Mixed,” effectively bypassing the strict login requirement when necessary while still attempting to attach a user object if a token is present.
// auth.guards.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY, IS_MIXED_KEY } from 'src/config'; // Custom decorator constants
// 1. The Standard User Guard with "Public" and "Mixed" awareness
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
async canActivate(context: ExecutionContext): Promise<any> {
// Check for @Public() decorator
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ context.getHandler(),
context.getClass(),
]);
if (isPublic) {
// If public, try to validate the token if it exists, but don't fail if it doesn't
return this.handleOptionalAuth(context);
}
// Check for @Mixed() decorator (e.g., allows both standard Users and Professionals)
const isAllowedBoth = this.reflector.getAllAndOverride<boolean>(IS_MIXED_KEY, [ context.getHandler(),
context.getClass(),
]);
if (isAllowedBoth) {
// Logic: If header exists, validate it. If not, allow through (depending on business logic)
const hasHeader = context.switchToHttp().getRequest().headers.authorization;
return hasHeader ? super.canActivate(context) : true;
}
return super.canActivate(context);
}
// Helper to handle "Public but try to resolve user" logic
private async handleOptionalAuth(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (authHeader) {
try {
const result = await super.canActivate(context); // Calls strategy
if (result) return true;
} catch (e) {
request.user = null; // Token invalid? Treat as guest.
}
} else {
request.user = null; // No token? Treat as guest.
}
return true;
}
}
// 2. The Moderator Guard (Mirrors the flexibility of the User guard)
@Injectable()
export class ModeratorAuthGuard extends AuthGuard('jwt-moderator') {
constructor(private reflector: Reflector) {
super();
}
// ... Implementation mirrors JwtAuthGuard but uses 'jwt-moderator' strategy
}
// 3. The Strict Admin Guard (No flexibility—Admins must always be authenticated)
@Injectable()
export class AdminAuthGuard extends AuthGuard('admin-jwt') {}
// 4. The Combined Guard (Uses our polymorphic strategy)
@Injectable()
export class CombinedAuthGuard extends AuthGuard('jwt-combined') {};
This pattern gives you granular control. By default, endpoints are secure. But with a simple decorator, you can open an endpoint to the public (while still capturing user data if they _are_ logged in) or allow a “Combined” route where the system accepts tokens from different user tables seamlessly.
With our sophisticated Guards in place, we need a clean, declarative interface to control them from our Controllers. Hardcoding logic checks inside route handlers is an anti-pattern; instead, we implement Custom Decorators* to keep our business logic distinct from our authorization logic. We will create two specific categories of decorators: **Metadata Decorators**, which act as signals to our Guards (toggling “Public” or “Mixed” modes), and *Parameter Decorators, which extract and strictly type the authenticated identity.
The Metadata decorators (@Public, @AllowBoth) utilize NestJS’s SetMetadata to attach custom flags to the route handler. Our Guards read these flags at runtime to decide whether to bypass strict authentication. Simultaneously, the Parameter decorators (@AuthUser, @AuthModerator, @AuthAdmin) solve a common pain point in Express-based frameworks: the untyped req.user object. By leveraging createParamDecorator, we can extract the user principal from the request and cast it immediately to the generated Prisma types (User, Moderator, Admin). This ensures that inside your controller methods, you are working with guaranteed, type-safe entities, not generic objects.
// auth.decorators.ts
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
import { Admin, Moderator, User } from '@prisma/client';
import { IS_PUBLIC_KEY, IS_MIXED_KEY } from 'src/config';
// Define the type alias for our authenticated entities
// This matches the 'authSelect' object we defined in the strategies
export type AuthEntity<T> = Pick<T, 'id' | 'email' | 'role' | 'firstname' | 'lastname'>;
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const AllowBoth = () => SetMetadata(IS_MIXED_KEY, true);
export const AuthUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthEntity<User> => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);
export const AuthModerator = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthEntity<Moderator> => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);
export const AuthAdmin = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthEntity<Admin> => {
const request = ctx.switchToHttp().getRequest();
return request.user;
}
);
By using these decorators, your controller method signatures become self-documenting and type-safe. You no longer have to guess what properties exist on the user object — TypeScript and Prisma validation will guide you.
We have built the security engine; now we must provide the interface. In NestJS, Controllers are the convergence point where our Guards, Strategies, and Decorators meet to handle incoming requests. By applying our custom logic here, we transform standard route handlers into self-documenting, secure endpoints. The goal is to make the code declarative: a developer reading the controller should immediately understand the security requirements without needing to dig into the middleware.
In the example below, we apply the CombinedAuthGuard at the class level, securing the entire resource by default. We then selectively relax these rules for specific endpoints using @Public() or enforce strict context injection using @AuthUser(). This pattern ensures that “secure by default” is the baseline, while maintaining the flexibility to expose public data (like availability calendars) alongside protected actions.
import { Controller, Get, Query, UseGuards, Post, Body } from '@nestjs/common';
import { ApiOperation, ApiQuery } from '@nestjs/swagger';
import { CombinedAuthGuard, AdminAuthGuard } from './auth.guards';
import { AuthUser, Public, AuthAdmin } from './auth.decorators';
import { User, Admin } from '@prisma/client';
import { GetBookingsDto, CreateBookingDto } from './dtos';
// 1. Secure the entire Controller by default
// Every route inside this class now requires a valid token unless overridden.
@UseGuards(CombinedAuthGuard)
@Controller('bookings')
export class BookingController {
constructor(private readonly bookingService: BookingService) {}
// 2. Standard Authenticated Route
// The Guard validates the token, and @AuthUser extracts the User entity.
// TypeScript guarantees 'user' is a valid Prisma User model, not 'any'.
@Get('/')
@ApiOperation({ summary: 'Get all user bookings' })
@ApiQuery({ type: GetBookingsDto })
findAllBookings(@Query() payload: GetBookingsDto, @AuthUser() user: AuthEntity<User>) {
return this.bookingService.findAllBookings(user.id, user.role, payload);
}
// 3. Public Route (Bypassing the Guard)
// We use the @Public() decorator to signal the Guard to skip validation.
// Perfect for "read-only" data that doesn't require identity.
@Public()
@Get('/availability')
@ApiOperation({ summary: 'Check public slot availability' })
checkAvailability(@Query('date') date: string) {
return this.bookingService.getPublicSlots(date);
}
// 4. Role-Specific Action
// We can inject specific user types. If a non-Admin calls this,
// the parameter decorator or the service logic can enforce further checks.
// Alternatively, we can stack guards (e.g., adding AdminAuthGuard) for double security.
@Post('/admin/audit')
@UseGuards(AdminAuthGuard) // Enforces that the token is specifically an Admin token
@ApiOperation({ summary: 'Audit bookings (Admin only)' })
auditBookings(@AuthAdmin() admin: AuthEntity<Admin>) {
console.log(`Audit initiated by admin: ${admin.email}`);
return this.bookingService.runAudit();
}
}
This approach creates a clear separation of concerns. The Controller doesn’t worry about parsing headers or validating JWT signatures; it simply declares _who_ it needs (@AuthUser) and _what_ the rules are (@Public), relying on the underlying architecture to enforce them safely.
Conclusion: The Payoff of Rigorous Architecture
-----------------------------------------------
Building a system with distinct JWT strategies, polymorphic guards, and custom parameter decorators is undeniably an investment in upfront complexity. However, for large-scale applications, this investment pays distinct dividends. By tightly coupling NestJS*’s execution context with *Prisma’s generated types, we have effectively moved security from runtime guesswork to compile-time certainty. We are no longer relying on fragile string comparisons or assuming req.user exists; instead, we have a system where access control is a strictly enforced contract. Whether you are handling a high-traffic public endpoint or a sensitive admin audit, this architecture ensures that your application remains both flexible enough to handle complex business requirements and rigid enough to prevent unauthorized access. You now possess a scalable blueprint for identity management that is ready for production.