Sunday, March 1, 2026
HomeTechnologyAdd Rate Limiting + Brute-Force Protection (Express + TypeScript)

Add Rate Limiting + Brute-Force Protection (Express + TypeScript)

Cool — next up: rate limiting + brute-force protection (plus a few small security hardening wins).

We’ll add:

  • Rate limiting for /login and /refresh
  • Slowdown to make repeated attempts painful (without blocking legit users too aggressively)
  • Helmet for sensible security headers
  • A clean, reusable middleware setup

Add Rate Limiting + Brute-Force Protection (Express + TypeScript)

Why this matters

Auth endpoints are the #1 target for:

  • credential stuffing (reused passwords)
  • brute-force attacks
  • token refresh abuse

Even a basic protection layer reduces noise and risk a lot.


Step 1: Install packages

npm install helmet express-rate-limit express-slow-down
npm install -D @types/express-slow-down

Notes:

  • express-rate-limit blocks or rejects after a threshold
  • express-slow-down intentionally delays responses after a threshold

Step 2: Add Helmet globally

Update src/server.ts:

import helmet from "helmet";

// ...
app.use(helmet());

That’s it. Helmet sets a bunch of common security-related headers.

If you serve no HTML at all (API-only), this is almost always safe.


Step 3: Create reusable rate limiters

Create src/middleware/rateLimiters.ts:

import rateLimit from "express-rate-limit";
import slowDown from "express-slow-down";

/**
 * General API limiter (optional):
 * helps stop basic flooding across your whole API.
 */
export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 300,               // 300 requests per 15 min per IP
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: {
    error: { message: "Too many requests, please try again later." }
  }
});

/**
 * Auth: login limiter (strict)
 */
export const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 10,                // 10 attempts per 15 min per IP
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: {
    error: { message: "Too many login attempts. Try again in 15 minutes." }
  }
});

/**
 * Auth: login slowdown (soft pressure)
 * After 5 attempts, add delay that increases per request.
 */
export const loginSlowdown = slowDown({
  windowMs: 15 * 60 * 1000, // 15 minutes
  delayAfter: 5,            // allow 5 requests at full speed
  delayMs: (hits) => (hits - 5) * 500, // +500ms each request after 5
  maxDelayMs: 10_000        // cap delay at 10 seconds
});

/**
 * Auth: refresh limiter (moderate)
 * Refresh can be abused; keep it tighter than general API.
 */
export const refreshLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 30, // 30 refreshes per 15 min per IP
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: {
    error: { message: "Too many refresh attempts. Please slow down." }
  }
});

These numbers are sane defaults for many apps. You can tune them later.


Step 4: Apply limiters to routes

Update src/routes/authRoutes.ts:

import { Router } from "express";
import {
  registerUser,
  loginUser,
  refreshAccessToken,
  logoutUser
} from "../controllers/authController";

import { validate } from "../middleware/validate";
import { registerSchema, loginSchema } from "../validation/authSchemas";

import {
  loginLimiter,
  loginSlowdown,
  refreshLimiter
} from "../middleware/rateLimiters";

const router = Router();

router.post("/register", validate(registerSchema), registerUser);

// slowdown first, then limiter
router.post("/login", loginSlowdown, loginLimiter, validate(loginSchema), loginUser);

router.post("/refresh", refreshLimiter, refreshAccessToken);
router.post("/logout", logoutUser);

export default router;

Why slowdown before limiter?

  • slowdown makes automated attacks expensive immediately
  • limiter stops it hard once it crosses the line

Step 5: (Optional but recommended) Add a global API limiter

If you have more endpoints beyond auth, apply a broad limiter once in server.ts:

import { apiLimiter } from "./middleware/rateLimiters";

// after JSON/cors/cookie middleware
app.use(apiLimiter);

If your API includes uploads, streaming, or other high-frequency endpoints, you might prefer route-specific limiters instead.


Step 6: Make brute forcing less “informative”

You already did part of this by returning “Invalid credentials” for both wrong email and wrong password.

Two other good moves:

A) Keep login response time similar

Don’t fail instantly for “email not found” while hashing for real users. Attackers can time that.

A simple approach is to always run a bcrypt compare, even if the user doesn’t exist:

// inside login controller
const fakeHash = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5o0qH5o2l9n8eC5gqEwO8yqZCqZ9W"; // any valid bcrypt hash

const user = await User.findOne({ email });
const hashToCheck = user?.password ?? fakeHash;

const match = await bcrypt.compare(password, hashToCheck);
if (!user || !match) throw new AppError("Invalid credentials", 401);

This makes enumeration harder (not perfect, but better).

B) Log suspicious activity

At minimum: log IP + endpoint + user agent for rate-limited events (or use a real logger like Winston later).


Important production note: memory store vs shared store

By default, express-rate-limit stores counts in memory. That’s fine for:

  • a single server
  • local dev
  • small deployments

If you run multiple instances (horizontal scaling), you want a shared store (commonly Redis) so limits apply across all servers.

You can keep the blog simple now, and later add “rate limit with Redis store” as a next step.


Quick test you can do

Hit login repeatedly (wrong password):

  • first ~5 requests: normal speed
  • after that: each attempt gets slower
  • eventually: you get a 429 response

That’s exactly what you want.


Next step if you say “next” again

The best next upgrade after this is:

RBAC: Roles + Permissions

  • user, admin, support, etc.
  • protect routes based on role
  • keep authorization logic clean and testable

Say next and I’ll build RBAC the same way: clean middleware + typed user context + example protected endpoints.

RELATED ARTICLES

Most Popular

Recent Comments