Credential stuffing is a replay attack, not a guessing attack. The attacker already has valid username and password pairs from someone else’s breach and is testing which of them also work on you. Success rates are low per attempt and completely adequate in aggregate, because the list is free and the attempts are cheap.

The two controls everyone reaches for first — a per-IP rate limit and a CAPTCHA — do work, in the narrow sense that they raise the attacker’s cost. What they mostly do is change the shape of the attack into one your logs stop recognising. This is about what is left to detect after that has happened.

What the attack looks like after your rate limit works

A stuffing run against an unprotected login is loud: thousands of POSTs from a handful of datacenter addresses, over minutes. Add a per-IP limit and the run does not stop, it redistributes. The list moves onto a residential proxy pool and becomes:

  • One attempt per exit address. A pool of a few hundred thousand residential IPs makes a per-IP counter unreachable by construction. The counter is not broken; it is counting the wrong noun.
  • Plausible geography. Exits are real consumer ISP addresses in the country the account belongs to, because the pool lets the attacker choose.
  • Low and slow. A list of ten million pairs does not need to clear in an hour. Spread over a fortnight it never looks like a spike on any dashboard you have.

What survives this is that the attacker has changed the network and kept everything else. The requests still come from a small number of machines, and machines are what a device layer measures.

Why each standard control misses

  • Per-IP rate limiting. Keep it — it is cheap and it stops the lazy version — but stop treating it as the control. Against rotating residential exits the threshold is never reached.
  • CAPTCHA. Solving services price bulk work in cents per thousand challenges. That is a tax on the attacker and a tax on every real customer who logs in, applied in the wrong ratio: the attacker pays it once per list, your users pay it once per session.
  • MFA. Genuinely effective, for enrolled accounts. Enrolment is rarely universal, and the unenrolled accounts skew towards the older, more valuable ones.
  • Breached-password screening. Stops future reuse. Does nothing about credentials that leaked last year and still work.

The signals that still separate a replay from a real login

Three layers, in the order they cost you something.

Network. network.datacenter still catches the unsophisticated half, and it costs nothing to check. network.tor is a smaller population but a strong one at a login. network.proxy against a residential exit is the modern shape and the reason network signals alone are not enough — a good residential pool looks like a customer.

Device. This is where the attack stops being able to hide, because the pool rotates addresses and not machines.

  • device.visitor_id is stable across cookie clears and incognito, so the same machine attempting account after account keeps the same id.
  • device.times_seen: 1 on a login to an account that has existed for two years is the tell. It is not proof — people buy laptops — but it is the single most useful number on the response at a login.
  • device.tampering_score above 0.6 means the browser is lying about itself, which is what an antidetect browser is for.
  • device.linked_accounts is the only signal here that is literally the attack rather than a proxy for it: one device, many distinct accounts, inside your own tenant. You get it by passing your accountId alongside fingerprintEventId.

Session shape. No token at all means no JavaScript ran, which for a login form is either a hardened browser or a script. Treat it as degraded rather than as guilt, and count how often it happens per account.

Where the check goes

Before the password comparison, and after the account lookup. That ordering is deliberate on both sides. Running it before the lookup means you cannot pass accountId and you lose linked_accounts, which is the signal worth the most. Running it after the password verify means you have spent a bcrypt round on every attempt in the run, which is a small denial-of-service the attacker gets for free.

// routes/login.js
const Sentinel = require('@sentinelsup/sdk');
const sentinel = new Sentinel({ apiKey: process.env.SENTINEL_KEY });

app.post('/api/login', loginRateLimit, async (req, res) => {
  const { email, password, monocle, sentinel_fp } = req.body;

  // Indexed read, no password work yet — but it gives the
  // verdict an accountId, which is what returns linked_accounts.
  const user = await findUserByEmail(email);

  let v = { decision: 'allow', degraded: true };
  try {
    v = await sentinel.evaluate({
      token: monocle,
      fingerprintEventId: sentinel_fp,
      accountId: user?.id,
    });
  } catch (err) {
    req.log.error({ err }, 'sentinel unavailable'); // fail open
  }

  const ok = user && await verifyPassword(user, password);

  // One response for every failure, whatever caused it.
  if (!ok || v.decision === 'block') {
    await recordAttempt(email, v);
    return res.status(401).json({ error: 'Invalid login' });
  }

  const newDevice = v.device?.times_seen === 1;
  if (v.decision === 'review' || newDevice) {
    await sendDeviceVerification(user);
    return res.json({ next: 'verify_device' });
  }

  return issueSession(res, user);
});

What to do at each band

  • allow, returning device. Issue the session. This is the overwhelming majority of logins and it should feel like nothing happened.
  • allow, times_seen: 1. A new machine on an existing account. Send the new-device notice and let it through if the account holds nothing liquid; require an emailed code if it does.
  • review. Step up. An emailed code is enough, and it converts far better than a CAPTCHA because it only fires for the few percent that earned it.
  • block. Refuse — identically to a wrong password, see below.
  • device.multi_account with linked_accounts in double digits. That is the run itself, in progress. It deserves an alert and a look at what else that device touched, not just a 401.

Bands and where to put the boundaries are covered properly in the thresholds post; the short version is to route on decision and use risk_score only for sorting a review queue.

The mistake that undoes all of it

If a blocked-but-correct credential produces a different status code, a different response body, or a visibly different latency than a wrong password, your defence has become a validation oracle. The attacker stops trying to log in and starts harvesting a verified credential list instead, which is worth more per line than the accounts are. Same status, same body, same rough timing — and do the step-up decision after both checks have run, not between them.

Failing open, and what to watch instead

Login is availability-critical in a way that signup is not: fail closed on a vendor blip and you have locked out every customer, including the ones with support contracts. Fail open, mark the verdict degraded, and alert on the rate of degraded logins rather than on individual ones. An absent verdict means “no opinion”, never “safe”, so the code around it has to be written as though it might be missing.

Store reasons and risk_score on the attempt record, not just the outcome. Six weeks later, when you want to know whether the step-up threshold is too tight, the reason codes are the only thing that can answer it.

Checklist

  • Account lookup, then evaluate, then password verify.
  • accountId and fingerprintEventId both sent, or linked_accounts is absent.
  • One identical failure response for wrong password and for blocked.
  • Step-up on review and on first-seen devices, not on every login.
  • Fail open, mark degraded, alert on the rate.
  • reasons persisted on the attempt row.
  • Per-IP rate limit still in place, but not counted on.
FAQ

Frequently Asked Questions

Is per-IP rate limiting still worth having?

Yes, but as hygiene rather than as the control. It stops unsophisticated runs from a handful of addresses for almost no cost. It cannot stop a run distributed across a residential proxy pool, because each exit makes one attempt and the counter never reaches its threshold. Keep it and add a signal that is not the IP.

Will blocking datacenter IPs stop credential stuffing?

It stops the cheap half and pushes the rest onto residential exits, which look like customers. Datacenter is worth checking because it costs nothing, but a policy built only on it degrades over a few weeks as the attacker notices. The device layer is what keeps working, because a proxy pool rotates addresses and not machines.

Should a first-seen device always trigger a step-up?

On accounts that hold money or personal data, yes — an emailed code fires for a small share of logins and is far cheaper than an account takeover. On low-value accounts a new-device notification is usually enough. What you should not do is treat times_seen: 1 as fraud on its own: people buy laptops, reinstall browsers and travel.

Does MFA make credential stuffing detection unnecessary?

For enrolled accounts it removes most of the risk. The problem is coverage: enrolment is rarely universal and the unenrolled accounts skew towards older, higher-value ones. Detection is also what tells you a run is happening at all, which MFA does not — a wall of failed second factors is a symptom you would rather see named.

One call, before the password compare

Maskbreak returns a decision, a device history and the reasons behind both in a single call. Free tier: 1,000 requests per hour, no credit card.

Try Maskbreak free →