---
title: 'Credential Stuffing Defense'
source: 'https://academia.sh/en/courses/runtime-security/credential-stuffing-defense'
course: 'Runtime Security and Secrets Management'
language: en
updated: '2026-10-07T04:31:05+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Runs account and address thresholds over the same attempt record and separates the attack shape each threshold stops'
    - 'Measures the number of blocked attempts and the number of account takeovers prevented separately'
    - 'Compares how many times cheaper the same takeover is closed at the code and design stages'
---

# Credential Stuffing Defense

Running account and address thresholds separately over a one-hour login attempt record and separating which threshold stops which attack shape: the account threshold stops brute force and the address threshold stops concentrated-source spraying, while stuffing with a single attempt per account passes both; the gap between the number of attempts blocked and the number of account takeovers prevented, and the lockout of the shared egress address and of the legitimate account being targeted.

The previous lesson tied the rate counter to a single key and never asked what that key was. The
state of the login attempt series stayed uncertain for exactly this reason: the rate per second
was known, but how many accounts and how many addresses the attempts were spread across was not.
The same total rate is one kind of attack when loaded onto a single account and a very different
kind when distributed as a single attempt each across 4,000 accounts. This lesson moves the
threshold off the rate axis and onto the account and address axes.

The names of the two shapes are kept apart. **Credential stuffing** is the trying, in this
system, of account–password pairs that leaked from somewhere else; usually a single attempt is
made per account, and the hope is that the same password was used here too. **Spraying** is the
trying of a small number of common passwords across many accounts; a few attempts fall on each
account. Both are sparse on the account axis, but they show different density on the address axis,
and a defense can measure exactly this difference.

## Three Attack Shapes, Two Axes

Everything below is a model built with `node`. No attempt is actually made, and no credential list
or attempt tool is written; the attack is represented only as a record row, with target account
and source address fields.

- **AD11.** The 1,900 real accounts, the attack shapes, and their counts belong to the fictional
  measurement network. The record is generated with a visible seed; because the generator is
  written in the lesson itself, the run is repeatable.
- **AD12.** Whether an attempt succeeds derives in the model from a property of the account:
  stuffing succeeds only on an account whose password belongs to a leaked pair, and spraying
  succeeds only on an account whose password is common.
- **AD13.** The threshold looks at the number of **failed** attempts accumulated within the
  window; a successful attempt does not increment the counter. This is the common behavior in
  production, and it is the source of this lesson's main finding.
- **AD14.** The legitimate failure rate is 12%, and the whole field crew comes from a single
  shared egress address. Thirty subscribers also forget their passwords and try one after
  another.
- **AD15.** When the threshold is exceeded, later attempts are blocked; an account lock and an
  address throttle give the same result. In reality the two take different times to clear, which
  does not change the direction of the numbers.

```js
// login.mjs — one-hour login attempt record; a model, no real attempts are generated
export const SEED = 20250804;
let s = SEED;
const rnd = () => (s = (s * 48271) % 2147483647) / 2147483647;
const int = (n) => Math.floor(rnd() * n);

// real accounts: is a second factor enrolled, does the password belong to a leaked pair, is it common
export const ACCOUNTS = [];
for (let i = 0; i < 1900; i++)
  ACCOUNTS.push({ name: `account-${i}`, factor: rnd() < 0.86, reused: rnd() < 0.038, common: rnd() < 0.009 });
export const BY_NAME = new Map(ACCOUNTS.map((a) => [a.name, a]));

// [minute, account, address, class, success]
export function generateRecords() {
  const k = [];
  for (let i = 0; i < 500; i++)                      // field crew: a single shared egress address
    k.push([int(60), ACCOUNTS[int(380)].name, 'shared-egress', 'legit', rnd() > 0.12]);
  for (let i = 0; i < 1900; i++)                     // portal subscribers: scattered addresses
    k.push([int(60), ACCOUNTS[380 + int(1520)].name, `subscriber-addr-${int(900)}`, 'legit', rnd() > 0.12]);
  for (let i = 0; i < 30; i++) {                     // subscriber who forgot the password: consecutive attempts
    const n = 6 + int(4), t0 = int(50), addr = `subscriber-addr-${int(900)}`, acct = ACCOUNTS[380 + int(1520)].name;
    for (let j = 0; j < n; j++) k.push([t0 + j, acct, addr, 'legit', j === n - 1]);
  }
  for (let i = 0; i < 4000; i++) {                   // stuffing: a single attempt per account, 400 addresses
    const acct = i < 1900 ? ACCOUNTS[i] : null;
    k.push([int(60), acct ? acct.name : `unknown-${i}`, `stuffing-addr-${i % 400}`, 'stuffing', Boolean(acct && acct.reused)]);
  }
  for (let r = 0; r < 3; r++)                        // spraying: three rounds, one attempt per account
    for (let i = 0; i < 1900; i++)
      k.push([5 + r * 20 + int(5), ACCOUNTS[i].name, `spraying-addr-${i % 40}`, 'spraying',
        ACCOUNTS[i].common && r === i % 3]);
  for (let i = 0; i < 600; i++)                      // brute force: a single account, a single address
    k.push([10 + Math.floor(i / 120), ACCOUNTS[7].name, 'brute-addr', 'bruteforce', false]);
  return k.sort((a, b) => a[0] - b[0]);
}

// threshold: failed attempts accumulated within the window; once exceeded, later attempts are blocked
export function measure(records, accountLimit, addressLimit) {
  const accountFails = new Map(), addressFails = new Map(), locked = new Set();
  const byKind = {}, taken = [];
  let blockedLegit = 0, legitByAccount = 0, legitByAddress = 0, blockedBad = 0, passedBad = 0;
  for (const [, account, address, cls, success] of records) {
    const f = accountFails.get(account) || 0, g = addressFails.get(address) || 0;
    const blocked = f >= accountLimit || g >= addressLimit;
    if (cls === 'legit') {
      if (blocked) { blockedLegit++; locked.add(account); f >= accountLimit ? legitByAccount++ : legitByAddress++; }
      else if (!success) { accountFails.set(account, f + 1); addressFails.set(address, g + 1); }
      continue;
    }
    byKind[cls] = byKind[cls] || { blocked: 0, passed: 0, takenOver: 0 };
    if (blocked) { blockedBad++; byKind[cls].blocked++; continue; }
    passedBad++; byKind[cls].passed++;
    if (success) { byKind[cls].takenOver++; taken.push([account, cls]); }
    else { accountFails.set(account, f + 1); addressFails.set(address, g + 1); }
  }
  return { blockedLegit, legitByAccount, legitByAddress, lockedAccounts: locked.size,
    blockedBad, passedBad, takenOver: taken, byKind };
}
```

## Which Threshold Sees What

```js
// run-thresholds.mjs — the same record; the only thing that changes is the account and address thresholds
import { SEED, generateRecords, measure } from './login.mjs';

const s = (x, n) => String(x).padStart(n);
const K = generateRecords();
const count = (f) => K.filter(f).length;
const I = Infinity;
console.log(`seed ${SEED}; 60 minutes, ${K.length} attempts` +
  ` (${count((r) => r[3] === 'legit')} legit / ${count((r) => r[3] !== 'legit')} bad)\n`);

console.log(`${'attack kind'.padEnd(14)}${s('attempts', 9)}${s('accounts', 10)}${s('addresses', 11)}` +
  `${s('per account', 14)}${s('per address', 14)}${s('accounts taken over', 21)}`);
for (const t of ['stuffing', 'spraying', 'bruteforce']) {
  const r = K.filter((x) => x[3] === t);
  const a = new Set(r.map((x) => x[1])).size, d = new Set(r.map((x) => x[2])).size;
  console.log(`${t.padEnd(14)}${s(r.length, 9)}${s(a, 10)}${s(d, 11)}` +
    `${s((r.length / a).toFixed(1), 14)}${s((r.length / d).toFixed(1), 14)}` +
    `${s(r.filter((x) => x[4]).length, 21)}`);
}

const SETTINGS = [['no threshold', I, I], ['account 5', 5, I], ['address 50', I, 50],
  ['account 5 + address 50', 5, 50], ['account 3 + address 20', 3, 20]];
console.log(`\n${'setting'.padEnd(24)}${s('blocked bad', 17)}${s('passed bad', 12)}` +
  `${s('taken over', 13)}${s('blocked legit', 18)}${s('locked accounts', 17)}`);
for (const [name, ae, de] of SETTINGS) {
  const r = measure(K, ae, de);
  console.log(`${name.padEnd(24)}${s(r.blockedBad, 17)}${s(r.passedBad, 12)}${s(r.takenOver.length, 13)}` +
    `${s(r.blockedLegit, 18)}${s(r.lockedAccounts, 17)}`);
}

console.log(`\nblocked attempts by attack kind:`);
console.log(`${'setting'.padEnd(24)}${['stuffing', 'spraying', 'bruteforce'].map((t) => s(t, 12)).join('')}`);
for (const [name, ae, de] of SETTINGS.slice(1)) {
  const r = measure(K, ae, de);
  console.log(`${name.padEnd(24)}` +
    ['stuffing', 'spraying', 'bruteforce'].map((t) => s(r.byKind[t].blocked, 12)).join(''));
}
```

```
seed 20250804; 60 minutes, 12923 attempts (2623 legit / 10300 bad)

attack kind    attempts  accounts  addresses   per account   per address  accounts taken over
stuffing           4000      4000        400           1.0          10.0                   57
spraying           5700      1900         40           3.0         142.5                   13
bruteforce          600         1          1         600.0         600.0                    0

setting                       blocked bad  passed bad   taken over     blocked legit  locked accounts
no threshold                            0       10300           70                 0                0
account 5                             669        9631           69               220               91
address 50                           4244        6056           63               227              164
account 5 + address 50               4301        5999           62               364              194
account 3 + address 20               5512        4788           57               613              315

blocked attempts by attack kind:
setting                     stuffing    spraying  bruteforce
account 5                         18          55         596
address 50                         0        3694         550
account 5 + address 50            11        3694         596
account 3 + address 20            15        4899         598
```

The first table puts the two axes side by side, and the distinction is read there. Brute force sits
at the extreme on the account axis with 600 attempts; when the account threshold is set to 5, 596
of its attempts are blocked, so this shape counts as finished with a single threshold. Spraying is
sparse on the account axis (3 attempts per account, under the threshold), but because it is
squeezed into 40 addresses it rises to 142.5 attempts per address; when the address threshold is
set to 50, 3,694 of its attempts are blocked. Stuffing stays under the threshold on both axes: one
attempt per account, ten per address. The address threshold blocks **zero** attempts from this
shape.

The 15 and 18 seen in the stuffing column of the lower table come not from stuffing's own density
but from the same accounts also being targeted by spraying: when the failures of the two shapes
add up on the same account counter, a few accounts cross the threshold. That is, stuffing is seen
only indirectly, and only when another shape is next to it.

The last two columns of the middle table show the cost. At account threshold 5, 91 legitimate
accounts are locked, and only 30 of them are subscribers who forgot their passwords; the rest were
locked **because they were the target of the attack**. Failed attempts aimed at their accounts
filled the counter, and the real owner could no longer get in. At address threshold 50, 227
legitimate attempts are cut; all belong to the field crew coming from the shared egress address,
because the 12% failure share of the 500 attempts passing through that address crosses the
threshold on its own. The address threshold cuts not an account but a team.

## The Takeover the Threshold Does Not Prevent

The column most likely to be overlooked in the table above is the "taken over" column. The number
that stands out in a dashboard is the blocked attempts, and as the setting tightens that
number climbs from hundreds to thousands. The taken over column, by contrast, barely moves: 70
accounts are taken over in the run with no threshold, and 57 at the tightest setting. 5,512
attempts were blocked, and takeovers fell by only 13. These two numbers have to sit in the same
table, because the purpose of the defense is not to reduce attempts but to prevent the account
from being taken over.

```js
// stage-cost.mjs — which stage closes the same account takeover earlier
import { ACCOUNTS, BY_NAME, generateRecords, measure } from './login.mjs';

const s = (x, n) => String(x).padStart(n);
const K = generateRecords(), I = Infinity;
const legit = K.filter((r) => r[3] === 'legit').length;
const base = measure(K, I, I), tight = measure(K, 3, 20);
const noFactor = (r) => r.takenOver.filter(([name]) => !BY_NAME.get(name).factor).length;
const setting = ACCOUNTS.filter((a) => a.reused || a.common).length;

console.log(`${'measure'.padEnd(34)}${s('stage', 16)}${s('taken over', 13)}${s('legit cost', 30)}`);
for (const [a, b, c, d] of [
  ['no measure', '-', base.takenOver.length, '0'],
  ['account 3 + address 20 threshold', 'runtime', tight.takenOver.length,
    `${tight.blockedLegit} attempts, ${tight.lockedAccounts} accounts`],
  ['second factor, 86% coverage', 'code', noFactor(base), `extra step in ${legit} sessions`],
  ['threshold and second factor', 'both', noFactor(tight), `${tight.blockedLegit} attempts, extra step`],
  ['pair check at password setting', 'design', 0, `${setting} accounts change password`],
]) console.log(`${a.padEnd(34)}${s(b, 16)}${s(c, 13)}${s(d, 30)}`);

console.log(`\nsecond factor coverage ${ACCOUNTS.filter((a) => a.factor).length}/${ACCOUNTS.length};` +
  ` the ${ACCOUNTS.filter((a) => !a.factor).length} accounts outside coverage stay vulnerable at every setting\n`);

console.log(`${'setting'.padEnd(24)}${s('account threshold cut', 23)}${s('address threshold cut', 23)}` +
  `${s('locked legit accounts', 23)}`);
for (const [name, ae, de] of [['account 5', 5, I], ['address 50', I, 50],
  ['account 5 + address 50', 5, 50], ['account 3 + address 20', 3, 20]]) {
  const r = measure(K, ae, de);
  console.log(`${name.padEnd(24)}${s(r.legitByAccount, 23)}${s(r.legitByAddress, 23)}${s(r.lockedAccounts, 23)}`);
}
console.log(`\nlegit attempts from the shared egress address: ` +
  `${K.filter((r) => r[3] === 'legit' && r[2] === 'shared-egress').length}`);
```

```
measure                                      stage   taken over                    legit cost
no measure                                       -           70                             0
account 3 + address 20 threshold           runtime           57    613 attempts, 315 accounts
second factor, 86% coverage                   code            5   extra step in 2623 sessions
threshold and second factor                   both            4      613 attempts, extra step
pair check at password setting              design            0   70 accounts change password

second factor coverage 1639/1900; the 261 accounts outside coverage stay vulnerable at every setting

setting                   account threshold cut  address threshold cut  locked legit accounts
account 5                                   220                      0                     91
address 50                                    0                    227                    164
account 5 + address 50                      138                    226                    194
account 3 + address 20                      220                    393                    315

legit attempts from the shared egress address: 500
```

The upper table answers the course's main question for this lesson. The runtime threshold blocked
53% of bad attempts and brought takeovers down from 70 to 57: the share of takeovers prevented is
19%. The reason lies not in the setting of the threshold but in its definition. The threshold
counts **failures**; in stuffing, the successful attempt is the first attempt the account sees,
and at that moment the counter is empty. The counter fills only after failures accumulate, so the
threshold structurally carries the property of being late.

The same takeover falls from 70 to 5 at the code stage with a second factor. Why the 5 is not zero
is also written in the table: second factor coverage is 1,639 accounts, and the 261 accounts
outside coverage are protected at no threshold setting. Coverage is this measure's only real
setting. Its price is an extra step in 2,623 legitimate sessions; no session is cut, though some
sessions are delayed.

The measure at the design stage closes it at an even lower cost. When leaked pairs and common
passwords are checked as the password is set, the success condition of both stuffing and spraying
disappears: the taken over count is zero. Its price is 70 accounts changing their passwords, and
this is a one-time event. For the same item, the runtime threshold cuts 613 legitimate attempts
every day and locks 315 accounts; the check at design closes the item once.

The lower table separates where the false block lands. The account threshold puts the cut on the
account owner, and the address threshold puts it on everyone behind a network location. 500
legitimate attempts sit behind the shared egress address, and when the address threshold is
lowered to 20, the attempts cut from that address rise to 393. The address threshold hands out
punishment according to the size of the team behind the address rather than the targeted attacker.

## Summary

- The three attack shapes separate on two axes: brute force, at 600 attempts per account, stops at
  the account threshold (596 blocked); spraying, at 142.5 attempts per address, stops at the
  address threshold (3,694 blocked); stuffing, at 1.0 per account and 10.0 per address, is under
  both.
- The address threshold blocked zero attempts from stuffing. The 15–18 attempts seen in the lower
  table are an indirect catch that comes from the same accounts also being targeted by spraying.
- 70 accounts were taken over in the run with no threshold, and 57 at the tightest setting. While
  blocked attempts rose to 5,512, takeovers prevented are only 13 (19%), because the threshold
  counts failures and in stuffing the successful attempt is the account's first attempt.
- The false block lands in two different places: of the 91 accounts locked at account threshold 5,
  only 30 are subscribers who forgot their passwords, and the rest were locked because they were
  the target of the attack. All 227 attempts cut at address threshold 50 belong to the field crew
  at the shared egress address.
- The same takeover falls from 70 to 5 at the code stage with a second factor (the remaining 5
  from the 261 accounts outside coverage), and to 0 at design with a password setting check. The
  price at design is a one-time password change on 70 accounts; the price of the runtime threshold
  is 613 legitimate attempts cut every day.

## Next Step

The thresholds of this lesson, and the rules and counters of the previous two lessons, all looked
at requests or attempts one by one. What the three shared was the assumption that the defense
looks at information arriving with the request. Yet there is one more decision made at runtime,
given by the server and having nothing to do with the content of the request: the security
headers sent along with the response. They stop no request; they determine what the browser is
allowed to do, and when managed from a single place they cover all the applications behind them at
once. The next lesson measures this layer not by the requests it catches but by **the number of
applications it covers**, and puts the main question to the end left outside coverage.
