CodingNic

Security Hardening

Rate Limiting

Security Hardening 15 min read

Rate Limiting

Objectives

By the end of this lesson, you should be able to:

  • Install and configure express-rate-limit on a login endpoint
  • Explain what windowMs and max control
  • Confirm a brute-force attack is actually blocked by rate limiting

💡 Why this matters: The last lesson showed an unprotected login endpoint cracked in 5 requests. This lesson adds the one piece that stops it, without changing the login logic itself at all.

⚠️ A note on verification: every snippet and every output in this lesson was actually run.

Installing express-rate-limit

bash
npm install express-rate-limit

Configuring a Limiter

javascript
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 3,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'TooManyRequests', message: 'Too many login attempts, try again later' }
});

windowMs is the size of the time window, here 15 minutes, max is how many requests a single client can make within that window before being blocked, standardHeaders: true adds RateLimit-* response headers so a client can see its own remaining quota, legacyHeaders: false skips the older, deprecated X-RateLimit-* header style. Once a client exceeds max within the window, every further request gets the configured message, with a 429 Too Many Requests status, until the window resets.

Applying It to the Login Route

javascript
app.post('/api/v1/auth/login', loginLimiter, async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'InvalidCredentials', message: 'Incorrect email or password' });
  }
  res.json({ success: true });
});

One line added, loginLimiter as middleware before the route handler, exactly the same pattern as requireAuth or validate(schema) from earlier modules. The login logic itself, hashing, comparison, response shape, is completely unchanged.

Re-Running the Attack

The exact same scripted attack from the last lesson, five passwords, including the correct one last, against this rate-limited endpoint:

text
Attempt 1: "123456" -> 401 {"error":"InvalidCredentials","message":"Incorrect email or password"}
Attempt 2: "password" -> 401 {"error":"InvalidCredentials","message":"Incorrect email or password"}
Attempt 3: "qwerty" -> 401 {"error":"InvalidCredentials","message":"Incorrect email or password"}
Attempt 4: "letmein" -> 429 {"error":"TooManyRequests","message":"Too many login attempts, try again later"}
Attempt 5: "correct-horse-battery-staple" -> 429 {"error":"TooManyRequests","message":"Too many login attempts, try again later"}

With max: 3, the first three attempts are checked normally, all fail, correctly. The fourth attempt is blocked before it ever reaches the bcrypt comparison, and so is the fifth, the attempt with the actual correct password. The attack that cracked the unprotected endpoint in 5 requests never succeeds at all here, the correct password is never even checked.

Choosing max and windowMs

There’s a real trade-off here: a small max and long windowMs stop attacks hard but can also lock out a legitimate user who mistypes their password a few times in a row, a larger max is friendlier to genuine mistakes but gives an attacker more attempts before being blocked. A common starting point is somewhere around 5 attempts per 15 minutes for a login endpoint specifically, tuned based on real usage patterns, this lesson used max: 3 purely to keep the demonstration short.

Rate Limiting Isn’t Just for Login

Any endpoint that’s expensive, sensitive, or a plausible attack target benefits from a limiter, password reset requests, account creation, and any public API endpoint that costs real server resources per request are all common candidates, each can have its own rateLimit() instance with its own windowMs and max, tuned for that specific endpoint’s needs.

Try It

  1. Build the rate-limited login route above, and confirm the same attack sequence from the last lesson now gets blocked.
  2. Change max to 10, rerun the attack, and confirm the correct password is found on attempt 5, explain why raising max reintroduces the vulnerability from the last lesson.
  3. Add a separate limiter, with its own windowMs and max, to the registration endpoint from Module 1, and explain why registration might reasonably need a different configuration than login.
  4. Inspect the RateLimit-* response headers on a request that hasn’t hit the limit yet, and explain what each one tells a client.

Recap

  • express-rate-limit blocks a client after max requests within windowMs, returning 429 Too Many Requests for the rest of the window.
  • Adding it to a route is one line of middleware, no change to the route’s own logic.
  • Against the brute-force attack from the last lesson, the rate limiter blocks the attack before the correct password is ever reached, real, measured behavior, not a theoretical claim.

Next lesson: managing secrets correctly, and how rate limiting fits into a broader defense-in-depth strategy.