A login endpoint receives 300 password attempts in one minute. A normal user may mistype a password twice; an automated attacker can try thousands of leaked credentials. Authentication alone cannot distinguish those request rates, so the API needs a second boundary: how much work one caller may ask it to do in a period.

Rate limiting is not one universal number placed in middleware. A useful limiter identifies the action being protected, chooses a caller identity that is difficult to evade, updates the counter atomically, and has an explicit failure policy.

define the abuse case before choosing an algorithm

Different routes need different limits. A public article request is cheap and cacheable. Password reset sends email, reveals account behavior if designed badly, and can be abused to annoy a victim. A withdrawal attempt touches money and deserves tighter controls plus risk checks.

Write the rule in a sentence first:

One account may start at most five password-reset attempts in 15 minutes, while one source IP may start at most 30 across all accounts.

That rule contains two limits because either identity alone is weak. An attacker can rotate email addresses behind one IP, or rotate IP addresses while targeting one account. Layered keys make evasion more expensive.

make the Redis update atomic

A fixed-window limiter stores a counter under a key that expires at the end of the window. The unsafe version calls GET, calculates a value in Node.js, then calls SET. Two requests can read the same value and both write the same increment.

Redis commands should own the transition. A Lua script can increment, set the expiry only for the first request, and return the remaining lifetime as one atomic operation:

local count = redis.call("INCR", KEYS[1])

if count == 1 then
  redis.call("PEXPIRE", KEYS[1], ARGV[1])
end

local ttl = redis.call("PTTL", KEYS[1])
return { count, ttl }

The Node.js boundary can turn that result into a decision:

type LimitResult = {
  allowed: boolean;
  remaining: number;
  retryAfterMs: number;
};

async function checkLimit(
  redis: RedisClient,
  key: string,
  limit: number,
  windowMs: number,
): Promise<LimitResult> {
  const [count, ttl] = await redis.evalsha(
    RATE_LIMIT_SHA,
    1,
    key,
    String(windowMs),
  ) as [number, number];

  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    retryAfterMs: Math.max(0, ttl),
  };
}

In a real client, load the script at startup and retry with EVAL if Redis reports NOSCRIPT. Do not assemble Lua from request input.

build keys from trusted identities

A key should name the environment, action, identity type, identity value, and window:

prod:limit:password-reset:account:usr_82:20260713T1030
prod:limit:password-reset:ip:203.0.113.8:20260713T1030

Hash email addresses or other personal identifiers before putting them in Redis keys. This reduces accidental exposure in dashboards and logs. Keep the action in the key so a burst of harmless reads does not consume a security-sensitive write limit.

IP addresses require care behind Vercel, Nginx, or another proxy. Trust X-Forwarded-For only when your application receives traffic through a proxy you control and the framework is configured with the correct trusted proxy depth. Otherwise a client can supply a fake header and select a new identity on every request.

choose the response contract

When the limit is exceeded, return HTTP 429 Too Many Requests. A machine-readable body and Retry-After header make clients easier to operate:

return new Response(
  JSON.stringify({
    error: "rate_limit_exceeded",
    message: "Try again later.",
  }),
  {
    status: 429,
    headers: { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) },
  },
);

Do not reveal whether an email address exists through different limits or messages. For password reset, the public response should remain neutral even when the internal decision differs.

fixed window, sliding window, or token bucket

A fixed window is easy to understand but permits a boundary burst: a caller can use the full limit just before one window ends and again just after the next starts. A sliding log is precise but stores one entry per request. A sliding counter approximates the previous and current windows with modest storage. A token bucket allows short bursts while enforcing a longer refill rate.

Start with fixed windows for low-risk endpoints where simplicity matters. Use a token bucket or sliding approach when traffic bursts are legitimate and boundary behavior materially affects protection. The algorithm is less important than testing it under concurrency and observing how real users hit it.

decide whether failure is open or closed

If Redis is unavailable, a limiter cannot make its normal decision. There is no single correct fallback:

Route Reasonable failure policy
Public article read Fail open and record the dependency failure
Login Apply a small in-process emergency limit, then fail open or challenge
Password reset Fail closed temporarily with a generic response
Withdrawal Fail closed and preserve the request for safe retry

An in-memory fallback is per process and cannot provide a global limit, but it can reduce damage during a short Redis outage. Document the policy per route instead of hiding it inside generic middleware.

test the limiter as a state machine

The useful tests are not only “request six returns 429.” Send parallel requests and verify that exactly the allowed number succeeds. Advance a controlled clock across the window boundary. Test expiry, malformed proxy headers, IPv6 normalization, Redis timeouts, and two application instances sharing the same Redis server.

Observe allowed and blocked counts by route and reason, but avoid labels containing raw account IDs or IPs. Alert on sudden changes in block rate and Redis latency. A limiter that silently blocks legitimate traffic is an availability incident; one that always allows during dependency failures is security theater.

The practical operating rule is simple: protect one expensive or abusable action at a time, make the counter atomic, combine identities where evasion matters, and know exactly what the API does when Redis disappears.