← Back to Blog
Node.jsNext.jsRate LimitingAPI SecurityDDoSBrute ForceRedis

API Rate Limiting and Brute Force Protection in Node.js and Next.js [2026]

Why Rate Limiting Matters

Every public API endpoint is a potential attack vector. Without rate limiting, a single malicious client can:

  • Brute force your authentication endpoints (trying thousands of passwords per minute)
  • Scrape your entire database in minutes (abusing paginated GET endpoints)
  • Exhaust resources (CPU, database connections, memory) with a DDoS-style flood
  • Spam your contact forms, signup flows, or comment sections
  • Cost you money (if you pay per API call to a third-party service)

Rate limiting caps the number of requests a client can make within a time window. When implemented correctly, it stops each of these attacks without degrading the experience for legitimate users.

Choosing a Rate Limiting Algorithm

There are three mainstream algorithms. Each has trade-offs:

| Algorithm | Behavior | Best For | |-----------|----------|----------| | Fixed Window | Count requests per clock-aligned window (e.g., 100 req/min resetting every minute at :00) | Simple endpoints, low-traffic apps | | Sliding Window Log | Tracks timestamps of every request; evicts entries older than the window | Precision-critical scenarios | | Sliding Window (Redis Sorted Set) | Counts requests in a rolling time window using a Redis sorted set | Distributed systems, production | | Token Bucket | Tokens refill at a constant rate; bursts allowed up to bucket capacity | APIs with burst traffic patterns | | GCRA (Generic Cell Rate Algorithm) | Rate limits with minimal memory per key (used by rate limiting libraries) | High-throughput, memory-constrained |

Recommendation for production: Use sliding window with Redis or GCRA (implemented by @upstash/ratelimit or rate-limit-redis). These prevent the "traffic spike at window boundary" problem that fixed-window algorithms suffer from.

Rate Limiting in Express / Node.js APIs

For traditional Node.js REST APIs, express-rate-limit is the standard choice. Here's a production setup:

js
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
import { createClient } from "redis";

const redisClient = createClient({ url: process.env.REDIS_URL });

// General API limiter: 100 requests per 15 minutes per IP
export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,     // Return RateLimit-* headers
  legacyHeaders: false,      // Disable X-RateLimit-* headers
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),
  message: { error: "Too many requests, please try again later." },
  keyGenerator: (req) => {
    // Use X-Forwarded-For behind a proxy, fallback to IP
    return req.headers["x-forwarded-for"]?.split(",")[0]
      || req.ip
      || req.connection.remoteAddress;
  },
});

// Strict limiter for auth endpoints: 5 attempts per 15 minutes
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),
  skipSuccessfulRequests: true, // Only count failures
  message: { error: "Too many login attempts. Try again later." },
  keyGenerator: (req) => `${req.ip}:${req.body?.email || "unknown"}`,
});

Key patterns:

  • skipSuccessfulRequests — only increment the counter on failed auth attempts. This prevents legitimate users from being locked out after a successful login.
  • Composite keys for auth endpoints — scope the rate limit to both IP and email/username, so an attacker rotating IPs still hits per-account limits.
  • X-Forwarded-For extraction — behind a reverse proxy (NGINX, Cloudflare, AWS ALB), req.ip is always 127.0.0.1. Always read the real client IP from the header chain.

Applying Limiters to Routes

js
import { apiLimiter, authLimiter } from "./middleware/rateLimit.js";

// Apply globally to all API routes
app.use("/api", apiLimiter);

// Apply stricter limits to auth endpoints
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);

// Apply different limits to expensive endpoints
app.use("/api/reports/export", rateLimit({
  windowMs: 60 * 60 * 1000,  // 1 hour
  max: 3,
  store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
}));

Rate Limiting in Next.js (App Router)

Next.js middleware runs on every request at the edge, making it the ideal place for rate limiting. The recommended approach uses Upstash Ratelimit (works with both Upstash serverless Redis and self-hosted Redis):

ts
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// API rate limit: 10 requests per 10 seconds per IP
const apiRatelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "10 s"),
  analytics: true,
  prefix: "ratelimit:api",
});

// Auth rate limit: 5 requests per 60 seconds per IP+email
const authRatelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  analytics: true,
  prefix: "ratelimit:auth",
});

export async function middleware(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for")?.split(",")[0]
    ?? request.headers.get("x-real-ip")
    ?? "127.0.0.1";

  const { pathname } = request.nextUrl;

  // Apply rate limits based on route patterns
  if (pathname.startsWith("/api/auth/")) {
    const email = request.nextUrl.searchParams.get("email") || "unknown";
    const identifier = `${ip}:${email}`;
    const { success, limit, remaining, reset } = await authRatelimit.limit(identifier);

    if (!success) {
      return NextResponse.json(
        { error: "Too many authentication attempts. Try again later." },
        {
          status: 429,
          headers: {
            "X-RateLimit-Limit": String(limit),
            "X-RateLimit-Remaining": String(remaining),
            "X-RateLimit-Reset": String(reset),
            "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)),
          },
        }
      );
    }
  }

  if (pathname.startsWith("/api/")) {
    const { success, limit, remaining, reset } = await apiRatelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { error: "Too many requests." },
        {
          status: 429,
          headers: {
            "X-RateLimit-Limit": String(limit),
            "X-RateLimit-Remaining": String(remaining),
            "X-RateLimit-Reset": String(reset),
          },
        }
      );
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: "/api/:path*",
};

Important: Next.js middleware runs in the Edge Runtime. Avoid importing heavy dependencies or making database queries from middleware. The Redis call via Upstash REST is fast, but keep the middleware lean.

Self-Hosted Redis Alternative

If you self-host Redis (or use a Docker Compose stack), use ioredis instead:

ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis as UpstashRedis } from "@upstash/redis";

// Wraps ioredis for the Upstash SDK interface
const redisClient = new Redis(process.env.REDIS_URL!);
const redis = UpstashRedis.fromEnv();  // or configure REST manually

Alternatively, skip Upstash entirely and use express-rate-limit with a custom Next.js API route handler (for Route Handlers, not middleware).

Brute Force Protection Strategies

Rate limiting is necessary but not sufficient for auth brute force protection. Combine it with these additional layers:

1. Account Lockout After N Failures

js
// Pseudocode — implement in your auth route handler
const FAILURE_THRESHOLD = 5;
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes

async function checkAccountLockout(email) {
  const key = `lockout:${email}`;
  const attempts = await redis.get(key);
  if (attempts && parseInt(attempts) >= FAILURE_THRESHOLD) {
    const ttl = await redis.ttl(key);
    throw new AccountLockedError(
      `Account temporarily locked. Try again in ${Math.ceil(ttl / 60)} minutes.`
    );
  }
}

async function recordFailedAttempt(email) {
  const key = `lockout:${email}`;
  await redis.incr(key);
  await redis.expire(key, LOCKOUT_DURATION_MS, "NX"); // Set TTL only on first increment
}

2. Delayed Response (Constant-Time Comparison)

Never reveal why authentication failed — always return the same generic error. And always use constant-time comparison to prevent timing attacks:

js
import { timingSafeEqual } from "crypto";

function verifyPassword(input, stored) {
  const inputBuf = Buffer.from(input);
  const storedBuf = Buffer.from(stored);
  // Constant-time comparison prevents timing side-channel attacks
  if (inputBuf.length !== storedBuf.length) {
    return timingSafeEqual(Buffer.from("a"), Buffer.from("b")); // Fake comparison
  }
  return timingSafeEqual(inputBuf, storedBuf);
}

3. CAPTCHA After Threshold

Display a CAPTCHA (Turnstile, reCAPTCHA, hCaptcha, or the self-hosted ALTCHA) after 2-3 failed attempts. This stops automated tools without inconveniencing legitimate users:

ts
// Next.js API route handler
if (failedAttempts >= 2) {
  const { token } = req.body;
  const isValid = await verifyTurnstileToken(token);
  if (!isValid) {
    return res.status(400).json({ error: "CAPTCHA verification required." });
  }
}

4. Progressive Delay

Gradually increase the response time as failed attempts accumulate:

js
const delays = [0, 200, 500, 1000, 2000, 5000]; // ms
const delay = delays[Math.min(failedAttempts, delays.length - 1)];
await new Promise((resolve) => setTimeout(resolve, delay));

Rate Limiting Headers: Standards Compliance

Always return standard rate limiting headers so clients can programmatically respect your limits:

RateLimit-Limit: 100
RateLimit-Remaining: 87
RateLimit-Reset: 1719123456
Retry-After: 45

The IETF standard (draft-ietf-httpapi-ratelimit-headers) defines these headers. The Retry-After header is the most critical — it tells the client exactly when to retry. Browsers and HTTP clients (like fetch, axios) respect it automatically.

Rate Limiting in Distributed Systems

If your application runs across multiple nodes (Kubernetes pods, Lambda instances, multiple servers), in-memory rate limiting won't work — each node has its own counter, and an attacker could send 100 requests to each of 10 pods, bypassing your per-pod limit of 100.

Always use a centralized store for distributed rate limiting:

| Store | Pros | Cons | |-------|------|------| | Redis | Fast, atomic Sorted Set operations (ZCARD, ZREMRANGEBYSCORE), TTL auto-cleanup | Requires Redis setup | | Upstash | Serverless Redis via REST API, no connection pool management | Cold start latency on first request | | DynamoDB | Managed, no extra infra | Higher latency, TTL-based expiry, eventually consistent | | PostgreSQL | No extra services | Slow for high-throughput, table bloat |

Redis is the standard choice for production systems.

Testing Your Rate Limiting

Use artillery or k6 to simulate burst traffic and verify your rate limiter responds correctly:

bash
npm install -D artillery

# artillery.yaml
# config:
#   target: "http://localhost:3000"
#   phases:
#     - duration: 5
#       arrivalRate: 50  # 50 requests/second for 5 seconds
# scenarios:
#   - flow:
#       - get:
#           url: "/api/endpoint"

npx artillery run artillery.yaml

Expected result: After ~2 seconds, all subsequent requests should return 429 Too Many Requests.

Deployment Checklist

  • [ ] Rate limiting applied to all /api/* endpoints (not just auth routes)
  • [ ] Different limits for auth endpoints (stricter) vs general API (looser)
  • [ ] Composite key (ip:email) for auth rate limiting
  • [ ] Centralized Redis store for multi-instance deployments
  • [ ] Standard rate limiting headers (RateLimit-*, Retry-After) in all 429 responses
  • [ ] skipSuccessfulRequests on auth rate limiters
  • [ ] Account lockout with automatic unlock after cooldown
  • [ ] Constant-time comparison for password verification
  • [ ] Progressive response delay for repeated failures
  • [ ] CAPTCHA gating after N failed login attempts
  • [ ] Rate limiting on GraphQL endpoints (per-query cost analysis is ideal)
  • [ ] Monitoring and alerting on rate limit events (spike in 429s = possible attack)

Summary

Rate limiting is a fundamental security control that every production API needs. The key takeaways:

  1. Start simple, evolve to Redis — in-memory limits (via express-rate-limit's default MemoryStore) work for single-instance development. Move to Redis-backed stores before deploying to production.

  2. Layer your defenses — rate limiting alone won't stop a determined attacker. Combine it with account lockout, CAPTCHA gating, progressive delays, and constant-time comparisons for real brute force protection.

  3. Use sliding windows — fixed windows have boundary-effect vulnerabilities. Sliding windows (or GCRA) provide accurate rate limiting at scale.

  4. Always return headers — standard rate limiting headers let clients and CDNs (Cloudflare, Fastly) cooperate with your rate limits, not fight against them.

  5. Test under load — your rate limiter is only as good as its behavior under real traffic. Use artillery, k6, or autocannon to verify limits are enforced correctly.

Next week: GraphQL Security — depth limiting, query cost analysis, and securing your Apollo/Relay endpoints against introspection attacks and malicious queries.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Senior JavaScript security consultants with 10+ years of experience.