Skip to content
ExponaBLOG
NESTJS · DAY 033 / 100

Rate limiting with @nestjs/throttler: a practical guide

Protect login and public endpoints in minutes.

Expona Admin8 min read

Rate limiting with @nestjs/throttler: protecting your API in production

DDoS attacks, brute-force login attempts, and runaway clients can cripple your API faster than you'd expect. I've seen production services grind to a halt because a single endpoint lacked basic protection, and recovery was messy. Rate limiting with @nestjs/throttler is your first line of defense, and it takes minutes to set up correctly.

In this post, I'll walk you through implementing global rate limits, overriding them per route, and scaling to multiple instances with Redis storage. By the end, you'll have a bulletproof strategy for protecting login endpoints, public APIs, and internal health checks.

What is @nestjs/throttler and why it matters

@nestjs/throttler is NestJS's official rate-limiting module. It works by tracking requests per client (usually by IP or user ID) and rejecting traffic that exceeds your configured limit within a time window. Think of it as a bouncer at a club: let X people in per minute, block everyone else until the window resets.

Without rate limiting, a single attacker can consume all your database connections, exhaust memory, or trigger expensive operations thousands of times per second. With it, you sleep better knowing your API enforces reasonable usage boundaries.

Setting up global rate limits

Start by installing the module and configuring it at the application level. This sets a baseline policy that applies to every route unless you override it.

typescript
npm install @nestjs/throttler

In your app.module.ts, register the throttler module with a sensible global configuration:

typescript
import { Module } from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    ThrottlerModule.forRoot({
      ttl: 60,          // Time-to-live in seconds
      limit: 10,        // Max requests per ttl
      skipIfUnlimited: true,
    }),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

This configuration means: allow 10 requests per 60 seconds per client. The skipIfUnlimited: true flag lets you bypass throttling for specific routes (more on that later).

Now every route is protected. A client making 11 requests in 60 seconds gets a 429 (Too Many Requests) response. Simple, but often too lenient for sensitive operations like login.

Per-route overrides with @Throttle decorator

Login endpoints need tighter restrictions. A brute-force attack trying a thousand passwords per second should fail fast. Use the @Throttle decorator to override global limits on specific routes.

typescript
import { Controller, Post, Body } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('login')
  @Throttle({ default: { limit: 3, ttl: 300 } })
  async login(@Body() credentials: LoginDto) {
    return this.authService.validateCredentials(credentials);
  }

  @Post('register')
  @Throttle({ default: { limit: 2, ttl: 3600 } })
  async register(@Body() data: RegisterDto) {
    return this.authService.createUser(data);
  }
}

Here, /auth/login allows only 3 attempts per 5 minutes (300 seconds), and /auth/register allows 2 attempts per hour. These are aggressive enough to stop password-spraying attacks but forgiving for legitimate users who fat-finger their password.

The @Throttle decorator accepts a default key that maps to a named throttler configuration. If you're running multiple throttler instances (e.g., one for public routes, one for premium routes), you can name them and override each independently. For now, stick with default.

Skipping rate limits for internal endpoints with @SkipThrottle

Health checks, metrics endpoints, and internal admin routes shouldn't count against rate limits. Your Kubernetes orchestrator hammering /health every 10 seconds shouldn't trip throttling. Use @SkipThrottle to exempt routes.

typescript
import { Controller, Get } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { HealthService } from './health.service';

@Controller('health')
@SkipThrottle()
export class HealthController {
  constructor(private healthService: HealthService) {}

  @Get()
  async check() {
    return this.healthService.getStatus();
  }
}

@Controller('admin')
export class AdminController {
  @Get('metrics')
  @SkipThrottle()
  async getMetrics() {
    return { requests: 12000, errors: 45 };
  }
}

Applying @SkipThrottle() at the class level exempts all routes. At the method level, it exempts just that endpoint. I always skip health checks—they're not a security risk and you don't want false alarms when your infra probes surge.

Scaling with Redis storage for multiple instances

In production, your NestJS service runs on multiple pods. If Pod A allows 5 requests and Pod B allows 5 requests, a single client can make 10 requests by round-robin load balancing—circumventing your 5-request limit entirely. That's why you need shared state: Redis.

@nestjs/throttler supports multiple storage backends. By default it uses in-memory storage, which doesn't scale. Switch to Redis:

typescript
import { Module } from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nestjs/throttler';
import { createClient } from 'redis';
import { APP_GUARD } from '@nestjs/core';

const redisClient = createClient({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
});

@Module({
  imports: [
    ThrottlerModule.forRoot({
      ttl: 60,
      limit: 10,
      skipIfUnlimited: true,
      storage: new ThrottlerStorageRedisService(redisClient),
    }),
  ],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}

Now all instances share a single Redis store. When Pod A receives request 1, Redis increments the counter. When Pod B receives request 2, it reads the same counter from Redis. Once you hit the limit globally, all pods enforce it.

Make sure your Redis instance is highly available—use a managed Redis service (AWS ElastiCache, Azure Cache, or similar) or a Redis cluster. A single Redis node failing shouldn't take down your entire rate-limiting layer.

Real-world scenario: protecting a public API with tiered limits

Imagine you're running a weather API. Public users get 10 requests per minute, but premium subscribers get 100 per minute, and your internal dashboard gets unlimited access.

typescript
import { Controller, Get, Request, UseGuards } from '@nestjs/common';
import { Throttle, SkipThrottle } from '@nestjs/throttler';
import { AuthGuard } from '@nestjs/passport';
import { WeatherService } from './weather.service';

@Controller('weather')
export class WeatherController {
  constructor(private weatherService: WeatherService) {}

  @Get('current/:city')
  @Throttle({ default: { limit: 10, ttl: 60 } })
  async getCurrentWeather(@Param('city') city: string) {
    return this.weatherService.getCurrentWeather(city);
  }

  @Get('current/:city')
  @UseGuards(AuthGuard('jwt'))
  @Throttle({ default: { limit: 100, ttl: 60 } })
  async getCurrentWeatherPremium(
    @Param('city') city: string,
    @Request() req,
  ) {
    if (req.user.tier !== 'premium') {
      throw new ForbiddenException('Premium access required');
    }
    return this.weatherService.getCurrentWeather(city);
  }

  @Get('internal/stats')
  @UseGuards(AuthGuard('internal'))
  @SkipThrottle()
  async getStats() {
    return this.weatherService.getAggregateStats();
  }
}

This setup lets you offer a free tier with reasonable limits, monetize premium access with higher limits, and keep internal operations unrestricted. Throttling becomes part of your SLA strategy.

Common mistakes

Mistake 1: Forgetting to enable the guard globally

If you don't register ThrottlerGuard as an APP_GUARD, the module imports but never activates. Your routes won't be protected. Always add it to your AppModule providers.

Mistake 2: Using in-memory storage in production with multiple instances

The default storage is in-memory. It works fine locally and in single-instance deployments, but it's a security hole in Kubernetes. Clients exploit the lack of shared state by hitting different pods. Always use Redis (or another shared store) in production.

Mistake 3: Not handling 429 errors gracefully on the client

When a client hits the rate limit, they receive a 429 status. If your frontend doesn't handle this, users see errors. Implement exponential backoff and inform users they're rate-limited. Never hammer the endpoint faster.

Mistake 4: Setting limits too strict

A 5-request-per-minute global limit will frustrate legitimate users. Know your typical traffic patterns. Monitor your API in the first week and adjust. Start permissive and tighten only after you see abuse patterns.

Mistake 5: Not excluding monitoring endpoints

Forget to skip throttling on health checks, and your observability pipeline triggers false alerts when your monitoring system hits the limit. Always use @SkipThrottle() on internal endpoints.

Key takeaways

  • Start with global limits. Register ThrottlerModule with a reasonable baseline (e.g., 10 requests per 60 seconds) to protect every route by default.
  • Override per route with @Throttle. Apply tighter limits to sensitive endpoints like login (3 per 5 minutes) and register (2 per hour).
  • Skip throttling for internal endpoints. Use @SkipThrottle() on health checks, metrics, and internal admin routes to avoid false positives.
  • Use Redis in production. In-memory storage doesn't scale across multiple instances. Switch to Redis storage as soon as you move to a multi-pod deployment.
  • Monitor and adjust. Rate limits are not set-and-forget. Track 429 responses and refine limits based on actual usage patterns and abuse attempts.
  • Combine with authentication. Rate limiting alone isn't enough. Use it alongside proper authentication, input validation, and logging to detect and respond to attacks.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles