Securing NestJS: A Deep Dive into Authentication, Authorization, and RBAC/ABAC
Security is not a feature; it's a foundation. This guide transcends basic login tutorials to explore the complete identity lifecycle. We cover the integration of Passport.js strategies, implementing secure OAuth2 flows, defining complex permission systems with CASL, and hardening your API with refresh token rotation and MFA.
Authentication & Authorization
Passport.js Integration
Passport.js is the de-facto authentication middleware for Node.js, integrated via @nestjs/passport with a strategy-based architecture that supports 500+ authentication mechanisms through a unified interface.
// local.strategy.ts @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { constructor(private authService: AuthService) { super({ usernameField: 'email' }); } async validate(email: string, password: string): Promise<User> { const user = await this.authService.validateUser(email, password); if (!user) throw new UnauthorizedException(); return user; } } // Controller usage @Post('login') @UseGuards(AuthGuard('local')) async login(@Request() req) { return this.authService.login(req.user); }
JWT Authentication
JWT (JSON Web Tokens) provides stateless authentication by encoding user claims in signed tokens—ideal for microservices and SPAs where session storage isn't practical.
// jwt.strategy.ts @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: process.env.JWT_SECRET, }); } async validate(payload: JwtPayload) { return { userId: payload.sub, email: payload.email, roles: payload.roles }; } } // auth.service.ts @Injectable() export class AuthService { constructor(private jwtService: JwtService) {} async login(user: User) { const payload = { email: user.email, sub: user.id, roles: user.roles }; return { access_token: this.jwtService.sign(payload, { expiresIn: '15m' }), refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }), }; } }
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │──Login──►│ Server │──Verify──►│ DB │
└────┬─────┘ └────┬─────┘ └──────────┘
│ │
│◄───JWT Token────────┤
│ │
│───Request + JWT────►│
│ │──Validate Token (no DB hit)
│◄───Response─────────┤
Session-based Authentication
Session authentication stores user state server-side with a cookie-based session ID—better for traditional web apps where you need server-side session control and instant invalidation.
// main.ts import * as session from 'express-session'; import * as passport from 'passport'; import RedisStore from 'connect-redis'; app.use(session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, httpOnly: true, maxAge: 86400000 }, })); app.use(passport.initialize()); app.use(passport.session()); // session.serializer.ts @Injectable() export class SessionSerializer extends PassportSerializer { serializeUser(user: User, done: Function) { done(null, user.id); } async deserializeUser(id: number, done: Function) { const user = await this.userService.findById(id); done(null, user); } }
OAuth2 and Social Login
OAuth2 enables third-party authentication through providers like Google, GitHub, or Facebook—Passport strategies handle the OAuth flow while you manage user creation/linking.
// google.strategy.ts @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { constructor(private authService: AuthService) { super({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: 'http://localhost:3000/auth/google/callback', scope: ['email', 'profile'], }); } async validate(accessToken: string, refreshToken: string, profile: Profile) { const user = await this.authService.findOrCreateOAuthUser({ provider: 'google', providerId: profile.id, email: profile.emails[0].value, name: profile.displayName, }); return user; } } // auth.controller.ts @Get('google') @UseGuards(AuthGuard('google')) googleLogin() {} @Get('google/callback') @UseGuards(AuthGuard('google')) googleCallback(@Req() req) { return this.authService.login(req.user); }
Role-based Access Control (RBAC)
RBAC restricts access based on predefined roles assigned to users—implemented via custom decorators and guards that check user roles against required permissions.
// roles.decorator.ts export const ROLES_KEY = 'roles'; export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); // roles.guard.ts @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); if (!requiredRoles) return true; const { user } = context.switchToHttp().getRequest(); return requiredRoles.some(role => user.roles?.includes(role)); } } // Usage @Get('admin/dashboard') @Roles(Role.Admin, Role.SuperAdmin) @UseGuards(JwtAuthGuard, RolesGuard) getAdminDashboard() {}
┌─────────────────────────────────────┐
│ User │
│ roles: ['admin'] │
└─────────────────┬───────────────────┘
│
┌───────────▼───────────┐
│ RolesGuard │
│ Required: ['admin'] │
│ User has: ['admin'] │
│ Result: ✓ ALLOWED │
└───────────────────────┘
Attribute-based Access Control (ABAC)
ABAC evaluates access based on attributes of the user, resource, action, and environment—more flexible than RBAC for complex authorization rules like "users can only edit their own posts."
// policies/post.policy.ts @Injectable() export class PostPolicy { canUpdate(user: User, post: Post): boolean { return ( user.id === post.authorId || user.roles.includes('admin') || (user.department === post.department && user.roles.includes('editor')) ); } canDelete(user: User, post: Post): boolean { return user.roles.includes('admin') || (user.id === post.authorId && post.status === 'draft'); } } // posts.controller.ts @Patch(':id') async update(@Req() req, @Param('id') id: string, @Body() dto: UpdatePostDto) { const post = await this.postsService.findOne(id); if (!this.postPolicy.canUpdate(req.user, post)) { throw new ForbiddenException('Cannot update this post'); } return this.postsService.update(id, dto); }
CASL Integration
CASL is an isomorphic authorization library that allows defining abilities (permissions) in a declarative way—it works great with NestJS for both backend validation and frontend UI control.
// casl-ability.factory.ts @Injectable() export class CaslAbilityFactory { createForUser(user: User) { const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility); if (user.roles.includes('admin')) { can('manage', 'all'); } else { can('read', 'Article'); can('create', 'Article'); can('update', 'Article', { authorId: user.id }); can('delete', 'Article', { authorId: user.id, status: 'draft' }); cannot('delete', 'Article', { status: 'published' }); } return build(); } } // policies.guard.ts @Injectable() export class PoliciesGuard implements CanActivate { constructor(private caslAbilityFactory: CaslAbilityFactory, private reflector: Reflector) {} async canActivate(context: ExecutionContext): Promise<boolean> { const policyHandlers = this.reflector.get<PolicyHandler[]>(CHECK_POLICIES_KEY, context.getHandler()); const { user } = context.switchToHttp().getRequest(); const ability = this.caslAbilityFactory.createForUser(user); return policyHandlers.every(handler => handler(ability)); } }
API Keys
API keys provide simple authentication for service-to-service communication or public APIs—typically implemented as a custom guard that validates keys against stored hashes.
// api-key.guard.ts @Injectable() export class ApiKeyGuard implements CanActivate { constructor(private apiKeyService: ApiKeyService) {} async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); const apiKey = request.headers['x-api-key']; if (!apiKey) return false; const keyRecord = await this.apiKeyService.validate(apiKey); if (!keyRecord || keyRecord.revoked || keyRecord.expiresAt < new Date()) { return false; } request.apiClient = keyRecord.client; // Attach client info await this.apiKeyService.recordUsage(keyRecord.id); return true; } } // Controller @Get('data') @UseGuards(ApiKeyGuard) getData(@Req() req) { console.log(`Request from: ${req.apiClient.name}`); }
Multi-factor Authentication
MFA adds a second verification layer beyond passwords, typically using TOTP (Time-based One-Time Passwords) via apps like Google Authenticator—dramatically improves security posture.
// mfa.service.ts import * as speakeasy from 'speakeasy'; import * as qrcode from 'qrcode'; @Injectable() export class MfaService { generateSecret(user: User) { const secret = speakeasy.generateSecret({ name: `MyApp (${user.email})`, issuer: 'MyApp', }); return { secret: secret.base32, otpauthUrl: secret.otpauth_url }; } async generateQrCode(otpauthUrl: string): Promise<string> { return qrcode.toDataURL(otpauthUrl); } verifyToken(secret: string, token: string): boolean { return speakeasy.totp.verify({ secret, encoding: 'base32', token, window: 1, // Allow 30s clock skew }); } } // Login flow with MFA async login(credentials: LoginDto) { const user = await this.validateCredentials(credentials); if (user.mfaEnabled) { return { requiresMfa: true, tempToken: this.createTempToken(user) }; } return this.issueTokens(user); }
Refresh Token Rotation
Refresh token rotation issues a new refresh token with each access token refresh, invalidating the old one—this limits the damage window if a refresh token is compromised.
// auth.service.ts @Injectable() export class AuthService { async refreshTokens(refreshToken: string) { const payload = this.jwtService.verify(refreshToken, { secret: process.env.REFRESH_SECRET }); // Check if token exists and not revoked const storedToken = await this.tokenRepo.findOne({ where: { token: this.hash(refreshToken), revoked: false } }); if (!storedToken) throw new UnauthorizedException('Invalid refresh token'); // Revoke old token await this.tokenRepo.update(storedToken.id, { revoked: true }); // Issue new token pair const user = await this.usersService.findById(payload.sub); const newAccessToken = this.createAccessToken(user); const newRefreshToken = this.createRefreshToken(user); // Store new refresh token await this.tokenRepo.save({ userId: user.id, token: this.hash(newRefreshToken), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }); return { accessToken: newAccessToken, refreshToken: newRefreshToken }; } }
┌────────┐ ┌────────┐
│ Client │ │ Server │
└───┬────┘ └───┬────┘
│ │
│───── Access Token (expired) ─────────►│
│◄──── 401 Unauthorized ────────────────│
│ │
│───── Refresh Token (RT1) ────────────►│
│ │ │
│ │ ┌─────────────────────┐ │
│ │ │ 1. Verify RT1 │ │
│ │ │ 2. Revoke RT1 │ │
│ │ │ 3. Generate RT2 │ │
│ │ │ 4. Store RT2 │ │
│ │ └─────────────────────┘ │
│ │
│◄──── New Access + Refresh (RT2) ──────│