Leveraging NestJS Guards with Custom Decorators: JWT Validation, Role-Based Access, and Rate Limiting

Wiljeder FilhoWiljeder Filho
6 min read

Guards in NestJS are an essential part of the authentication and authorization pipeline. They allow us to control access to specific routes based on various conditions, such as authentication, roles, and request limits.

In this article, we'll explore three custom guards and custom decorators that will enhance your NestJS applications:

  1. JWT Token Validation Guard – Validates the JWT and injects user data into the request.

  2. Role-Based Access Control (RBAC) Guard – Manages access based on user roles, supporting both role hierarchy and strict role matching.

  3. Rate Limiting Guard – Restricts request rates based on a user’s subscription plan.

Each of these guards will work with custom decorators, making it easy to use them throughout your application.

1. JWT Authentication Guard

Before we enforce role-based access and rate limiting, we need a way to extract the user data from a JWT token. This JWTAuthGuard will:

  • Validate the JWT token using a secret stored in the environment. We’ll use ConfigService to access it.

  • Decode the token and attach the user data to the request object.

JWT Guard Implementation

Create a Custom Decorator

We'll create a @User() decorator to easily access user data inside route handlers.

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const User = createParamDecorator(
  (data: keyof any, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return data ? request.user?.[data] : request.user;
  },
);

Implement the JWT Guard

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import * as jwt from 'jsonwebtoken';

@Injectable()
export class JWTAuthGuard implements CanActivate {
  constructor(private configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    const token = request.headers.authorization?.split(' ')[1];

    if (!token) {
      throw new UnauthorizedException('No token provided');
    }

    try {
      const secret = this.configService.get<string>('JWT_SECRET');
      const decoded = jwt.verify(token, secret);
      request.user = decoded; // Attach user data to request
      return true;
    } catch {
      throw new UnauthorizedException('Invalid token');
    }
  }
}

When we assign request.user = decoded in our JWT Guard, TypeScript throws an error:

"Property 'user' does not exist on type 'Request'."

This happens because the Request object from Express does not include a user property by default. Since we are adding it manually, we need to extend the Request type.

Solution: Extend the Request Interface

Create a new file types/express.d.ts and add the following:

typescriptCopiarEditarimport { Request } from 'express';

declare module 'express' {
  export interface Request {
    user?: any; // Define `user` property (Modify type as needed)
  }
}

Now, TypeScript will recognize request.user as a valid property in our guards.

Usage in a Controller

import { Controller, Get, UseGuards } from '@nestjs/common';
import { User } from 'src/decorators/user.decorator';
import { JWTAuthGuard } from 'src/guards/jwt-auth.guard';

@Controller('user')
export class UserController {
  @Get()
  @UseGuards(JWTAuthGuard)
  getUserProfile(@User() user) {
    return { message: 'User profile data', user };
  }
}

2. Role-Based Access Control (RBAC) Guard

This guard will:

  • Check if the user has the required role.

  • Support role hierarchy (higher roles can access lower-role endpoints).

  • Support strict role matching (only users with the exact role can access).

Custom Decorator

This is used to attach metadata to route handlers, which the RBAC guard can later read and enforce access control.

import { SetMetadata } from '@nestjs/common';

export const Roles = (roles: string[], strict = false) =>
  SetMetadata('roles', { roles, strict });

Role Hierarchy

Define a role hierarchy, where higher roles inherit permissions from lower roles.

export const ROLE_HIERARCHY = {
  admin: ['admin', 'manager', 'member'],
  manager: ['manager', 'member'],
  member: ['member'],
};

Implement the RBAC Guard

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLE_HIERARCHY } from 'src/constants/role-hierarchy';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const { roles, strict } = this.reflector.get<{
      roles: string[];
      strict: boolean;
    }>('roles', context.getHandler()) || { roles: [], strict: false };

    if (!roles.length) return true;

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user || !user.role) {
      throw new ForbiddenException('User role is required');
    }

    const userRoles = strict ? [user.role] : ROLE_HIERARCHY[user.role] || [];

    if (!roles.some((role) => userRoles.includes(role))) {
      throw new ForbiddenException('Access denied');
    }

    return true;
  }
}

Usage in a Controller

import { Controller, Get, UseGuards } from '@nestjs/common';
import { Roles } from 'src/decorators/roles.decorator';
import { JWTAuthGuard } from 'src/guards/jwt-auth.guard';
import { RolesGuard } from 'src/guards/roles.guard';
import { ConfigService } from '@nestjs/config';

@Controller('admin')
export class AdminController {
  constructor(private readonly configService: ConfigService) {}

  @Get('dashboard')
  @UseGuards(JWTAuthGuard, RolesGuard)
  @Roles(['manager']) // Role hierarchy applies (admin can also access)
  getManagerDashboard() {
    return { message: 'Manager dashboard' };
  }

  @Get('settings')
  @UseGuards(JWTAuthGuard, RolesGuard)
  @Roles(['manager'], true) // Strict role match (only manager allowed)
  getStrictManagerSettings() {
    return { message: 'Manager-only settings' };
  }
}

3. Rate Limiting Guard

This guard will:

  • Limit API calls based on the user’s subscription plan.

  • Use in-memory tracking for simplicity (Redis or DB could be used for persistence).

  • We’ll use a Sliding Window Counter approach, which is a more production-friendly rate-limiting method.

Plan-Based Rate Limits

export const RATE_LIMITS = {
  anonymous: { limit: 5, window: 60 }, // 5 requests per minute
  free: { limit: 15, window: 60 },
  pro: { limit: 120, window: 60 },
  enterprise: { limit: 600, window: 60 },
};

Rate Limiting Guard Implementation (Sliding Window Algorithm)

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  HttpException,
} from '@nestjs/common';
import { Request } from 'express';
import { RATE_LIMITS } from 'src/constants/rate-limits';

const requestTimestamps = new Map<string, number[]>();

@Injectable()
export class RateLimitGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    const user = request.user;

    const userPlan = user?.plan || 'anonymous';
    const { limit, window } = RATE_LIMITS[userPlan];

    const userKey = user?.id
      ? `${user.id}-rate-limit`
      : `${request.ip}-rate-limit`;

    const currentTime = Date.now();
    const windowStart = currentTime - window * 1000;

    const timestamps = requestTimestamps.get(userKey) || [];
    const updatedTimestamps = timestamps.filter(
      (timestamp) => timestamp > windowStart,
    );

    if (updatedTimestamps.length >= limit) {
      const nextResetIn = Math.ceil(
        (updatedTimestamps[0] + window * 1000 - currentTime) / 1000,
      );

      throw new HttpException(
        `Rate limit exceeded. Try again in ${nextResetIn}s`,
        429,
      );
    }

    updatedTimestamps.push(currentTime);
    requestTimestamps.set(userKey, updatedTimestamps);

    return true;
  }
}

Usage in a Controller

import { Controller, Get, UseGuards } from '@nestjs/common';
import { JWTAuthGuard } from 'src/guards/jwt-auth.guard';
import { RateLimitGuard } from 'src/guards/rate-limit.guard';

@Controller('usage')
export class UsageController {
  @Get()
  @UseGuards(JWTAuthGuard, RateLimitGuard)
  checkUsage() {
    return { message: 'API usage information' };
  }
}

Final Thoughts

With these three custom guards, we now have:

  • JWT authentication to extract user data.

  • RBAC guard with role hierarchy and strict role enforcement.

  • Rate limiting guard based on user plans.

This approach keeps our authorization logic modular, reusable, and easy to maintain. 🚀

Feel free to check the implementation of this article in this github repository

0
Subscribe to my newsletter

Read articles from Wiljeder Filho directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Wiljeder Filho
Wiljeder Filho

Coding stuff