DEV Community

Rahul S
Rahul S

Posted on

Credential Stuffing Doesnt Break Your Login. It Drowns It. Heres a Pre-Auth Filter.

Credential stuffing is the most boring attack in the world, which is exactly why it works. There's no exploit, no injection, no clever bypass. Someone bought a list of a few million email/password pairs from an unrelated breach, and now a botnet is quietly replaying them against your login form at a few requests per second per IP, spread across ten thousand IPs. Every single request is a valid, well-formed login attempt. That's the whole problem.

I want to talk about why the usual defenses miss this, and a cheap filter that actually bites.

Why lockouts and rate limits don't catch it

The instinct is to reach for per-account lockout ("5 failed attempts, freeze the account") and per-IP rate limiting. Both are fine, both are worth having, and neither one sees a modern stuffing run.

Per-account lockout assumes the attacker hammers one account. Stuffing does the opposite — it tries one password against a million different accounts. Each account sees a single failed attempt. Your lockout counter never moves. Worse, if the attacker knows you lock accounts, they'll deliberately trip it to lock out your real users — you've built them a denial-of-service button.

Per-IP rate limiting assumes the volume comes from one place. It doesn't. The list is fanned out across a residential proxy network, so each individual IP makes three or four requests an hour and looks exactly like a human on hotel wifi. Your threshold is either high enough to let them through or low enough to block real people on shared NATs.

The reason both fail is the same: they're counting behavior over time on an axis (account, IP) the attacker deliberately spreads thin. By the time you have enough events to be confident, the credentials that were going to work already worked.

Move the decision one layer up — before the password check

Here's the reframe. You don't need to know whether this login will succeed. You need to know whether this request is worth spending a bcrypt verification on at all.

A password check is expensive on purpose — that's the point of a slow hash. Credential stuffing turns that against you: every junk attempt costs you real CPU. So do the cheap check first. Before you touch the password, score the source: the IP and the email address. Botnets have a texture that individual requests give away, even when the credentials are real.

  • The IP is far more likely to be a datacenter range, a hosting provider, or a known proxy/VPN exit than a residential connection. Real humans logging in from an AWS IP is rare; a login wave from one is not.
  • The email, in aggregate, skews toward the reused, the disposable, and the previously-flagged. A single account per real user; a botnet churns through addresses that already show up on abuse lists.

Neither signal is proof on its own. Together, cheaply, they let you tier your response.

The filter, in code

Score before the hash. Here's the shape in Express:

import { verifyPassword } from "./auth.js";

async function scoreSource(ip, email) {
  const url =
    `https://api.ipasis.com/v1/validate-email` +
    `?email=${encodeURIComponent(email)}&ip=${encodeURIComponent(ip)}`;
  try {
    const res = await fetch(url, {
      headers: { "X-API-Key": process.env.IPASIS_KEY },
      signal: AbortSignal.timeout(150),
    });
    if (!res.ok) return null;      // fail OPEN — see below
    return (await res.json()).risk; // { score: 0..100, recommendation, ... }
  } catch {
    return null;                   // timeout / network — fail open
  }
}

app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const ip = req.headers["x-forwarded-for"]?.split(",")[0] ?? req.ip;

  const risk = await scoreSource(ip, email);
  // null (scoring unavailable) => ALLOW, i.e. proceed to the normal check
  const rec = risk?.recommendation ?? "ALLOW";

  if (rec === "BLOCK") {
    // High-confidence bot source. Don't run the hash, don't reveal anything.
    return res.status(200).json({ status: "ok" }); // soft, ambiguous
  }

  const ok = await verifyPassword(email, password); // now spend the CPU
  if (!ok) return res.status(401).json({ error: "invalid credentials" });

  if (rec === "REVIEW") {
    // Middle tier: right creds, suspicious source — step up, don't wall off.
    return res.status(200).json({ status: "step_up", challenge: "email_otp" });
  }
  return res.status(200).json({ status: "ok", token: issueToken(email) });
});
Enter fullscreen mode Exit fullscreen mode

The call returns in under 20ms, so it fits in the login path without users feeling it. I used IPASIS here because a single validate-email request returns both IP reputation and email risk, which keeps the pre-auth path to one round trip — and it hands you a risk.recommendation of ALLOW / REVIEW / BLOCK directly, so the tiering falls out of the response instead of you hand-tuning score cutoffs. There's a free tier (no credit card) to wire this up against your real login traffic, and the response fields are in the docs. Any provider that gives you both signals works; the architecture is the point, not the vendor.

Three things that will bite you if you skip them

Fail open, never closed. If the scoring call times out or errors, let the login proceed to the normal password check. A fraud filter that takes down your login when a third party has a bad day is a worse outage than the attack. Note the ?? 100 — absence of a score means proceed, not block.

Don't return a clean failure to the bad tier. If you respond to a low-score request with a crisp 401, you've built the attacker an oracle: they learn which of their IPs your filter flags and rotate away from them. Return the same soft, ambiguous 200 you'd return for a wrong password, or a generic error. Make the filter invisible.

Score, don't hard-block. A risk score is a prior, not a verdict — which is exactly why the tiered recommendation matters. Use the top tier (BLOCK) to skip the hash and stay quiet, use the middle (REVIEW) to add a step-up — a second factor, an email OTP — rather than a wall, and let the rest straight through. Notice the step-up only fires after the password already matched: a suspicious source with the correct credentials is the account-takeover case you most want a second factor on, and you're not challenging people who'd have failed anyway. Hard-blocking on a raw categorical like "datacenter IP" will eventually catch a real customer behind a corporate proxy, and you'll never hear about the ones who just gave up.

The mental model that ties it together: rate limits and lockouts are reactive — they need a pattern to accumulate. Scoring the source is pre-emptive — it reads the texture of a single request before you've spent anything on it. Distributed stuffing is specifically designed to defeat the first kind. That's the whole reason to add the second.

Top comments (0)