PLAINFINCH learn by building

Rate limits

You will be able to limit how often someone can try to log in, so a program can't guess passwords all day.

What problem does a rate limit solve?

Every route answers as fast as requests arrive. That is what you want for the menu page and exactly what you don't want for the login form. A program can try ten thousand passwords for ada@example.com in the time a person types one, and many members reuse short passwords that appear in public lists of leaked ones. Without a limit, the only thing between an attacker and the account is how quickly your server answers.

Rate limits help elsewhere too. A sign-up form can be filled thousands of times to create fake accounts. A contact form can send spam to the club secretary all night. An order form can flood the bakery kitchen when someone's script gets stuck in a loop. A rate limit says: this visitor may do this action so many times in so much time, and no more.

How does a simple limiter count?

The idea fits in a few lines. Keep a Map keyed by who is asking. For each key, store when their current window started and how many attempts they have made in it. On every request, look up the entry. If there is none, or the window is over, start a new one with a count of zero. Add one. If the count is now over the limit, refuse; otherwise call next() and let the login handler run.

This is called a fixed window. It is easy to read and good enough for a login form. Its weakness is the edge: a visitor can use five attempts at the end of one window and five more at the start of the next. Larger sites use a sliding window or a token bucket, which spread the allowance more evenly. The answer to the visitor is the same either way.

function loginLimit(req, res, next) {
  const now = Date.now();
  let entry = attempts.get(req.ip);
  if (!entry || now - entry.start >= WINDOW_MS) {
    entry = { start: now, count: 0 };
    attempts.set(req.ip, entry);
  }
  entry.count += 1;
  if (entry.count > MAX_ATTEMPTS) {
    const wait = Math.ceil((entry.start + WINDOW_MS - now) / 1000);
    res.set('Retry-After', String(wait));
    return res.status(429).json({ error: 'Too many login attempts. Try again later.' });
  }
  next();
}

What should the server answer when someone hits the limit?

The status for this is 429 Too Many Requests. It tells browsers, scripts and other servers that the request was fine but there have been too many of them. Avoid 403 Forbidden, which means the action is never allowed, and avoid 401, which would make it look like the password was wrong.

Add a Retry-After header with the number of seconds left in the window. People see a useful message ("try again in 9 minutes") if your login page reads it, and well-written clients wait that long before trying again. Keep the body short and the same for everyone. It shouldn't say whether the email exists, because that would turn the limiter into a way to discover members.

FigureOne IP address tries POST /login for ada@example.com. Attempts 1 to 5 reach the password check and get 401. The sixth gets 429 with Retry-After: 595. Ten minutes after the window began, the right password gets 200.One IP address tries POST /login for ada@example.com. Attempts 1 to 5 reach the password check and get 401. The sixth gets 429 with Retry-After: 595. Ten minutes after the window began, the right password gets 200.
Every attempt is counted before the password is checked, so the sixth inside the window gets 429 even with the right password. Retry-After says how many seconds are left; once the window ends, the count starts again at 1.

Why does the limiter run before the password check?

Put the limiter in front of the login handler: app.post('/login', loginLimit, handler). Every attempt is counted, right or wrong, and once the limit is reached even the correct password gets 429. That sounds harsh, but the alternative leaks information: if the right password still worked during a block, an attacker would keep guessing and notice the one answer that wasn't 429.

Hashing is slow on purpose, so the order affects the server's health too. A blocked request never reaches scryptSync, which means a flood of guesses costs your server a Map lookup each instead of real work.

How do you test a time window without waiting?

The practice server has a pretend clock. Each request moves it forward one second, and a request can say "after": 601000 to move it ten minutes first. Press **Wrong password** six times, read the 429 and its Retry-After, then press **Right password, 10 minutes later**. The Requests view shows the timestamps, and the login succeeds because the window has ended.

On a real server you would shorten the window while testing, or write an automated test that replaces Date.now. Test the edges on purpose: exactly five attempts, the sixth, and the first one after the window. Off-by-one mistakes in limiters are common, and they show up as either an extra free guess or a member locked out one attempt early.

Who counts as one visitor?

The simplest key is req.ip, the address the request came from. It has limits you should know about. Many people can share one address: a school, an office, or a whole mobile network. Set the limit high enough that a club meeting full of members on the same Wi-Fi can still log in. One attacker can also control many addresses, and IPv6 users usually have a whole block of them, so serious limiters group IPv6 addresses by their first 64 bits.

For logins, add a second counter keyed by the email being tried. That catches an attacker who spreads guesses for one account over many addresses. Use it to slow attempts down, not to lock the account for good, or anyone could lock a member out by typing their email wrongly six times.

What changes on a real host?

Most hosts put a proxy in front of your app. Then every request seems to come from the proxy, req.ip is the same for everyone, and the first five visitors lock everybody else out. Express reads the real address from the X-Forwarded-For header when you tell it how many proxies to trust, usually with app.set('trust proxy', 1). Never set it to true without thinking: then a visitor can send a fake X-Forwarded-For and get a fresh limit with every request.

A Map lives in memory. It empties when the server restarts, and if you run two copies of the server, each keeps its own count, which doubles the allowance. For one small server that is acceptable. Larger setups keep counts in a shared store, often the database, or use a well-maintained rate limiting package for Express with a shared store behind it. Old entries also need cleaning up now and then, or the Map slowly grows with every address that ever visited.

What should you check when an AI writes a rate limiter?

Read the window logic first: is there a line that starts a new window when the old one is over? Generated limiters sometimes create the entry once and never reset it. Check that the refusing branch uses return, so the handler doesn't run anyway. Check the status is 429 with Retry-After. Then look at what it limits: a limiter on app.use for every page, set strict enough to stop password guessing, will also block normal browsing. Put strict limits on the routes that need them, such as login, sign-up and password reset, and generous ones, if any, on everything else.

Field notes

  • Without a limit, a program can try thousands of passwords against your login form.
  • A simple limiter counts attempts per visitor inside a time window, then starts a fresh window.
  • When the limit is hit, answer 429 Too Many Requests with a Retry-After header.
  • Run the limiter before the password check so every attempt counts, right or wrong.

Easy to get wrong

Without return, the middleware sends 429 and then calls next() anyway. The login handler still runs, still checks the password, and tries to send a second answer, which logs ERR_HTTP_HEADERS_SENT.

With true, Express believes whatever X-Forwarded-For says, and visitors can write that header themselves. Each guess could claim a new address and get a fresh limit. Trust exactly the proxies your host puts in front of you.

A limit tight enough for password guessing is far too tight for browsing. Loading one page with its stylesheet and images would use up the allowance. Attach strict limits only to the routes that need them.

Questions people ask

How many attempts should a login allow?

There is no single number. Five to ten attempts per account in a quarter of an hour stops online guessing while leaving room for typos. Pair it with a sensible password rule and offer a password reset, so members who forget aren't stuck.

Should I use a CAPTCHA instead?

They solve a different part of the problem and many people find them hard to use, especially with a screen reader. A rate limit works in the background for everyone. Some sites show an extra challenge only after several failures, which keeps it out of most people's way.

Does a rate limit stop a large attack on my whole site?

No. A flood from thousands of computers needs protection in front of your server, which most hosts and proxy services provide. Your own limiter protects specific actions, such as logins and orders, from abuse by one visitor or a small group.

Why does the practice server give every request the same IP address?

All requests here come from the same pretend browser, so req.ip is always the same documentation address. That is enough to see the limiter work. On a real server each visitor, or each network, has its own address.

Keep going