Skip to content
academia.sh

Lesson 03 / 15

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.

Published Updated

In this lesson

  • 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
Contents

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.
// 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

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close