Programming Intermediate 8 min

How to Rate-Limit a Node.js API

Without rate limiting, your API is exposed to brute-force attacks on login endpoints, aggressive scrapers that hammer your database, and accidental infinite loops in client code that can bring your server down. Rate limiting is a first-line defense — cheap to implement, immediately effective.

Step-by-step

  1. 1

    Install express-rate-limit

    express-rate-limit is the standard package for this. It is maintained by the Express ecosystem and covers the common cases with minimal configuration.

    bash
    npm install express-rate-limit
  2. 2

    Apply a global limit across all routes

    A loose global limit catches runaway clients before they saturate your server. Attach it before your router definitions.

    javascript
    const express = require('express');
    const rateLimit = require('express-rate-limit');
    
    const app = express();
    
    const globalLimiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 200,                  // requests per window per IP
      standardHeaders: true,     // emit RateLimit-* headers (RFC 6585)
      legacyHeaders: false,      // disable X-RateLimit-* (deprecated)
      message: { error: { code: 'TOO_MANY_REQUESTS', message: 'Slow down.' } },
    });
    
    app.use(globalLimiter);
  3. 3

    Tighten limits on auth endpoints

    Login, password reset, and OTP endpoints are brute-force targets. Apply a much stricter limiter directly to those routes. Five attempts in 15 minutes is a reasonable ceiling for a login endpoint.

    javascript
    const authLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 5,
      skipSuccessfulRequests: true, // only count failed attempts
      message: {
        error: {
          code: 'AUTH_RATE_LIMITED',
          message: 'Too many login attempts. Try again in 15 minutes.',
        },
      },
    });
    
    app.post('/auth/login', authLimiter, loginHandler);
    app.post('/auth/forgot-password', authLimiter, forgotPasswordHandler);
  4. 4

    Key by IP and authenticated user ID

    The default key is the client IP. For authenticated routes, keying by user ID prevents an attacker from cycling IPs to bypass the limit. Return the user ID when present, otherwise fall back to IP.

    javascript
    const userAwareLimiter = rateLimit({
      windowMs: 60 * 1000, // 1 minute
      max: 60,
      keyGenerator: (req) => {
        // req.user is set by your auth middleware when the token is valid
        return req.user ? `user:${req.user.id}` : req.ip;
      },
    });
    
    app.use('/api', authenticateOptional, userAwareLimiter);
  5. 5

    Understand the RateLimit response headers

    With standardHeaders: true, every response carries:

    • RateLimit-Limit — the max requests allowed
    • RateLimit-Remaining — requests left in the current window
    • RateLimit-Reset — Unix timestamp when the window resets

    When the limit is hit, the library sends HTTP 429 and a Retry-After header (seconds until reset). Well-behaved clients respect it; log and block those that don't.

  6. 6

    Use a Redis store for multi-instance deployments

    The default in-memory store does not share state between processes or machines. If you run more than one Node process (cluster, PM2, Kubernetes), each instance counts independently — the limit becomes effectively multiplied. Switch to a Redis-backed store.

    bash
    npm install rate-limit-redis ioredis
  7. 7

    Configure the Redis store

    Pass the store option to your limiter. The counter now lives in Redis and is shared across all instances.

    javascript
    const { RedisStore } = require('rate-limit-redis');
    const Redis = require('ioredis');
    
    const redisClient = new Redis(process.env.REDIS_URL);
    
    const globalLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 200,
      standardHeaders: true,
      legacyHeaders: false,
      store: new RedisStore({
        sendCommand: (...args) => redisClient.call(...args),
      }),
    });
  8. 8

    Log and monitor rate-limit events

    Knowing who is hitting your limits is as important as enforcing them. Override the handler to log before sending the 429 response. Feed those logs to your monitoring system (Datadog, Grafana, Logtail) and alert on spikes.

    javascript
    const logger = require('./logger'); // pino, winston, etc.
    
    const globalLimiter = rateLimit({
      windowMs: 15 * 60 * 1000,
      max: 200,
      standardHeaders: true,
      legacyHeaders: false,
      handler: (req, res, next, options) => {
        logger.warn({
          event: 'rate_limit_exceeded',
          ip: req.ip,
          userId: req.user?.id ?? null,
          path: req.path,
          method: req.method,
        });
        res.status(options.statusCode).json(options.message);
      },
    });

Tips & gotchas

  • Set <code>trustProxy: true</code> (or <code>app.set('trust proxy', 1)</code>) when your app sits behind a reverse proxy like Nginx or a load balancer — otherwise <code>req.ip</code> is always the proxy's address.
  • Use <code>skip</code> to whitelist internal health-check routes or your own monitoring IPs so they never trigger the limit.
  • The in-memory store is fine for a single-process dev environment, but always use Redis in staging and production.
  • Do not set limits so tight that they trigger under normal usage — analyse your real traffic patterns first.

Wrapping up

Rate limiting is table stakes for any public API. Apply a loose global limit, a strict limit on auth routes, key by user ID when authenticated, and back it with Redis the moment you scale beyond one process. The whole setup takes under an hour and protects you against a wide class of abuse and accidental overload.

#Node.js #API #Security
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.