hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Securing Your API with @hectoday/http

The Threat Landscape

  • What Could Go Wrong
  • Project Setup

Brute-Force Protection

  • Rate Limiting Login Attempts
  • Account Lockout
  • Timing Attack Prevention

CSRF Protection

  • What Is CSRF?
  • CSRF Tokens
  • CSRF for API Consumers

Token Hardening

  • Refresh Token Rotation
  • Token Revocation
  • Secure Token Storage

Password Reset

  • The Password Reset Flow
  • Building the Reset Routes
  • Reset Security

Putting It All Together

  • Security Headers
  • Logging and Monitoring
  • Security Checklist
  • Capstone: Hardened Auth API

Rate Limiting Login Attempts

The problem

Without rate limiting, an attacker can send thousands of login requests per second. Each request tests a password. At 1,000 requests per second, they can try 86 million passwords in a day.

bcrypt slows each attempt (roughly 100ms at cost factor 10), but that only means the attacker needs more connections in parallel, not more time. Rate limiting caps how many attempts are allowed, regardless of how fast the attacker’s connection is.

Two dimensions of rate limiting

You need to limit by two things:

Per-IP: Limits how many login attempts a single IP address can make. This stops a single machine from flooding your endpoint. But an attacker with many IPs (a botnet, rotating proxies) can bypass per-IP limits.

Per-email: Limits how many login attempts can target a single account. This protects individual accounts even if the attacker uses many IPs. But a credential stuffing attack uses a different email on each request, bypassing per-email limits.

Neither is sufficient alone. Together, they cover both attack patterns.

The rate limiter

Create src/rate-limit.ts:

// src/rate-limit.ts
interface RateLimitEntry {
  count: number;
  resetAt: number;
}

const store = new Map<string, RateLimitEntry>();

export function rateLimit(
  key: string,
  limit: number,
  windowMs: number,
): { allowed: boolean; retryAfterMs: number } {
  const now = Date.now();
  const entry = store.get(key);

  if (!entry || now > entry.resetAt) {
    store.set(key, { count: 1, resetAt: now + windowMs });
    return { allowed: true, retryAfterMs: 0 };
  }

  entry.count++;

  if (entry.count > limit) {
    const retryAfterMs = entry.resetAt - now;
    return { allowed: false, retryAfterMs };
  }

  return { allowed: true, retryAfterMs: 0 };
}

The function takes a key (like an IP address or email), a limit (max attempts), and a time window. It returns whether the request is allowed and how long until the window resets.

This is a fixed window rate limiter. It counts requests within a time window and resets the count when the window expires. It is simple and works well for login protection. More sophisticated algorithms exist (sliding window, token bucket) but the fixed window is enough for what we need.

Apply it to the login route

Update src/routes/auth.ts:

import { rateLimit } from "../rate-limit.js";
import { getClientIp } from "../ip.js";
import { log } from "../logger.js";

route.post("/login", {
  request: { body: LoginBody },
  resolve: async (c) => {
    if (!c.input.ok) {
      return Response.json({ error: c.input.issues }, { status: 400 });
    }

    const { email, password } = c.input.body;
    const ip = getClientIp(c.request);

    // Rate limit by IP: 20 attempts per minute
    const ipLimit = rateLimit(`login:ip:${ip}`, 20, 60_000);
    if (!ipLimit.allowed) {
      log("rate_limited", { key: "ip", ip, email });
      return Response.json(
        { error: "Too many login attempts. Try again later." },
        {
          status: 429,
          headers: {
            "retry-after": String(Math.ceil(ipLimit.retryAfterMs / 1000)),
          },
        },
      );
    }

    // Rate limit by email: 5 attempts per minute
    const emailLimit = rateLimit(`login:email:${email}`, 5, 60_000);
    if (!emailLimit.allowed) {
      log("rate_limited", { key: "email", ip, email });
      return Response.json(
        { error: "Too many login attempts. Try again later." },
        {
          status: 429,
          headers: {
            "retry-after": String(Math.ceil(emailLimit.retryAfterMs / 1000)),
          },
        },
      );
    }

    // ... rest of login logic (look up user, compare password, etc.)
  },
});

The numbers

20 per IP per minute: A legitimate user might mistype their password a few times. 20 is generous. An attacker trying hundreds or thousands of passwords will hit this quickly.

5 per email per minute: A single account should not receive more than a few login attempts per minute from any combination of IPs. This is the per-account defense.

These numbers are starting points. Adjust based on your user base and threat model. A banking app might use 3 per email per 5 minutes. A social app might use 10 per email per minute.

Status 429 and Retry-After

HTTP status 429 means “Too Many Requests.” The Retry-After header tells the client how many seconds to wait before trying again. Well-behaved clients (and some browsers) respect this header automatically.

The same error message

Notice we return the same error message for both IP and email rate limiting. We do not say “this IP is rate limited” or “this email is rate limited.” That would tell an attacker which limit they hit and help them adjust their strategy.

Cleaning up expired entries

The in-memory Map grows as new keys are added. Old entries (past their reset time) should be cleaned up periodically:

// Add to src/rate-limit.ts
export function cleanup(): void {
  const now = Date.now();
  for (const [key, entry] of store) {
    if (now > entry.resetAt) {
      store.delete(key);
    }
  }
}

// Run every 5 minutes
setInterval(cleanup, 5 * 60 * 1000);

In production, use Redis with built-in key expiry instead of an in-memory Map. The interface stays the same.

Exercises

Exercise 1: Test the rate limiter. Send 6 login requests with the same email in rapid succession (use a bash loop or a script). The first 5 should succeed (with 401 for wrong password), and the 6th should get 429 with a Retry-After header.

Exercise 2: Send 21 login requests with different emails from the same IP. The first 20 should go through, and the 21st should get 429. This tests the per-IP limit.

Exercise 3: What happens if a legitimate user gets rate limited? They see “Too many login attempts. Try again later.” After the window resets (1 minute), they can try again. Is this acceptable UX? Consider adding a “too many attempts” message to your login page that shows the retry time.

Why do you need both per-IP and per-email rate limiting?

What HTTP status code indicates rate limiting?

← Project Setup Account Lockout →

© 2026 hectoday. All rights reserved.