Welcome to Chapter 11 of our Node.js backend journey! In this chapter, we’re diving deep into critical security enhancements that are non-negotiable for any production-ready application: Rate Limiting, Cross-Origin Resource Sharing (CORS), and Role-Based Access Control (RBAC). These mechanisms are essential for protecting your API from abuse, enabling secure interactions with frontend applications, and ensuring users only access resources they are authorized to see.

By the end of this chapter, you will have fortified your Fastify application with robust defenses. We’ll implement measures to prevent brute-force attacks and denial-of-service attempts through rate limiting, configure secure communication channels for client-side applications using CORS, and establish a granular permission system with RBAC to control access based on user roles. This incremental approach will ensure each security layer is properly integrated and tested, bringing us closer to a truly production-grade backend.

Prerequisites

Before starting this chapter, ensure you have completed the previous chapters, especially those covering:

  • Chapter 9: Authentication & Authorization: Where we set up user registration, login, JWT token handling, and basic authorization.
  • Chapter 10: Session & Token Handling: Where we refined JWT handling and introduced refresh tokens.

We will build upon the existing authentication context, assuming request.user contains authenticated user information, including their assigned roles.

Planning & Design

Implementing security features requires careful consideration of where they fit within the request lifecycle and how they interact with existing components.

Component Architecture

The security features we’re implementing will act as middleware or plugins within our Fastify application’s request pipeline.

  • CORS will typically be applied early to handle pre-flight requests and set appropriate headers.
  • Rate Limiting will also be applied early, often before authentication, to prevent unauthenticated abuse.
  • RBAC will be applied at the route level, after authentication, to check user roles against required permissions for specific endpoints.
flowchart TD Client[Client Request] --> FastifyApp[Fastify Application] subgraph Fastify Application FastifyApp --> CORS[CORS Plugin] CORS --> RateLimit[Rate Limiting Plugin] RateLimit --> Authentication[Authentication Middleware/Hook] Authentication --> RBAC[RBAC Hook/Decorator] RBAC --> RouteHandler[Route Handler] RouteHandler --> Database[(Database)] end Database --> RouteHandler RouteHandler --> Response[Response] Response --> Client

File Structure

We’ll integrate these features as Fastify plugins or decorators, maintaining a clean and modular project structure.

  • src/plugins/cors.ts: For CORS configuration.
  • src/plugins/rate-limit.ts: For rate limiting configuration.
  • src/plugins/rbac.ts: For our custom RBAC implementation.
  • src/routes/...: Existing routes will be modified to incorporate RBAC.

Step-by-Step Implementation

Let’s begin by enhancing our application’s security, one layer at a time.

1. Implementing Rate Limiting

Rate limiting is crucial for preventing various forms of abuse, such as brute-force attacks, spamming, and denial-of-service (DoS) attacks. We’ll use the @fastify/rate-limit plugin.

a) Setup/Configuration

First, install the plugin:

npm install @fastify/rate-limit
npm install --save-dev @types/fastify__rate-limit # If using TypeScript

Next, let’s create a plugin for rate limiting.

File: src/plugins/rate-limit.ts

import fp from 'fastify-plugin';
import rateLimit from '@fastify/rate-limit';
import { FastifyInstance } from 'fastify';
import { AppConfig } from '../config';

/**
 * @fastify/rate-limit plugin for Fastify.
 * Configures rate limiting for the application.
 */
export default fp(async (fastify: FastifyInstance) => {
  const { RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_TIME_WINDOW_MS } = AppConfig;

  fastify.register(rateLimit, {
    max: RATE_LIMIT_MAX_REQUESTS,       // Max requests per time window
    timeWindow: RATE_LIMIT_TIME_WINDOW_MS, // Time window in milliseconds (e.g., 60000 for 1 minute)
    hook: 'preHandler',                 // Where to apply the hook (before route handlers)
    // Optional: Add a key generator if you want to limit based on something other than IP
    // keyGenerator: (request) => request.headers['x-forwarded-for'] || request.ip,
    errorResponseBuilder: (request, context) => {
      fastify.log.warn(`Rate limit exceeded for IP: ${request.ip} on route: ${request.url}`);
      return {
        statusCode: 429,
        error: 'Too Many Requests',
        message: `You have exceeded the request limit of ${context.max} requests in ${context.timeWindow}ms. Please try again after some time.`,
        retryAfter: context.after, // Time in seconds to wait before retrying
      };
    },
    // For production, consider using a store like Redis for distributed rate limiting
    // store: new RedisStore({ client: new Redis(...) })
  });

  fastify.log.info(`Rate Limiting enabled: Max ${RATE_LIMIT_MAX_REQUESTS} requests per ${RATE_LIMIT_TIME_WINDOW_MS / 1000} seconds.`);
});

We need to add these configuration variables to our src/config.ts and .env files.

File: src/config.ts (add to AppConfig interface and AppConfig object)

// ... existing imports and interfaces

interface IAppConfig {
  // ... existing fields
  RATE_LIMIT_MAX_REQUESTS: number;
  RATE_LIMIT_TIME_WINDOW_MS: number;
}

export const AppConfig: IAppConfig = {
  // ... existing fields
  PORT: parseInt(process.env.PORT || '3000', 10),
  NODE_ENV: process.env.NODE_ENV || 'development',
  LOG_LEVEL: process.env.LOG_LEVEL || 'info',
  JWT_SECRET: process.env.JWT_SECRET || 'supersecretjwtkey',
  JWT_ACCESS_TOKEN_EXPIRATION: process.env.JWT_ACCESS_TOKEN_EXPIRATION || '15m',
  JWT_REFRESH_TOKEN_EXPIRATION: process.env.JWT_REFRESH_TOKEN_EXPIRATION || '7d',
  RATE_LIMIT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
  RATE_LIMIT_TIME_WINDOW_MS: parseInt(process.env.RATE_LIMIT_TIME_WINDOW_MS || '60000', 10), // 1 minute
  // ... other fields
};

File: .env (add these lines)

# ... existing environment variables
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_TIME_WINDOW_MS=60000 # 1 minute

Finally, register the plugin in our main application file.

File: src/app.ts (add import and registration)

// ... existing imports
import rateLimitPlugin from './plugins/rate-limit'; // Add this import

// ... inside the buildApp function
export function buildApp() {
  const fastify = Fastify({
    logger: PinoLogger,
    disableRequestLogging: true, // Disable default request logging if using pino-http
  });

  // Register plugins
  fastify.register(SwaggerPlugin);
  fastify.register(SensiblePlugin);
  fastify.register(HelmetPlugin);
  fastify.register(rateLimitPlugin); // Register rate limit plugin here
  // ... other plugins
b) Core Implementation Explanation
  • We’re using fp (fastify-plugin) to make sure our plugin is properly encapsulated and registered.
  • max: This defines the maximum number of requests allowed within the timeWindow.
  • timeWindow: This defines the duration in milliseconds for which the max requests are counted. Here, 60000ms means 1 minute.
  • hook: 'preHandler': This ensures the rate limiting check happens before any route handler is executed.
  • errorResponseBuilder: This customizes the error response sent to the client when the rate limit is exceeded. It also logs the event, which is vital for monitoring.
  • keyGenerator: (Commented out) By default, @fastify/rate-limit uses request.ip. If your application is behind a proxy, you might need to use request.headers['x-forwarded-for'] to get the real client IP. Ensure your proxy is configured to set this header correctly and securely.
  • For production, especially with multiple instances, you’d use a distributed store like Redis (@fastify/rate-limit-redis) to ensure consistent rate limiting across all instances.
c) Testing This Component
  1. Start your Fastify application: npm run dev
  2. Use a tool like curl or Postman to make rapid requests to any endpoint, e.g., GET /api/v1/health.
  3. Make more than RATE_LIMIT_MAX_REQUESTS requests within RATE_LIMIT_TIME_WINDOW_MS.
  4. You should receive a 429 Too Many Requests status code with the custom error message configured.
  5. Check your server logs for the Rate limit exceeded warning.

Example curl loop (on Linux/macOS):

for i in $(seq 1 101); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/health; done

(Adjust 101 to be RATE_LIMIT_MAX_REQUESTS + 1)

The first RATE_LIMIT_MAX_REQUESTS requests should return 200, and subsequent requests within the window should return 429.

2. Implementing CORS (Cross-Origin Resource Sharing)

CORS is a security feature implemented by web browsers that restricts web pages from making requests to a different domain than the one that served the web page. This prevents malicious scripts from making unauthorized requests to your API. We’ll use @fastify/cors.

a) Setup/Configuration

Install the plugin:

npm install @fastify/cors
npm install --save-dev @types/fastify__cors # If using TypeScript

Now, create a plugin for CORS.

File: src/plugins/cors.ts

import fp from 'fastify-plugin';
import cors from '@fastify/cors';
import { FastifyInstance } from 'fastify';
import { AppConfig } from '../config';

/**
 * @fastify/cors plugin for Fastify.
 * Configures Cross-Origin Resource Sharing (CORS) for the application.
 */
export default fp(async (fastify: FastifyInstance) => {
  const { ALLOWED_ORIGINS, NODE_ENV } = AppConfig;

  // Split comma-separated origins, trim whitespace, and filter out empty strings
  const origins = ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(o => o);

  fastify.register(cors, {
    origin: (origin, callback) => {
      // Allow requests with no origin (like mobile apps or curl requests)
      if (!origin) {
        callback(null, true);
        return;
      }

      // In development, allow all origins for easier testing
      if (NODE_ENV === 'development') {
        callback(null, true);
        return;
      }

      // In production, check against the list of allowed origins
      if (origins.includes(origin)) {
        callback(null, true);
      } else {
        fastify.log.warn(`CORS: Origin ${origin} not allowed.`);
        callback(new Error('Not allowed by CORS'), false);
      }
    },
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], // Allowed HTTP methods
    allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization', 'Refresh-Token'], // Headers allowed in requests
    credentials: true, // Allow cookies, authorization headers, etc. to be sent
  });

  fastify.log.info(`CORS enabled. Allowed origins: ${NODE_ENV === 'development' ? 'ALL (development mode)' : origins.join(', ')}`);
});

We need to add ALLOWED_ORIGINS to our src/config.ts and .env files.

File: src/config.ts (add to AppConfig interface and AppConfig object)

// ... existing imports and interfaces

interface IAppConfig {
  // ... existing fields
  ALLOWED_ORIGINS: string;
}

export const AppConfig: IAppConfig = {
  // ... existing fields
  ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS || 'http://localhost:4200,http://localhost:3000', // Default for development
  // ... other fields
};

File: .env (add these lines)

# ... existing environment variables
ALLOWED_ORIGINS=http://localhost:4200,http://localhost:3000,https://your-production-frontend.com

Finally, register the CORS plugin in our main application file. Ensure it’s registered early, typically before other route handlers or authentication.

File: src/app.ts (add import and registration)

// ... existing imports
import corsPlugin from './plugins/cors'; // Add this import

// ... inside the buildApp function
export function buildApp() {
  const fastify = Fastify({
    logger: PinoLogger,
    disableRequestLogging: true,
  });

  // Register plugins
  fastify.register(SwaggerPlugin);
  fastify.register(SensiblePlugin);
  fastify.register(HelmetPlugin);
  fastify.register(corsPlugin); // Register CORS plugin here, ideally before rate limit
  fastify.register(rateLimitPlugin);
  // ... other plugins
b) Core Implementation Explanation
  • origin: This is the most critical part.
    • In development mode, we allow all origins (callback(null, true)) for ease of development. Never do this in production.
    • In production, we parse a comma-separated list of allowed origins from ALLOWED_ORIGINS environment variable. The origin function then checks if the incoming request’s origin is in this list.
    • !origin: Allows requests without an Origin header (e.g., curl or server-to-server requests).
  • methods: Specifies which HTTP methods are allowed for cross-origin requests.
  • allowedHeaders: Lists headers that can be used in the actual request. Important for sending Authorization headers with JWTs.
  • credentials: true: This is vital if your frontend needs to send cookies (e.g., HttpOnly refresh tokens) or Authorization headers. It signals to the browser that the actual request can include user credentials.
c) Testing This Component
  1. Start your Fastify application: npm run dev
  2. Test an allowed origin:
    • Use curl with an Origin header from your ALLOWED_ORIGINS list:
      curl -H "Origin: http://localhost:4200" http://localhost:3000/api/v1/health -v
      
    • You should see Access-Control-Allow-Origin: http://localhost:4200 (or * in dev) in the response headers.
  3. Test a disallowed origin (in production mode or with a specific origin list):
    • Change NODE_ENV to production in your .env for this test, or ensure http://some-bad-domain.com is not in ALLOWED_ORIGINS.
    • curl -H "Origin: http://some-bad-domain.com" http://localhost:3000/api/v1/health -v
      
    • You should see a CORS error in your browser console if using a frontend, or potentially no Access-Control-Allow-Origin header from the server and a 403 or similar error from the client’s perspective (though curl won’t enforce browser CORS). The server logs should show CORS: Origin ... not allowed.

3. Implementing RBAC (Role-Based Access Control)

RBAC ensures that authenticated users can only access resources and perform actions that are permitted by their assigned roles. We’ll create a Fastify decorator that checks for required roles on specific routes.

a) Setup/Configuration

We’ll assume that our request.user object (set by the authentication middleware from previous chapters) contains a roles array (e.g., ['ADMIN', 'USER']).

Let’s define our roles centrally.

File: src/constants/roles.ts

export enum UserRole {
  ADMIN = 'ADMIN',
  EDITOR = 'EDITOR',
  USER = 'USER',
  GUEST = 'GUEST', // For unauthenticated but known users, if applicable
}

Now, create a Fastify plugin/decorator for RBAC.

File: src/plugins/rbac.ts

import fp from 'fastify-plugin';
import { FastifyInstance, FastifyRequest, FastifyReply, HookHandlerDoneFunction } from 'fastify';
import { UserRole } from '../constants/roles'; // Import our roles

// Extend FastifyRequest to include the user property from authentication
declare module 'fastify' {
  interface FastifyRequest {
    user?: {
      id: string;
      email: string;
      roles: UserRole[]; // Assumes roles are an array of UserRole
    };
  }
}

/**
 * RBAC (Role-Based Access Control) plugin for Fastify.
 * Provides a decorator `hasRole` to enforce role-based access on routes.
 */
export default fp(async (fastify: FastifyInstance) => {

  /**
   * Decorator function to check if the authenticated user has any of the required roles.
   * @param requiredRoles An array of roles that are allowed to access the route.
   * @returns A preHandler hook function.
   */
  fastify.decorate('hasRole', function (requiredRoles: UserRole[]) {
    return async (request: FastifyRequest, reply: FastifyReply) => {
      // 1. Ensure user is authenticated
      if (!request.user) {
        fastify.log.warn(`RBAC: Unauthorized access attempt to ${request.url} - no user context.`);
        throw fastify.httpErrors.unauthorized('Authentication required to access this resource.');
      }

      // 2. Check if user has any of the required roles
      const userRoles = request.user.roles || [];
      const hasPermission = requiredRoles.some(role => userRoles.includes(role));

      if (!hasPermission) {
        fastify.log.warn(`RBAC: Forbidden access attempt by user ${request.user.id} (roles: ${userRoles.join(', ')}) to ${request.url}. Required roles: ${requiredRoles.join(', ')}`);
        throw fastify.httpErrors.forbidden('You do not have the necessary permissions to access this resource.');
      }

      // If checks pass, continue to the route handler
    };
  });

  fastify.log.info('RBAC plugin loaded. `hasRole` decorator available.');
});

Now, register this RBAC plugin in src/app.ts. It should be registered after authentication, as it depends on request.user.

File: src/app.ts (add import and registration)

// ... existing imports
import rbacPlugin from './plugins/rbac'; // Add this import

// ... inside the buildApp function
export function buildApp() {
  const fastify = Fastify({
    logger: PinoLogger,
    disableRequestLogging: true,
  });

  // Register plugins (order matters for some)
  fastify.register(SwaggerPlugin);
  fastify.register(SensiblePlugin);
  fastify.register(HelmetPlugin);
  fastify.register(corsPlugin);
  fastify.register(rateLimitPlugin);
  fastify.register(AuthPlugin); // Assuming this sets request.user
  fastify.register(rbacPlugin); // Register RBAC AFTER AuthPlugin
  // ... other plugins
b) Core Implementation Explanation
  • declare module 'fastify': This is crucial for TypeScript. It tells TypeScript that the FastifyRequest interface now has an optional user property, which our authentication plugin populates.
  • fastify.decorate('hasRole', ...): We’re using Fastify’s decorator pattern to add a new utility function, hasRole, to the fastify instance. This function returns a preHandler hook.
  • hasRole(requiredRoles: UserRole[]): This function takes an array of roles that are allowed to access the current route.
  • Authentication Check: if (!request.user) ensures that the user is authenticated before checking roles. If not, it throws an unauthorized error.
  • Role Check: requiredRoles.some(role => userRoles.includes(role)) checks if the user’s roles array contains at least one of the requiredRoles. This implies an OR condition (e.g., if requiredRoles is [ADMIN, EDITOR], a user with ADMIN role or EDITOR role can access).
  • Forbidden Error: If no matching role is found, it throws a forbidden error, providing clear feedback to the client and logging the attempt.
c) Applying RBAC to Routes

Now, let’s update some of our routes to use this hasRole decorator. For example, let’s imagine an admin endpoint.

First, ensure your AuthPlugin (from Chapter 9/10) correctly populates request.user.roles. For demonstration, let’s assume AuthPlugin is properly parsing JWTs and setting request.user.

File: src/routes/user.routes.ts (Example modification)

import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { UserRole } from '../constants/roles'; // Import UserRole

// Extend FastifyInstance to include the hasRole decorator
declare module 'fastify' {
  interface FastifyInstance {
    hasRole: (requiredRoles: UserRole[]) => (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
  }
}

export async function userRoutes(fastify: FastifyInstance) {

  // Example: Get current user profile (requires USER role or higher, e.g., ADMIN)
  fastify.get('/profile', {
    preHandler: [fastify.authenticate, fastify.hasRole([UserRole.ADMIN, UserRole.USER])],
    handler: async (request: FastifyRequest, reply: FastifyReply) => {
      // request.user is guaranteed to exist here due to fastify.authenticate
      fastify.log.info(`User ${request.user!.id} accessed profile.`);
      return reply.send({ user: request.user });
    },
    schema: {
      tags: ['User'],
      summary: 'Get current user profile',
      security: [{ bearerAuth: [] }],
      response: {
        200: {
          type: 'object',
          properties: {
            user: {
              type: 'object',
              properties: {
                id: { type: 'string' },
                email: { type: 'string' },
                roles: { type: 'array', items: { type: 'string', enum: Object.values(UserRole) } },
              },
            },
          },
        },
        401: fastify.getSchema('401'), // Unauthorized
        403: fastify.getSchema('403'), // Forbidden
      },
    },
  });

  // Example: Admin-only endpoint to list all users
  fastify.get('/admin/users', {
    preHandler: [fastify.authenticate, fastify.hasRole([UserRole.ADMIN])], // Only ADMIN can access
    handler: async (request: FastifyRequest, reply: FastifyReply) => {
      // In a real app, you'd fetch users from the database
      fastify.log.info(`Admin user ${request.user!.id} listing all users.`);
      return reply.send({ message: 'List of all users (admin-only data)' });
    },
    schema: {
      tags: ['Admin'],
      summary: 'List all users (Admin only)',
      security: [{ bearerAuth: [] }],
      response: {
        200: {
          type: 'object',
          properties: {
            message: { type: 'string' },
          },
        },
        401: fastify.getSchema('401'),
        403: fastify.getSchema('403'),
      },
    },
  });
}
d) Testing This Component
  1. Ensure your AuthPlugin sets roles correctly. For testing, you might temporarily hardcode roles in your JWT payload or during user creation.
    • Create a user with ADMIN role (e.g., during seeding or by manual DB update).
    • Create a user with USER role.
  2. Start your Fastify application: npm run dev
  3. Test /api/v1/user/profile:
    • Log in as a USER. Get the access token.
    • Make a GET request to /api/v1/user/profile with the Authorization: Bearer <token> header. It should return 200 OK with the user profile.
    • Log in as an ADMIN. Get the access token.
    • Make a GET request to /api/v1/user/profile with the Authorization: Bearer <token> header. It should also return 200 OK.
  4. Test /api/v1/user/admin/users:
    • Log in as a USER. Get the access token.
    • Make a GET request to /api/v1/user/admin/users with the Authorization: Bearer <token> header. It should return 403 Forbidden with the message “You do not have the necessary permissions…”.
    • Check server logs for the RBAC: Forbidden access attempt warning.
    • Log in as an ADMIN. Get the access token.
    • Make a GET request to /api/v1/user/admin/users with the Authorization: Bearer <token> header. It should return 200 OK with the admin message.

Production Considerations

  • Rate Limiting:
    • Distributed Stores: For multi-instance deployments (e.g., AWS ECS), use a shared store like Redis for rate limiting to ensure consistent limits across all instances. @fastify/rate-limit-redis is a good option.
    • Configuration: Tune max and timeWindow based on expected traffic and abuse patterns. Different endpoints might need different limits.
    • Monitoring: Monitor 429 responses and rate limit logs to detect attacks or misconfigured clients.
  • CORS:
    • Strict Origins: In production, ALLOWED_ORIGINS must be strictly defined to only include your legitimate frontend domains. Avoid * at all costs.
    • Pre-flight Caching: Browsers cache pre-flight OPTIONS responses. Set Access-Control-Max-Age header via fastify-cors options to improve performance by reducing OPTIONS requests.
  • RBAC:
    • Granularity: For complex applications, consider more granular permissions (e.g., canCreateUser, canDeleteProduct) rather than just roles. Roles can then be collections of permissions.
    • Performance: RBAC checks add a small overhead. Ensure your role storage and retrieval are efficient.
    • Auditing: Log all attempts at unauthorized access for security auditing and incident response.
    • Data Consistency: Ensure roles are consistently managed across your user management system and database.

Code Review Checkpoint

At this point, your application has significantly enhanced security features.

Files Created/Modified:

  • src/plugins/rate-limit.ts: Implemented global rate limiting.
  • src/plugins/cors.ts: Configured CORS for secure cross-origin communication.
  • src/plugins/rbac.ts: Developed a custom RBAC decorator for role-based access control.
  • src/constants/roles.ts: Defined UserRole enum.
  • src/config.ts: Added environment variables for rate limit and CORS origins.
  • .env: Updated with new environment variables.
  • src/app.ts: Registered the new plugins.
  • src/routes/user.routes.ts: Modified to apply RBAC to specific routes.
  • package.json / package-lock.json: Added @fastify/rate-limit, @fastify/cors and their types.

Integration:

  • Rate limiting is applied globally to all requests, protecting the API from excessive traffic.
  • CORS is configured to allow only specified origins to interact with the API, enhancing browser-based security.
  • RBAC decorator (fastify.hasRole) is available for use in any route preHandler chain, ensuring that only users with appropriate roles can access sensitive endpoints. This relies on the request.user object being populated by the authentication plugin.

Common Issues & Solutions

  1. Issue: “Too Many Requests” (429) even with few requests.

    • Cause: The RATE_LIMIT_MAX_REQUESTS or RATE_LIMIT_TIME_WINDOW_MS might be set too low, or you’re testing with a keyGenerator that isn’t distinguishing users/IPs correctly (e.g., behind a proxy without x-forwarded-for).
    • Solution:
      • Adjust RATE_LIMIT_MAX_REQUESTS and RATE_LIMIT_TIME_WINDOW_MS in .env to reasonable values.
      • If behind a proxy, ensure request.headers['x-forwarded-for'] is correctly used in keyGenerator and your proxy is configured to pass the real IP.
      • Check Fastify logs for rate limit warnings; they often contain the IP that triggered it.
  2. Issue: CORS error in browser console: “No ‘Access-Control-Allow-Origin’ header is present…”

    • Cause: Your frontend’s origin is not included in ALLOWED_ORIGINS in your .env file (for production builds) or your corsPlugin logic is incorrect.
    • Solution:
      • Verify that ALLOWED_ORIGINS in your .env file (and consequently AppConfig.ALLOWED_ORIGINS) explicitly lists the full URL of your frontend application (e.g., http://localhost:4200 or https://your-app.com).
      • Ensure credentials: true is set in the CORS plugin options if your frontend needs to send Authorization headers or cookies.
      • Check server logs for CORS: Origin ... not allowed. warnings.
      • Ensure the corsPlugin is registered early in src/app.ts.
  3. Issue: 403 Forbidden when trying to access an RBAC-protected route, even with an authenticated user.

    • Cause: The authenticated user’s request.user.roles array does not contain any of the requiredRoles specified for the route, or request.user is not being populated correctly by your authentication plugin.
    • Solution:
      • Verify request.user: Temporarily log request.user in your authenticate plugin or directly in the RBAC preHandler to ensure it contains the expected roles array.
      • Check requiredRoles: Double-check the UserRole enum values and ensure the roles specified in fastify.hasRole([UserRole.ADMIN]) match the actual roles assigned to your test user.
      • User Data: Confirm that your user creation or seeding process assigns the correct roles to users in the database.
      • Order of Plugins: Ensure AuthPlugin is registered before rbacPlugin in src/app.ts.

Testing & Verification

  1. Start the Application: npm run dev
  2. Health Check: Access GET /api/v1/health.
    • Verify rate limiting: Make rapid requests. The first few should pass (200), subsequent ones should fail with 429 Too Many Requests.
    • Verify CORS (using curl with Origin header): An allowed origin should receive Access-Control-Allow-Origin in headers. A disallowed origin (if NODE_ENV=production) should not.
  3. User Authentication & RBAC:
    • Register/Login as a USER: Obtain JWT access token.
    • Access User Profile (GET /api/v1/user/profile):
      • Using the USER token: Should return 200 OK.
      • Using an invalid/expired token: Should return 401 Unauthorized.
    • Access Admin Endpoint (GET /api/v1/user/admin/users):
      • Using the USER token: Should return 403 Forbidden.
    • Register/Login as an ADMIN: Obtain JWT access token.
    • Access Admin Endpoint (GET /api/v1/user/admin/users):
      • Using the ADMIN token: Should return 200 OK.
  4. Logging: Review your application logs (npm run dev output) to ensure warnings for rate limit hits, CORS rejections, and forbidden RBAC access attempts are correctly recorded.

Summary & Next Steps

In this chapter, we significantly strengthened our Node.js Fastify API’s security posture. We successfully implemented:

  • Rate Limiting: To protect against abuse and DoS attacks.
  • CORS: To enable secure communication with specific frontend applications.
  • RBAC: To enforce granular access control based on user roles, ensuring users only access authorized resources.

These features are foundational for building robust and secure web applications and are critical for production environments.

In the next chapter, Chapter 12: File Upload & Static File Serving, we’ll explore how to handle file uploads securely and efficiently, and how to serve static assets directly from our Fastify application. This is a common requirement for many applications, from user profile pictures to document storage.