← Back to the library
SecurityApplied · 8 min

The login endpoint is hammered with leaked passwords

“Failed logins jump by orders of magnitude from thousands of rotating IPs, each trying a few email and password pairs, and a handful of real accounts are taken over because users reused passwords.”

ERROR CODES YOU MAY SEE

A code is a clue. Use its meaning and surrounding evidence to narrow the cause.

429HTTP status codes · RFC 6585 · standard

The client sent too many requests in a given amount of time (rate limiting). The response may include Retry-After.

Read Retry-After and any provider rate-limit headers before retrying. Check whether the limit is per key, per account, or per endpoint, and how many workers share it.

Official reference for 429 (opens in new tab)Code definition reviewed

THE PRINCIPLE

No single signal stops stuffing. Make each guess expensive, make a correct password insufficient on its own, and reveal nothing about which accounts exist.

FIRST MOVES

  1. Throttle by account as well as by IP and network; proxies rotate IPs, so IP limits alone are weak. Use temporary, escalating delays rather than permanent lockouts that enable denial of service.
  2. Return one generic message and the same status for unknown user, wrong password, and locked account, and keep response timing similar.
  3. Require MFA, or step it up for new devices and risky signals; offer passkeys, which cannot be replayed from another site's breach.
  4. Check new and changed passwords against breached-password data, such as the Pwned Passwords k-anonymity range API.
  5. Add bot detection or a challenge only for suspicious traffic, and monitor solve rates, since CAPTCHAs can be outsourced.

TOOLS: THEN → NOW

Per-IP rate limits and permanent lockoutsPer-account and per-source throttling with temporary, escalating delays
Password plus optional SMS codesPasskeys (WebAuthn) and risk-based step-up MFA
Password complexity rulesBreached-password checks at signup and password change

PATTERN SNAPSHOT

export async function login(email: string, password: string, ip: string) {
  const keys = [`login:acct:${email.trim().toLowerCase()}`, `login:ip:${ip}`];
  if (!(await throttle.allow(keys))) return { status: 429, body: "Too many attempts. Try again later." };

  const user = await users.findByEmail(email);
  // Always run a hash check so unknown accounts take the same time.
  const ok = await argon2.verify(user?.passwordHash ?? DUMMY_HASH, password);
  if (!user || !ok) {
    await throttle.recordFailure(keys);
    return { status: 401, body: "Invalid email or password." };
  }
  if (await risk.requiresStepUp(user, ip)) return { status: 200, body: { next: "mfa" } };
  return createSession(user);
}

CLOSE THE AI. EXPLAIN THIS.

Why does rate limiting by IP barely slow a credential-stuffing campaign, and which control still works when the attacker has the correct password?

HOW IT WORKS UNDERNEATH

SOURCES

Guide reviewed