Rate LimitingSecurity · NestJS

Nothing in the API stops a script from trying ten thousand passwords, or from calling one route until the database gives up. A throttler counts requests per caller and route and answers 429 past the limit, tighter on sign-in, not at all on the health check.

What you will learn

Read the theory for Rate Limiting

All Security lessons

All NestJS courses

loading types…

What you'll learn

  • Say what a rate limit defends against (credential stuffing, scraping, one client starving the rest) and what it does not (a distributed attack)
  • Configure @nestjs/throttler with a ttl and a limit, bind ThrottlerGuard globally, and read the X-RateLimit headers it adds
  • Tighten one route with @Throttle and exempt another with @SkipThrottle
  • Explain what the counter is keyed on (the caller's address and the route) and why a proxy changes that

Rate Limiting

Sign-in refuses a wrong password with a 401 and nothing else. So a script can try another, and another, ten thousand times a minute, until one of the shelter's staff turns out to have chosen changeme. Elsewhere, one misbehaving client fetching /cats in a loop can keep the database busy enough that everyone else waits. Both are the same problem: the API has no notion of how often. A rate limit adds one. It counts requests per caller, and past a limit it answers 429 Too Many Requests without doing the work. It will not stop a thousand machines each staying under the limit; that needs infrastructure in front of the app. It does stop the one script, which is most of them.

Counting with @nestjs/throttler

The module holds one or more throttler definitions, each a window and a limit: ttl is the window in milliseconds, limit how many requests one caller may make in it:

ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }])

The counting is done by ThrottlerGuard. Bound with APP_GUARD it counts every route; bound with @UseGuards() only some. A counter is kept per tracker and per route: the tracker is the caller's IP address by default (req.ip), the route is the controller and handler, so exhausting GET /cats says nothing about POST /cats. The guard runs before the handler, which means a request counts whether it succeeds or fails, and a wrong password counts as much as a right one. That is what makes it a defence against guessing.

Each counted response carries the state: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window empties). Past the limit the guard throws ThrottlerException, a 429 with the body { "statusCode": 429, "message": "ThrottlerException: Too Many Requests" } and a Retry-After header saying how long to wait. A client that reads it can back off instead of hammering.

Per route: tighter, or none

One limit rarely fits every route. @Throttle() overrides it on a handler or a controller, naming the throttler it changes (default when the definition has no name):

@Throttle({ default: { limit: 1, ttl: 1000 } })
@Post('export')
export() {}

@SkipThrottle() exempts a route or controller entirely: no counting, no headers. That is for the routes a machine calls on a schedule, a health check polled by a load balancer every few seconds, which would otherwise be the first thing to hit the limit and take the instance out of rotation. On a controller that is skipped, @SkipThrottle({ default: false }) on one handler turns counting back on for it.

Several definitions can run side by side, each with a name, short at three per second and long at a hundred per minute, and a request must pass all of them; the docs' example is exactly that, and the headers then carry the name as a suffix.

What the counter is keyed on

req.ip is right when clients reach the app directly. Behind a proxy or a load balancer every request arrives from the proxy's address, and the whole internet shares one counter. Express's trust proxy setting makes req.ip read the X-Forwarded-For header the proxy sets; the docs show it, and a getTracker() override on a guard subclass can key on anything else, a user id or an API key. The counters live in memory by default, which is one process's memory: several instances need a shared store (the docs' Redis storage), or each instance grants the full limit on its own.

Your task

Three requests a minute anywhere, two on sign-in, none on the health check.

  1. In app.module.ts, configure the throttler for three requests per minute and bind its guard to every route.
  2. In auth/auth.controller.ts, allow two sign-in attempts per minute.
  3. In app.controller.ts, exempt the health check.

Then, in the request panel, sign in four times and read the fourth answer, headers included. A new Run starts the counters again.

When it fails

  • Nothing is ever 429 and no X-RateLimit headers appear: the module is configured but the guard is not bound; a guard that is never bound counts nothing.
  • Nest can't resolve dependencies of the ThrottlerGuard: the guard is bound in a module that does not import ThrottlerModule.
  • Sign-in is limited to 3, not 2: @Throttle() names a throttler that does not exist, or is on the controller while the handler's own metadata wins.
  • The health check is 429 after three polls: @SkipThrottle() is missing, or @SkipThrottle(false) was meant to skip and does the opposite.
  • Every caller shares one counter in production: the app is behind a proxy and req.ip is the proxy's; set trust proxy.

Remember

  • A rate limit counts requests per caller and route inside a window, and answers 429 past the limit; failed requests count too.
  • ThrottlerModule.forRoot([{ ttl, limit }]) and ThrottlerGuard as APP_GUARD count everything; X-RateLimit-* headers show the state.
  • @Throttle() tightens a route (sign-in), @SkipThrottle() exempts one (health).
  • The counter is keyed on req.ip; behind a proxy, trust it, and share storage across instances.
Stuck? Show a hint

app.module.ts: imports ThrottlerModule.forRoot([{ ttl: 60000, limit: 3 }]) and providers [{ provide: APP_GUARD, useClass: ThrottlerGuard }]. auth.controller.ts: @Throttle({ default: { limit: 2, ttl: 60000 } }) on login. app.controller.ts: @SkipThrottle() on health.