Rate Limiting

Count requests per caller with @nestjs/throttler, tighten individual routes, and answer 429 with a Retry-After the client can obey.

SyntaxThrottlerModule.forRoot([{ name?, ttl, limit, blockDuration? }])

Rate limiting caps how many requests one caller may make in a window. It is what stops a password-guessing script, an accidental retry loop, and a scraper that costs you a database. The @nestjs/throttler package provides it: ThrottlerModule holds the configuration, and ThrottlerGuard does the counting.

Where it runs

It is a guard, so it runs after all middleware and before any interceptor, pipe or handler. A blocked request never reaches your validation or your service. It also means the limit is decided from the raw request only; a guard cannot count by a field of a validated DTO.

How the counting works

Each throttler set is an independent counter with a ttl (the window, in milliseconds) and a limit. Define several, name them, and all of them must pass: 5 per second and 100 per minute. The tracker defaults to the caller's IP address; behind a proxy, set trust proxy on the adapter or override getTracker(), otherwise every request looks like it comes from the load balancer.

The response

Allowed responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. When the limit is reached the guard sets Retry-After, in seconds, and throws ThrottlerException, which is an HttpException with status 429 and the message ThrottlerException: Too Many Requests. Headers can be turned off per set with setHeaders: false.

What trips people up

The default storage is an in-memory map, per process. Run four replicas behind a load balancer and the effective limit is four times what you configured; point the storage option at a shared store to fix it.

Example

Example · typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import {
  ThrottlerGuard,
  ThrottlerModule,
  minutes,
  seconds,
} from '@nestjs/throttler';
import { AuthModule } from './auth/auth.module.js';

@Module({
  imports: [
    AuthModule,
    // The array form. Each entry is an independent counter and every one
    // of them must pass. seconds()/minutes() just return milliseconds.
    ThrottlerModule.forRoot([
      { name: 'burst', ttl: seconds(1), limit: 5 },
      { name: 'sustained', ttl: minutes(1), limit: 100 },
    ]),
  ],
  providers: [
    // Binds the guard to every route in the application. Without this,
    // the module is configured but nothing is enforced anywhere.
    { provide: APP_GUARD, useClass: ThrottlerGuard },
  ],
})
export class AppModule {}

When to use it

  • Stop a credential-stuffing script by allowing three sign-in attempts a minute and blocking for fifteen.
  • Give a public API a published quota and return Retry-After so well-behaved clients back off.
  • Keep one tenant's retry loop from exhausting the connection pool for everyone else.
  • Make the limit hold across replicas by moving the counters into Redis.

More examples

A tighter limit on sign-in, and a route with none

Use this whenever one route is far more expensive or more attractive to attack than the rest: sign-in, password reset, anything that sends an email or an SMS. The naming trap is the one that costs an afternoon - the moment you give your throttler sets names in forRoot(), the bare @SkipThrottle() and a @Throttle({ default: ... }) stop having any effect, silently, because 'default' is only the name of a set you never defined.

Example · typescript
// src/auth/auth.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { SkipThrottle, Throttle, minutes, seconds } from '@nestjs/throttler';
import { AuthService } from './auth.service.js';
import { SignInDto } from './dto/sign-in.dto.js';

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

  // The keys are throttler NAMES. 'burst' and 'sustained' are the sets
  // from forRoot(); 'sustained' still applies here because this only
  // overrides 'burst'. blockDuration keeps the caller out for 15 minutes
  // once the limit is hit, instead of only until the window rolls over.
  @Throttle({
    burst: { limit: 3, ttl: minutes(1), blockDuration: minutes(15) },
  })
  @Post('sign-in')
  signIn(@Body() dto: SignInDto) {
    return this.auth.signIn(dto);
  }

  // Skips the sets by name. @SkipThrottle() with no argument is shorthand
  // for @SkipThrottle({ default: true }) - which skips NOTHING here,
  // because neither set is called 'default'.
  @SkipThrottle({ burst: true, sustained: true })
  @Get('session')
  session() {
    return this.auth.currentSession();
  }

  // A per-route set that does not exist in forRoot() is simply ignored,
  // so the name has to match. seconds(10) = 10000 ms.
  @Throttle({ burst: { limit: 1, ttl: seconds(10) } })
  @Post('password-reset')
  requestReset(@Body('email') email: string) {
    return this.auth.requestPasswordReset(email);
  }
}

The object longhand, with shared storage

The array form and the object form are the same configuration; the array is a shorthand for an object whose only property is throttlers. You need the long form as soon as you want a custom storage, an errorMessage, a skipIf, or ipv6SubnetPrefix - options that belong to the module rather than to one set. Any class implementing the ThrottlerStorage interface, exported from @nestjs/throttler, can be the storage.

Example · typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule, minutes, seconds } from '@nestjs/throttler';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import Redis from 'ioredis';

@Module({
  imports: [
    // This is what forRoot([...]) desugars to. The array goes under
    // `throttlers`; everything else applies to all the sets at once.
    ThrottlerModule.forRoot({
      throttlers: [
        { name: 'burst', ttl: seconds(1), limit: 5 },
        { name: 'sustained', ttl: minutes(1), limit: 100 },
      ],
      // The built-in storage is an in-memory map, so each replica counts
      // on its own. A shared store gives all of them one set of counters.
      // Community package: npm i @nest-lab/throttler-storage-redis ioredis
      storage: new ThrottlerStorageRedisService(
        new Redis(process.env.REDIS_URL!),
      ),
      // Replaces 'ThrottlerException: Too Many Requests' in the 429 body.
      errorMessage: 'Too many requests. Try again shortly.',
      // Runs per request before any counting. Useful for an internal
      // caller that must never be throttled.
      skipIf: (context) => {
        const request = context.switchToHttp().getRequest<{ headers: Record<string, string> }>();
        return request.headers['x-internal-key'] === process.env.INTERNAL_KEY;
      },
    }),
  ],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}

Counting the real client behind a proxy

Reach for this the first time a deployed app rate-limits everybody at once: every request arrives from the ingress, so they all share one counter. Two things are needed, not one - the adapter must trust the proxy before the forwarded addresses are parsed at all, and the guard must read them. Trusting a proxy you do not control lets a caller set their own X-Forwarded-For and evade the limit, so name the hop rather than passing true.

Example · typescript
// src/throttler/throttler-behind-proxy.guard.ts
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';

@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
  // The default tracker is req.ip, which behind a proxy is the proxy.
  // req.ips is the X-Forwarded-For chain, left to right, and is only
  // populated once the adapter trusts the proxy (see main.ts below).
  protected async getTracker(req: Record<string, any>): Promise<string> {
    return req.ips.length ? req.ips[0] : req.ip;
  }
}

// src/main.ts
// import { NestFactory } from '@nestjs/core';
// import type { NestExpressApplication } from '@nestjs/platform-express';
//
// const app = await NestFactory.create<NestExpressApplication>(AppModule);
// // Without this, Express ignores X-Forwarded-For and req.ips is empty.
// // 'loopback' trusts a proxy on the same host; use the proxy's address
// // or hop count in production, never `true` on a public listener.
// app.set('trust proxy', 'loopback');

// src/app.module.ts
// providers: [{ provide: APP_GUARD, useClass: ThrottlerBehindProxyGuard }]

Discussion

  • Be the first to comment on this lesson.