Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 8 – A07:2025 Authentication Failures

Login defences: stuffing, spraying, enumeration

How credential stuffing and password spraying show up in a sign-in log, and the defences that blunt them: throttles, breached-password checks and one answer.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Distinguish credential stuffing, password spraying and one-account guessing by the shape they leave in a sign-in log
  • Configure throttles by account and source, and give sign-in, sign-up and reset the same answer for every outcome
  • Check a new password against breached ones with a k-anonymity range lookup that sends five hex characters
  • Write detection rules over an authentication log and judge them by precision and by the accounts they catch
  • Decide where a second factor, a CAPTCHA and device recognition belong in the sign-in flow

Before you start

What you’ll learn · Before you start

What you will learn

  • Distinguish credential stuffing, password spraying and one-account guessing by the shape they leave in a sign-in log
  • Configure throttles by account and source, and give sign-in, sign-up and reset the same answer for every outcome
  • Check a new password against breached ones with a k-anonymity range lookup that sends five hex characters
  • Write detection rules over an authentication log and judge them by precision and by the accounts they catch
  • Decide where a second factor, a CAPTCHA and device recognition belong in the sign-in flow

Before you start

On this page

In one made-up hour at a site with 20,000 accounts, 6,000 sign-in attempts arrived with email and password pairs from another site’s breach. They opened 43 accounts, and not one account failed more than once, so a lock after five failures would never have fired.

Defending sign-in starts with the shape of that traffic: how many accounts it names, how often each one, from how many sources, and how many of the names have no account here at all. You will tell the common shapes apart in a log, place throttles where each one bites, give every outcome of sign-in, sign-up and reset the same answer, check passwords against breached ones without sending them anywhere, and decide where a second factor, a CAPTCHA and device recognition belong.

Stuffing, spraying and one-account guessing leave different shapes in a log

OWASP’s Credential Stuffing Prevention Cheat Sheet tells three kinds of automated guessing apart by what they try. credential stuffing: Trying username and password pairs that leaked from another site's breach against your sign-in, betting that people reuse passwords. Each account is tried once or twice, from many sources, and many of the names have no account on your site. tests username and password pairs taken from the breach of another site. password spraying: Trying one or a few common passwords against many accounts, slowly, so that no single account sees enough failures to trip a per-account limit. Screening common passwords at sign-up removes what it looks for. tests one weak password against a large number of accounts. Brute force tests many passwords against one account. The Top 10’s A07 adds a hybrid that tries variations of leaked passwords, and its example is a season with the year moved on by one, Winter2025 becoming Winter2026.

The hour below is a model, and every rate in it is the model’s own assumption. 3,000 people sign in, and three automated senders run beside them. A real log has no column that says who sent an attempt, so the program uses its own labels to show the shape each sender leaves:

The shape each sender leaves in one hour of sign-ins

JavaScript (shapes.mjs) · night/shapes.mjs

// The shape each sender leaves in the hour's log: how many names it tries, how often per name and per source, and
// how many of its names have no account here. The log itself has no "sender" column: the model labels it.
import { buildHour } from './traffic.mjs';

const hour = buildHour();
const senders = ['people', 'pairs', 'spray', 'one-account'];
const most = (map) => Math.max(...map.values());
const rows = senders.map((who) => {
  const mine = hour.filter((a) => a.origin === who);
  const perAccount = new Map();
  const perSource = new Map();
  for (const a of mine) {
    if (a.account !== null) perAccount.set(a.account, (perAccount.get(a.account) ?? 0) + 1);
    perSource.set(a.source, (perSource.get(a.source) ?? 0) + 1);
  }
  const unknown = mine.filter((a) => a.account === null).length;
  return [who, mine.length, perAccount.size, `${Math.round((100 * unknown) / mine.length)} %`, most(perAccount), perSource.size, most(perSource), mine.filter((a) => a.password === 'right').length];
});
const head = ['sender', 'tries', 'accounts', 'no account', 'most/account', 'sources', 'most/source', 'right password'];
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
const line = (cells) => cells.map((c, i) => (i === 0 ? String(c).padEnd(widths[i]) : String(c).padStart(widths[i]))).join('  ');
console.log(line(head));
for (const r of rows) console.log(line(r));

Output

sender       tries  accounts  no account  most/account  sources  most/source  right password
people        3265      3001         1 %             2     2701            4            3001
pairs         6000      3253        46 %             1     1500            4              43
spray         6000      2000         0 %             3       10          600              13
one-account    400         1         0 %           400        1          400               1

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node shapes.mjs

JavaScript (traffic.mjs) · night/traffic.mjs

// One made-up hour of sign-in attempts at a site with 20,000 accounts. Every number below is this model's own
// assumption, chosen to show the shapes clearly; none is a measurement of a real site.

/** A seeded random source (mulberry32), so every run builds the same hour. */
export function seeded(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export const ACCOUNTS = 20000;
const COMMON = ['common-1', 'common-2', 'common-3']; // three passwords from a list of common ones

/**
 * The hour as a list of attempts in time order: { t, account, source, knownDevice, password, origin }.
 * account is null when the name has no account here; origin says who sent it (the model's label, never in a log).
 * screened: true when the site refused common passwords at registration, so no account uses one.
 */
export function buildHour({ seed = 12, screened = false } = {}) {
  const r = seeded(seed);
  const pick = (n) => Math.floor(r() * n);
  const attempts = [];
  // Passwords that matter here: 1 account in 170 uses a common password (none when the site screened them at
  // registration), and the owners of 1 in 100 of the accounts on the outside list use the same password here as there.
  const common = new Map();
  if (!screened) for (let a = 13; a < ACCOUNTS; a += 170) common.set(a, COMMON[Math.floor(a / 170) % 3]);
  // 3,000 people sign in: 8 in 100 mistype the password once, 1 in 100 first type a name with no account here.
  for (let i = 0; i < 3000; i++) {
    const account = 100 + i * 6;
    const source = `home-${i % 2700}`; // 300 people share an address with someone else
    const knownDevice = r() >= 0.05;
    let t = pick(3600);
    if (r() < 0.01) attempts.push({ t: t++, account: null, source, knownDevice, password: 'wrong', origin: 'people' });
    if (r() < 0.08) attempts.push({ t: (t += 2), account, source, knownDevice, password: 'wrong', origin: 'people' });
    attempts.push({ t: Math.min(3599, t + 5 + pick(15)), account, source, knownDevice, password: 'right', origin: 'people' });
  }
  // The owner of account 77 signs in at minute 40, from home, on a device the site knows.
  attempts.push({ t: 2400, account: 77, source: 'home-owner-77', knownDevice: true, password: 'right', origin: 'people' });
  // Pairs from another site's breach: 6,000 names, 45 in 100 with no account here, one try each, between minute 10
  // and minute 50, from 1,500 sources.
  const used = new Set();
  for (let k = 0; k < 6000; k++) {
    let account = null;
    if (r() >= 0.45) {
      do account = pick(ACCOUNTS); while (used.has(account));
      used.add(account);
    }
    const reused = account !== null && r() < 0.01;
    attempts.push({ t: 600 + pick(2400), account, source: `pairs-${k % 1500}`, knownDevice: false, password: reused ? 'right' : 'wrong', origin: 'pairs' });
  }
  // Spraying: three common passwords against 2,000 existing accounts, a quarter of an hour apart, from 10 sources.
  const sprayed = new Set();
  for (let k = 0; k < 2000; k++) {
    let account;
    do account = pick(ACCOUNTS); while (sprayed.has(account));
    sprayed.add(account);
    const start = pick(900);
    COMMON.forEach((pw, j) => {
      attempts.push({ t: start + j * 900 + pick(600), account, source: `spray-${k % 10}`, knownDevice: false, password: common.get(account) === pw ? 'right' : 'wrong', origin: 'spray' });
    });
  }
  // One account, 400 guesses from one source, one every 9 seconds; guess 300 is that account's password.
  for (let k = 0; k < 400; k++) {
    attempts.push({ t: k * 9, account: 77, source: 'one-source', knownDevice: false, password: k === 299 ? 'right' : 'wrong', origin: 'one-account' });
  }
  return attempts.sort((x, y) => x.t - y.t);
}

Read the table row by row. The pairs named 3,253 existing accounts once each, and 46 % of their names had no account here, because the list was made on another site. The spray tried 2,000 accounts three times each, a quarter of an hour apart, from only 10 sources, so each source sent 600 attempts. The one-account guessing made 400 tries on account 77 from a single source. People made 3,265 attempts on 3,001 accounts, at most 2 per account, and 1 % of their names had no account, which is what typing mistakes look like.

The shapes decide what a defence has to count:

Sender Tries per account, then per source Names with no account The count that sees it
Pairs from a breach 1, 4 46 % The share of names with no account, across the site
Spraying 3 (spread out), 600 none Failures on many accounts from one source
One-account guessing 400, 400 none Failures on one account
People 1 or 2, 1 to 4 1 % Nothing: this is the normal shape

Key idea

Each kind of automated guessing has a shape: pairs from a breach are thin and wide with many unknown names, spraying is wide from a few sources, and one-account guessing is tall. A control sees only the shape it counts.

Checkpoint

Check: The shapes of automated guessing

  1. Question 1 of 2 In the model hour, 46 % of the names that the pairs from a breach tried had no account on the site. Why so many?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The list was made on another site, so many of its addresses never signed up here

    A list of pairs from another site's breach names that site's users. Many of them have no account on yours, so a wave of pairs raises the share of names with no account far above the 1 % of people's typing mistakes.

  2. Question 2 of 2 The spray tried 2,000 accounts three times each, a quarter of an hour apart, from 10 sources. Which count shows it best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Failures on many different accounts from one source

    Spraying keeps each account under its limit by trying few passwords per account, so it is seen where its attempts meet: a few sources that each fail on hundreds of accounts.

Each throttle sees one shape, and a lock on the account alone shuts out owners

A throttle counts something, and it stops only the traffic that drives its count up. The program replays the same hour against seven sets of controls, one more control on each row. A delay doubles after each failure, from 1 second up to 60, and a source limit refuses a source that failed 20 times in the last 10 minutes:

The same hour against seven sets of sign-in controls JavaScript · night/controls.mjs
// The same hour against seven sets of sign-in controls. "Checked" counts password checks the site ran; "opened"
// counts accounts that a sender other than their owner signed in to; the last two columns are what people paid.
import { buildHour } from './traffic.mjs';

function run({ delay = null, sourceLimit = false, screened = false, stepUp = false, secondFactor = false }) {
  const hour = buildHour({ screened });
  const fails = new Map(); // delay key -> { n, last }
  const sourceFails = new Map(); // source -> failure times in the last 10 minutes
  const unknownWindow = []; // [t, namedNoAccount] for the last 5 minutes
  let unknownInWindow = 0;
  let stepUpUntil = -1;
  const opened = { pairs: new Set(), spray: new Set(), 'one-account': new Set() };
  const refused = new Set();
  const challenged = new Set();
  let checked = 0;
  hour.forEach((a, i) => {
    const person = a.origin === 'people' ? `${a.account}|${a.source}` : null;
    // The site-wide share of attempts that name no account, over the last 5 minutes.
    unknownWindow.push([a.t, a.account === null]);
    if (a.account === null) unknownInWindow++;
    while (unknownWindow[0][0] <= a.t - 300) if (unknownWindow.shift()[1]) unknownInWindow--;
    const share = unknownInWindow / unknownWindow.length;
    if (stepUp && unknownWindow.length >= 50 && share > 0.1) stepUpUntil = a.t + 900;
    const key = delay === 'account' ? `${a.account ?? `none-${i}`}` : `${a.account ?? `none-${i}`}|${a.source}`;
    const f = fails.get(key);
    if (delay && f && a.t < f.last + Math.min(2 ** (f.n - 1), 60)) {
      if (person) refused.add(person);
      return; // 429: refused before any password check
    }
    const recent = (sourceFails.get(a.source) ?? []).filter((t) => t > a.t - 600);
    sourceFails.set(a.source, recent);
    if (sourceLimit && recent.length >= 20) {
      if (person) refused.add(person);
      return;
    }
    checked++;
    if (a.password !== 'right') {
      fails.set(key, { n: (f?.n ?? 0) + 1, last: a.t });
      recent.push(a.t);
      return;
    }
    fails.delete(key);
    const needsCode = secondFactor || (a.t <= stepUpUntil && !a.knownDevice);
    if (person) {
      if (needsCode) challenged.add(person);
      return;
    }
    if (!needsCode) opened[a.origin].add(a.account);
  });
  return [checked, opened.pairs.size, opened.spray.size, opened['one-account'].size, refused.size, challenged.size];
}

const rows = [
  ['no controls', {}],
  ['delay per account', { delay: 'account' }],
  ['delay per account+source', { delay: 'pair' }],
  ['+ limit per source', { delay: 'pair', sourceLimit: true }],
  ['+ common passwords refused', { delay: 'pair', sourceLimit: true, screened: true }],
  ['+ code on spike, new device', { delay: 'pair', sourceLimit: true, screened: true, stepUp: true }],
  ['+ second factor for all', { delay: 'pair', sourceLimit: true, screened: true, stepUp: true, secondFactor: true }],
].map(([name, opts]) => [name, ...run(opts)]);
const head = ['controls', 'checked', 'opened: pairs', 'spray', 'one account', 'people refused', 'people asked for a code'];
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
const line = (cells) => cells.map((c, i) => (i === 0 ? String(c).padEnd(widths[i]) : String(c).padStart(widths[i]))).join('  ');
console.log(line(head));
for (const r of rows) console.log(line(r));

Output

controls                     checked  opened: pairs  spray  one account  people refused  people asked for a code
no controls                    15665             43     13            1               0                        0
delay per account              15317             43     13            0               7                        0
delay per account+source       15327             43     13            0               0                        0
+ limit per source             10395             43      4            0               0                        0
+ common passwords refused     10391             43      0            0               0                        0
+ code on spike, new device    10391              1      0            0               0                      115
+ second factor for all        10391              0      0            0               0                     3001

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node controls.mjs

With no controls, 57 accounts were opened: 43 by the pairs, 13 by the spray and 1 by the guessing on account 77. A delay keyed by the account alone stopped the 400 guesses, and it also refused 7 people, owners of accounts on which the other senders were failing at the same time. Keyed by account and source together, the same delay stopped the guessing and refused nobody, because each owner signs in from a source of their own.

The source limit cut the spray from 13 accounts to 4, because the spray came from 10 busy sources. It did nothing to the pairs, which sent 4 attempts per source. Refusing common passwords at sign-up removed the spray’s last 4, since no account was left with one of the three passwords it tried. The pairs stayed at 43 until the last two rows. A code sent to unknown devices while names with no account spiked brought them to 1, at the cost of a code for 115 people who signed in from a new device during the spike. A second factor on every account brought every sender to 0, and asked all 3,001 people for it.

NIST SP 800-63B-4 sets the upper bound for counting by account: no more than 100 consecutive failed attempts on one account. It also allows ways to lower the chance that the real owner is shut out, such as a bot-detection challenge, waits that grow as the count rises, and risk-based techniques. The Top 10 puts the trade-off in one line: limit or increasingly delay failed sign-ins, but do not create a denial of service.

Locking an account on its own count

“Five wrong passwords lock the account” counts every sender’s failures against the owner, as the delay keyed by the account alone did in the model: it refused 7 owners who typed nothing wrong. Key the delay by account and source, add a separate limit per source, and see the lesson on rate limiting for the windows behind both.

Checkpoint

Check: Throttle keys and what each one sees

  1. Question 1 of 2 A delay keyed by the account alone refused 7 people in the model, and the same delay keyed by account and source refused none. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Other senders' failures on those accounts counted against the owners, who sign in from sources of their own

    A count tied to the account alone lets anyone push an owner into a delay or a lock, a denial of service that the Top 10 warns about. Keying by account and source delays the sender and leaves the owner alone.

  2. Question 2 of 2 Why did a limit of 20 failures per source in 10 minutes cut the spray from 13 accounts to 4 but leave the pairs at 43?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The pairs sent only 4 attempts per source, so no source came near the limit

    A source limit sees senders that concentrate their attempts on a few sources. Pairs spread over many sources stay under it, which is why the site needs a signal that adds up across all sources.

One answer for every outcome, in sign-in, sign-up and reset

A sign-in form that says “Wrong password” for one account and “No account with that email” for another tells a stranger which addresses have accounts, which is user enumeration: Finding out which accounts exist from differences in a service's answers, such as the words, the status code or the response time. A login or reset form should give the same answer and do the same work for every name. More in “Passwords and secret tokens for web apps”. The lesson on passwords fixed the sign-in form with one message and the same hashing work for every name. Sign-up and reset give accounts away just as easily: “This email is already registered” is the same answer in other words. ASVS 5.0.0 requirement 6.3.8 asks, at level 3, that valid users cannot be deduced from failed sign-ins through error messages, status codes or timing, and that sign-up and recovery are protected the same way.

The program sends each flow two requests, one for an address with an account and one for an address without, to a weak and a fixed version, and compares everything a stranger can see. The status, the redirect, the cookie and the body are in the answer; the time is modelled by “work”, the number of password hashes, which is what makes a request slow:

Three flows, two addresses, and a check that compares the answers JavaScript · answers/same_answer.mjs
// Sign-in, sign-up and password reset, each in a weak and a fixed version, and a check that asks every flow about an
// address that has an account and one that has none. A stranger sees the status, the redirect, the cookie, the body
// and the time; "work" counts password hashes, the step that sets the time. The mail goes to the address, not to them.
const accounts = new Map([['asha@example.com', 'hash-of-asha']]);
const mail = [];

function weakApp() {
  return {
    signIn(email, work) {
      if (!accounts.has(email)) return { status: 404, body: 'No account with that email' };
      work.hashes++;
      return { status: 401, body: 'Wrong password' };
    },
    signUp(email, work) {
      if (accounts.has(email)) return { status: 409, body: 'This email is already registered' };
      work.hashes++;
      return { status: 302, location: '/welcome', cookie: true, body: '' };
    },
    reset(email) {
      if (!accounts.has(email)) return { status: 404, body: 'No account found for that email' };
      mail.push([email, 'reset link']);
      return { status: 200, body: 'We sent a reset link to your email' };
    },
  };
}

const jobs = []; // the fixed app's work after the response, done by a queue

function fixedApp() {
  return {
    signIn(email, work) {
      work.hashes++; // the real hash, or a dummy hash with the same settings for a name with no account
      return { status: 401, body: 'Invalid email or password' };
    },
    signUp(email, work) {
      work.hashes++; // hash the new password either way; the account starts only when the link is used
      jobs.push(() => mail.push([email, accounts.has(email) ? 'you already have an account: sign in or reset' : 'finish signing up']));
      return { status: 202, body: 'Check your email to finish signing up' };
    },
    reset(email) {
      jobs.push(() => accounts.has(email) && mail.push([email, 'reset link']));
      return { status: 202, body: 'If that address has an account, we sent it a reset link' };
    },
  };
}

/** What a stranger can tell apart: everything in the answer, and the work as a stand-in for the time. */
function seen(app, flow, email) {
  const work = { hashes: 0 };
  const r = app[flow](email, work);
  return { status: r.status, location: r.location ?? '-', cookie: r.cookie ? 'set' : '-', body: r.body, work: work.hashes };
}

for (const [name, app] of [['weak', weakApp()], ['fixed', fixedApp()]]) {
  mail.length = 0;
  let differ = 0;
  for (const flow of ['signIn', 'signUp', 'reset']) {
    const a = seen(app, flow, 'asha@example.com');
    const b = seen(app, flow, 'nobody@example.com');
    const fields = Object.keys(a).filter((k) => a[k] !== b[k]);
    if (fields.length) differ++;
    console.log(`${name.padEnd(5)}  ${flow.padEnd(6)}  ${fields.length ? `tells them apart by ${fields.join(', ')}` : `same answer: ${a.status} "${a.body}"`}`);
  }
  console.log(`${name.padEnd(5)}  check ${differ ? 'FAILS' : 'passes'}: ${differ} of 3 flows answer differently\n`);
}
// The queue runs later. Only the owner of each address reads what the fixed flows sent there.
for (const job of jobs) job();
for (const to of ['asha@example.com', 'nobody@example.com']) {
  console.log(`mail to ${to}: ${mail.filter(([e]) => e === to).map(([, m]) => m).join('; ')}`);
}

Output

weak   signIn  tells them apart by status, body, work
weak   signUp  tells them apart by status, location, cookie, body, work
weak   reset   tells them apart by status, body
weak   check FAILS: 3 of 3 flows answer differently

fixed  signIn  same answer: 401 "Invalid email or password"
fixed  signUp  same answer: 202 "Check your email to finish signing up"
fixed  reset   same answer: 202 "If that address has an account, we sent it a reset link"
fixed  check passes: 0 of 3 flows answer differently

mail to asha@example.com: you already have an account: sign in or reset; reset link
mail to nobody@example.com: finish signing up

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node same_answer.mjs

The weak flows fail the check in all three places: sign-in by status, words and work, sign-up by five fields at once, and reset by status and words. The fixed flows give one answer each, and they move the difference to where only the owner of the address can read it, the inbox. That follows OWASP’s Authentication Cheat Sheet, whose model answer to a sign-up is the same for every address: a link has been emailed to the address provided. For an address that already has an account, the fixed flow mails a notice in place of the link. Step through the fixed sign-up for both addresses:

Interactive · message by message One sign-up answer for two addresses Step through a sign-up for an address that has an account and one that has none, and compare what the browser sees with what each inbox gets.
One sign-up answer for two addresses, step 8 of 8. Messages so far: Any browser to Your app: sign up asha@example.com; Your app to Any browser: 202 Check your email; Your app to The address's inbox: you already have an account; Any browser to Your app: sign up nobody@example.com; Your app to Any browser: 202 Check your email; Your app to The address's inbox: finish signing up.Any browsersees only the answerYour appfixed sign-upThe address's inboxa link to finishsign up asha@example.com202 Check your emailyou already have an accountsign up nobody@example.com202 Check your emailfinish signing up

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. A taken address. Someone asks to sign up as asha@example.com, which already has an account. The browser may be Asha's or anyone's, so the answer must not depend on whether the account exists.
  2. The same work. The app hashes the submitted password as it would for a new account, so the time of the answer is the same either way.
  3. The same answer. The app answers 202 with "Check your email to finish signing up". No cookie, no redirect and no account yet.
  4. The difference goes by mail. A queue writes to the address after the answer has gone. Asha reads that someone tried to sign up with her address, with links to sign in or to reset.
  5. A new address. Now a sign-up for nobody@example.com, an address that no account uses yet. The app does the same hash.
  6. Identical again. The answer is the same 202 with the same words, so status, headers, body and time match the first request.
  7. A link to finish. This inbox receives the link that creates the account. The account exists only once its owner uses the link.
  8. What the browser learned. Nothing about either address. The two answers were equal, and only the owner of each inbox can read how the two cases differed.

Keep the check: the weak flows make it fail and the fixed ones make it pass, so it belongs in the test suite for every flow that takes an address, the API included. A new flow, or a quick fix to an old one, then cannot bring the difference back.

The same words with a different status code

A form can show “Invalid email or password” for both cases and still answer 404 for one of them, or redirect only when the account exists. Compare the whole answer, status and headers included, as the check above does, not the text on the screen.

Checkpoint

Check: One answer in sign-in, sign-up and reset

  1. Question 1 of 2 The fixed sign-up answers "Check your email to finish signing up" for every address. How does Asha, whose address already has an account, learn that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An email to her address says someone tried to sign up with it and offers sign-in and reset

    The answer in the browser must be identical for both addresses. What differs is the mail, which a queue sends after the answer, so the time does not depend on the account either.

  2. Question 2 of 2 The weak sign-in spends no password hash on a name with no account and one hash on a real one. What does a stranger learn from that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Which names have accounts, from how long the answer takes

    The fixed handler checks every password against a stored hash or a dummy hash with the same settings, so every failure costs one hash and takes the same time.

Check new passwords against breached ones without sending them

A password that already appears in a breach is the first one a list of pairs will try. NIST SP 800-63B-4 asks verifiers to compare a new password with a blocklist of common, expected and compromised values. ASVS 5.0.0 asks for a check against at least the 3,000 most common passwords (6.2.4) and, at level 2, against a set of breached passwords (6.2.12), at sign-up and at every change. The Top 10 suggests the 10,000 worst passwords. A reset sets a new password as well, so it gets the same check.

A breach list is too large to ship in every app, and nobody should send a password to another service to ask. The Pwned Passwords range API removes that dilemma with k-anonymity: Asking about a value without naming it: you send a short prefix of its hash that many hashes share, the service answers with all of them, and you compare the rest yourself. The Pwned Passwords range API works this way with five hex characters of a SHA-1.. The app hashes the password with SHA-1 and sends only the first five of the 40 hex characters. The service answers with every suffix that starts with those five, each with its count, and the app compares its own 35 remaining characters with that list. No API key is needed, and with the header Add-Padding: true every answer is padded to between 800 and 1,000 lines, the padding marked with a count of 0, so that its size says nothing about the prefix. The program runs the check on two made-up responses in that format, cut to 15 and 14 lines so that you can read them:

A range lookup at sign-up, on two made-up responses

Python · breach/range_lookup.py

# A breached-password check at sign-up, the way the Pwned Passwords range API works: hash the password with SHA-1,
# send only the first five hex characters, and compare the rest here. The two responses in ranges/ are made-up files
# in the documented format (SUFFIX:COUNT, one per line); the program sends nothing over a network.
import hashlib
from pathlib import Path


def range_response(prefix):
    """Stands in for GET https://api.pwnedpasswords.com/range/<prefix> with the Add-Padding header."""
    return (Path("ranges") / f"{prefix}.txt").read_text()


def weak_count(password):
    digest = hashlib.sha1(password.encode("utf-8")).hexdigest()  # lower case
    for line in range_response(digest[:5].upper()).splitlines():
        suffix, _, count = line.partition(":")
        if suffix == digest[5:]:  # the service sends upper case, so this never matches
            return int(count)
    return 0


def breach_count(password):
    digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = digest[:5], digest[5:]
    lines = range_response(prefix).splitlines()
    padding = sum(1 for line in lines if line.endswith(":0"))
    print(f"  sent: {prefix} (5 of 40 hex characters); kept here: {suffix[:5]}...{suffix[-4:]}")
    print(f"  answer: {len(lines)} lines, {padding} of them padding with a count of 0")
    for line in lines:
        candidate, _, count = line.partition(":")
        if candidate.strip().upper() == suffix and int(count) > 0:
            return int(count)
    return 0


for password in ["Rain-on-the-roof-1", "a-sentence-nobody-has-typed-before"]:
    print(password)
    found = breach_count(password)
    print(f"  weak check: {'found' if weak_count(password) else 'not found'}")
    verdict = f"found {found:,} times: refuse it and ask for another" if found else "not found: accept it"
    print(f"  fixed check: {verdict}")

Output

Rain-on-the-roof-1
  sent: 4513C (5 of 40 hex characters); kept here: 6B0FD...BCC7
  answer: 15 lines, 5 of them padding with a count of 0
  weak check: not found
  fixed check: found 2,047 times: refuse it and ask for another
a-sentence-nobody-has-typed-before
  sent: 04012 (5 of 40 hex characters); kept here: 04719...5BE4
  answer: 14 lines, 5 of them padding with a count of 0
  weak check: not found
  fixed check: not found: accept it

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 range_lookup.py

Text (4513C.txt) · breach/ranges/4513C.txt

18B40AF47F492D549DC46C03C21BB00FA9E:14
1C50884ADFFA01738A06E8B26034A7E6592:0
2CF3DFC8DF07413DF4F73EF8C9FCD698014:120
32C893BFF37BEB222F9875ADAAFCA56E7BB:0
3A37DFE702393E0FA6C8BBC2A299E490BBE:37
3B8C9B1D09A939DA0A61353314B90D4BDCC:5
3FE546DAE72C16511E6F046455C8DD2C95B:3
531B53A829A58820D8D01370933BAE1CA6E:37
6B0FDF4AE8A26B1972511F7D0ED6911BCC7:2047
8EFD9E5AFE3836702EAD50B5B6386984383:37
9165999B4C60FDB505A45030B2B964743AF:0
BBEEF965D4F0BD1529B8430691CC5324CA8:0
D7450C847008FD8A42518AA107F174CB78A:5
DC64E4D8A3A8E8487A4389ECC0ADA3F6DBA:0
FB68FEFD19AE3E8E5DF28FB36D4E020E267:2

Text (04012.txt) · breach/ranges/04012.txt

2E55756AC85E20B247F4A5F340C2FDAB208:5
3C795900A5E37F5F324094D80E94C0E337A:0
3E1BE16F1925286091F8257F1B7CE2710DC:961
403C2580123A49F7AE7C7AD1F75E63FCF0F:5
532B74EE3D420ED2EF1EFB31D5D287D3B50:0
65FCF5D6B02336C3EB3D7A9032A4ED336FA:0
7591695D4ECF191A9D79BBC70DDCB7CB037:0
9548B4E55154DF097B2ED4A41F013B8A056:2
9F2AFAAB5258C360E76D468F4ABC6444411:8
C0FE8BF4347BEC1A82EEB3891212E3CEF04:14
C1E497A8D0B53B625EC33DC0761858B2851:5
D4308D157FAABAF8FA81F40FB8E6B8C961D:2
DD6577F6B5DDEB69EF26632B5356D6D0489:14
E2C8EE98EDF9A530669C7A537C4AC69C8BE:0

The first password is in the made-up response 2,047 times, so sign-up refuses it and asks for another; the second is not in its response, so sign-up accepts it. The weak check never finds anything, because hexdigest() returns lower case and the service sends upper case, and a check that never fires looks exactly like a list with no matches. The fixed check compares in one case, skips the padding lines and needs a count above 0. Step through one lookup:

Interactive · message by message A breached-password check that sends five characters Step through one check at sign-up and watch what leaves your server and what stays on it.
A breached-password check that sends five characters, step 7 of 7. Messages so far: Sign-up form to Your server: new password; Your server to Range service: GET /range/4513C; Range service to Your server: suffixes with counts; Your server to Sign-up form: choose another password.Sign-up formthe person's browserYour serverfound 2,047 timesRange serviceknows breached hashesnew passwordGET /range/4513Csuffixes with countschoose another password

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A new password arrives. The person chooses Rain-on-the-roof-1, and the form sends it to your server over HTTPS, as every sign-up does.
  2. Hash it. Your server takes the SHA-1 of the password, 40 hex characters, in upper case to match the service. It splits them into the first 5 and the other 35.
  3. Send the prefix only. It asks for the range 4513C with the Add-Padding header. The 35 characters and the password never leave your server.
  4. Every hash with that start. The service answers with every suffix that begins with 4513C and its count, padded with lines whose count is 0, so the size of the answer says nothing.
  5. Compare here. Your server drops the padding and looks for its 35 characters, in one case. They are there, with a count of 2,047.
  6. Refuse and explain. Sign-up refuses the password and says it has appeared in a breach, so the person picks another. Change and reset run the same check.
  7. What left the server. Five hex characters, which every hash beginning 4513C shares. The service cannot tell which of them you hold, or whether you hold any.

The service’s documentation adds one caution for forms that check as the person types: a search on every character can give a third party who sees the requests enough to work out the password. Wait until the field is complete, and make the call from your server.

Pwned Password Checker Check a made-up password against the breached list. The tool sends only the first characters of its hash.

Checkpoint

Check: Breached-password checks by range

  1. Question 1 of 2 What does a range lookup send to the breached-password service?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first five hex characters of the password's SHA-1 hash

    The service answers with every suffix that begins with the five characters, padded with count-0 lines when asked. Your server compares its own 35 characters with them.

  2. Question 2 of 2 In range_lookup.py the weak check never found the breached password. What was wrong with it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It compared a lower-case hexdigest with the upper-case suffixes the service sends

    The fixed check upper-cases both sides, skips lines with a count of 0 and requires a count above 0. A test with a known breached example keeps such a silent miss from shipping.

Rules that read the whole log catch what one attempt cannot show

Throttles decide one attempt at a time. Some shapes appear only across many attempts, so the next layer is a set of rules that read the log. They need a few fields per attempt: the time, the account named or the fact that none exists, the source, whether the device was known and the result. They never need the password: ASVS 5.0.0 asks, at level 2, that every authentication operation is logged, success or failure (16.3.1), and that credentials are never written to a log (16.2.5). The program runs four rules over the hour of the first section and scores each one with the model’s labels:

Four detection rules over the same hour, scored JavaScript · night/detect.mjs
// Four rules read the hour's log the next morning. The log has the time, the account named (or none), the source,
// whether the device was known and the result, never the password. The model's labels score each rule.
import { buildHour } from './traffic.mjs';

const hour = buildHour();
const failed = (a) => a.password !== 'right';

// Rule 1: a source that fails on 10 or more different accounts within 10 minutes; its attempts are flagged from then
// until 10 minutes after the rule last fired, successes included.
function wideSources() {
  const out = new Set();
  const bySource = new Map();
  const firedAt = new Map();
  hour.forEach((a, i) => {
    const list = (bySource.get(a.source) ?? []).filter(([t]) => t > a.t - 600);
    if (failed(a)) list.push([a.t, a.account ?? `none-${i}`, i]);
    bySource.set(a.source, list);
    if (new Set(list.map((x) => x[1])).size >= 10) {
      firedAt.set(a.source, a.t);
      for (const x of list) out.add(x[2]);
    }
    if (a.t <= (firedAt.get(a.source) ?? -Infinity) + 600) out.add(i);
  });
  return out;
}
// Rule 2: a 5-minute stretch in which more than 10 % of attempts name no account; flags its unknown devices.
function noAccountSpike() {
  const out = new Set();
  for (let start = 0; start < 3600; start += 300) {
    const inside = hour.map((a, i) => [a, i]).filter(([a]) => a.t >= start && a.t < start + 300);
    const share = inside.filter(([a]) => a.account === null).length / inside.length;
    if (share > 0.1) for (const [a, i] of inside) if (!a.knownDevice) out.add(i);
  }
  return out;
}
// Rule 3: a right password on an account that failed 5 or more times in the hour before it.
function successAfterFailures() {
  const out = new Set();
  const fails = new Map();
  hour.forEach((a, i) => {
    if (a.account === null) return;
    const recent = (fails.get(a.account) ?? []).filter((t) => t > a.t - 3600);
    if (failed(a)) recent.push(a.t);
    else if (recent.length >= 5) out.add(i);
    fails.set(a.account, recent);
  });
  return out;
}
// Rule 4: a right password from a device the site does not know. This one sends the owner a notice.
const newDeviceSuccess = () => new Set(hour.flatMap((a, i) => (!failed(a) && !a.knownDevice ? [i] : [])));

const opened = hour.flatMap((a, i) => (a.origin !== 'people' && !failed(a) ? [i] : []));
const count = (set, who) => hour.filter((a, i) => set.has(i) && a.origin === who).length;
const total = (who) => hour.filter((a) => a.origin === who).length;
const pct = (n, d) => `${Math.round((100 * n) / d)} %`;
const rules = [
  ['1 source, 10+ accounts', wideSources()],
  ['2 no-account spike', noAccountSpike()],
  ['3 success after 5 fails', successAfterFailures()],
  ['4 success, new device', newDeviceSuccess()],
];
const head = ['rule', 'flagged', 'precision', 'pairs', 'spray', 'one account', 'people', `opened (of ${opened.length})`];
const rows = rules.map(([name, set]) => {
  const machine = set.size - count(set, 'people');
  return [name, set.size, pct(machine, set.size), pct(count(set, 'pairs'), total('pairs')), pct(count(set, 'spray'), total('spray')), pct(count(set, 'one-account'), total('one-account')), count(set, 'people'), opened.filter((i) => set.has(i)).length];
});
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
const line = (cells) => cells.map((c, i) => (i === 0 ? String(c).padEnd(widths[i]) : String(c).padStart(widths[i]))).join('  ');
console.log(line(head));
for (const r of rows) console.log(line(r));
const any = new Set(rules.slice(0, 3).flatMap(([, s]) => [...s]));
console.log(`\nrules 1 to 3 together: ${opened.filter((i) => any.has(i)).length} of ${opened.length} opened accounts, ${count(any, 'people')} people's attempts flagged`);

Output

rule                     flagged  precision  pairs  spray  one account  people  opened (of 57)
1 source, 10+ accounts      6000      100 %    0 %  100 %          0 %       0              13
2 no-account spike         11545       99 %  100 %   86 %         67 %     108              56
3 success after 5 fails        4       25 %    0 %    0 %          0 %       3               1
4 success, new device        199       29 %    1 %    0 %          0 %     142              57

rules 1 to 3 together: 57 of 57 opened accounts, 111 people's attempts flagged

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node detect.mjs

Rule 1 flagged every spray attempt and nothing else, because no person fails on 10 different accounts in 10 minutes; it caught the spray’s 13 opened accounts. Rule 2, the share of names with no account, caught the pairs wave and 56 of the 57 opened accounts. It flagged a whole stretch of the hour, 11,545 attempts with 108 of them from people, so it is a reason to step up, not to block. Rule 3 found the one account opened after 400 guesses, along with 3 owners who signed in after others had failed on their accounts. Together, rules 1 to 3 caught all 57.

Rule 4 is different: a right password from a device the site does not know. It caught all 57 accounts with a precision of 29 %, because 142 people also signed in from new devices. That is too many for a block and exactly right for a notice: ASVS 6.3.5 asks, at level 3, that users hear of suspicious sign-in attempts, and 6.3.7 that they hear of every change to their credentials. OWASP’s Credential Stuffing Prevention Cheat Sheet adds the most telling event to report: a correct password followed by a failed second factor.

Keeping sign-in logs in India

Organisations that CERT-In’s directions cover, under section 70B(6) of the IT Act, must report listed cyber incidents, such as unauthorised access to systems or data, within 6 hours of noticing them, and must hold the logs of their ICT systems inside India for a rolling period of 180 days. Sign-in events are among the most useful of those logs, because they show who was let in. Check with your counsel which directions apply to your organisation.

Checkpoint

Check: Detection rules over the log

  1. Question 1 of 2 Rule 4 flagged 199 sign-ins with a right password from an unknown device, 142 of them by people. What is its precision, in per cent, to the nearest whole number?

    Type a number.

    A hint

    Precision is the share of flagged attempts that the rule meant to catch.

    Show the answer: Question 1 of 2

    Answer: 29 % (anything from 28.5 to 29.5 counts)

    199 − 142 = 57 flagged sign-ins came from the automated senders, so the precision is 57 ÷ 199 ≈ 28.6 %, about 29 %. Those 57 are every account opened in the hour, which is why the rule is still useful for notices.

  2. Question 2 of 2 Rule 2 caught 56 of the 57 opened accounts but flagged 108 attempts by people. How should a site use it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: To step up unknown devices while it fires, not to block

    A rule that is right about a period and noisy about single attempts is a trigger for a cheap extra step, such as a code for unknown devices, rather than for a block.

Where a second factor, a CAPTCHA and device recognition belong

Every control so far fits into one sign-in flow, and the order matters: refuse what the throttles refuse before spending a hash on it, give every name the same work, read the risk signals, and only then decide whether to ask for more. Step through the flow:

Interactive · stage by stage The controls of one sign-in, in order Step through one sign-in and see which control acts at each stage, and why the order matters.
The controls of one sign-in, in order, step 6 of 6. Showing Throttle the attempt, Check the password, Read the risk signals, Step up if risky, Start the session. In focus: Start the session.low riskThrottle the attemptaccount+source, and sourceCheck the passwordsame work for any nameRead the risk signalsnew device, spike, sourceStep up if riskysecond factor or a codeStart the sessionnew ID and a notice

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Throttle first. A delay keyed by account and source and a limit per source decide before anything costly runs. A refused attempt gets a 429 and costs no hash.
  2. Check with the same work. The password is checked against the stored hash, or against a dummy hash with the same settings when the name belongs to no account. The answer is the same for every failure.
  3. Read the signals. A right password is not yet the end. The flow asks whether the device is known, whether names with no account are spiking and whether the source is flagged.
  4. Known device, quiet site. With low risk the flow goes straight to the session, which is why most people in the model were never asked for anything more.
  5. Step up when it is risky. A new device during a spike, or any account that has a second factor, must also pass it. A right password from a list stops here.
  6. Start and tell. The session gets a fresh identifier, and a sign-in from a new device sends the owner a notice, which is how the account opened before the spike was seen still reaches its owner.

A second factor is the strongest step. OWASP’s Credential Stuffing Prevention Cheat Sheet calls it by far the best defence against most password-related attacks, and adds that it can be required only in some situations, such as a new device or a risky action. The model showed both versions: every account at 0, with every person asked, or 1 account opened with 115 people asked, because the risk signal arrived a few minutes into the wave. Passkeys and the other factors, and how to recover from losing them, are the next lesson.

A CAPTCHA is a defence-in-depth step and not a defence. OWASP’s Credential Stuffing Prevention Cheat Sheet says that it may slow automation, that tools and services exist that break many of them, and that it can be shown only when a sign-in already looks suspicious. Under WCAG 2.2 success criterion 3.3.8, a step of authentication may demand a cognitive function test, such as a puzzle, only if it also gives people another method or a mechanism that does the work for them; on their own, only tests that ask a person to recognise objects, or non-text content they gave the site, are allowed. A CAPTCHA that asks for typed characters at sign-in therefore needs another way through.

Telling a device that has signed in before from a new one, by a long-lived cookie the site set or by traits of the browser. It is a signal about risk and proves nothing about the person. reads a long-lived cookie or the traits of the browser. OWASP’s Authentication Cheat Sheet treats such signals as risk signals and never as substitutes for authentication, and the Credential Stuffing Prevention Cheat Sheet warns that a client can fake its traits. Two last rules belong to every flow: ship no default accounts, which ASVS 6.3.2 asks at level 1, and keep every sign-in route, the mobile API included, behind the same controls. The table below decides between the extra steps for your own sign-in:

Interactive · answer and compare Which extra step fits your sign-in? Answer two questions about your users. The table marks the steps that fit and says why the others fall short.
Can most accounts use a second factor today, such as a passkey or an authenticator app?
Do most people sign in again and again from the same few devices?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four extra steps after the password against four things that decide between them
Extra step Stops a reused password Cost to people Accessible sign-in Holds when signals are faked
Second factor for everyone Asked when: every sign-in Good in the model, 0 accounts opened Poor all 3,001 people asked for it Mixed needs a method without a test, such as a passkey Good asks every time, whatever the signals say
Second factor on risk Asked when: a new device, or a spike Mixed 1 account opened before the spike was seen Good 115 of 3,001 people asked Mixed needs a method without a test, such as a passkey Mixed a sender that looks known is not asked
CAPTCHA on suspicion Asked when: after a few failures Mixed slows scripts; solving services exist Mixed a puzzle for anyone who looks suspicious Poor WCAG 2.2 3.3.8 asks for another way through Poor shown only when the signals catch it
Device recognition alone Asked when: never asks anyone Poor a signal about risk, not a factor Good nothing to do on a known device Good no task for the person at all Poor browser traits can be copied
How the answers decide
  • “Can most accounts use a second factor today, such as a passkey or an authenticator app?” No, few have one yet: rules out Second factor for everyone and adds a caveat to Second factor on risk. There is no factor to ask most accounts for. Ask where one exists, send a code on risk elsewhere, and offer enrolment after each sign-in.
  • “Can most accounts use a second factor today, such as a passkey or an authenticator app?” Yes, most can: adds a caveat to CAPTCHA on suspicion and Device recognition alone. A second factor beats both, so keep a CAPTCHA and device signals as extra layers, never as the main defence.
  • “Do most people sign in again and again from the same few devices?” Yes, mostly the same devices: adds a caveat to Second factor for everyone. Known devices make a risk-based step cheap. In the model a step on risk asked 115 people instead of 3,001.
  • “Do most people sign in again and again from the same few devices?” No, shared or changing devices: rules out Device recognition alone and adds a caveat to Second factor on risk. When new devices are the normal case, a device signal flags almost everyone and a risk rule asks nearly as many people as asking everyone.

Checkpoint

Check: Second factor, CAPTCHA and device signals

  1. Question 1 of 2 Your sign-in shows a CAPTCHA that asks people to type distorted characters after three failures. What does WCAG 2.2 success criterion 3.3.8 ask of it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: That the step also offers another way through, such as another method or a mechanism that helps

    The criterion allows a cognitive function test in authentication only when the step offers an alternative or a helping mechanism, or when the test asks to recognise objects or non-text content the person gave the site.

  2. Question 2 of 2 A colleague proposes skipping the password check for devices the site recognises by a long-lived cookie. What is wrong with that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Device recognition is a risk signal, not a way to prove who someone is

    A known device can lower the risk score and skip a step up, but it cannot replace the password or the second factor. Traits of a browser can also be copied, as the Credential Stuffing Prevention Cheat Sheet warns.

Practise: a throttle and a breach check you can trust

Build two parts of a hardened sign-in in JavaScript: the throttle of the second section, keyed by account and source with a limit per source, and the range check of the fourth. The sample tests include the boundaries, the owner signing in from home while someone else fails on their account, and a response with padding lines:

Code lab · 2 steps

Build a throttle and a breached-password check

Write a sign-in throttle keyed by account and source, then a range lookup that sends five characters and reads the answer correctly.

  1. A sign-in throttle keyed by account and source To do
  2. A breached-password check by range To do

Exercise · Medium · JavaScript

A sign-in throttle keyed by account and source

Write createThrottle(). It returns an object with three methods, and every now is a number of seconds.

  • allowed(account, source, now) returns true when an attempt may go on to the password check, and false when it must be refused with a 429 before any password is checked.
  • failed(account, source, now) records a failed attempt.
  • succeeded(account, source) records a successful one.

Two rules decide allowed:

  1. A delay for each pair of account and source. After n failures in a row for the pair, the next attempt is allowed only when now is at least the time of the last failure plus 2 ** (n - 1) seconds, never more than 60: 1, 2, 4, 8, 16, 32 and then 60. A success clears the pair's count.
  2. A limit for each source. A source that has 20 failures whose time is greater than now - 600 is refused, on any account.

allowed only reads; it never changes a count. The tests call failed or succeeded only for attempts that were allowed.

Starter code · throttle.mjs

/** A sign-in throttle: a delay for each account and source, and a limit of failures for each source. */
export function createThrottle() {
  return {
    allowed(account, source, now) {
      return true;
    },
    failed(account, source, now) {},
    succeeded(account, source) {},
  };
}
The sample tests · throttle.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { createThrottle } from './throttle.mjs';

test('a first attempt is allowed', () => {
  const t = createThrottle();
  assert.equal(t.allowed('asha', 'home', 0), true);
});

test('the wait doubles from 1 second', () => {
  const t = createThrottle();
  t.failed('asha', 'x', 100);
  assert.equal(t.allowed('asha', 'x', 100), false, 'no attempt at once after a failure');
  assert.equal(t.allowed('asha', 'x', 101), true, '1 second after the first failure');
  t.failed('asha', 'x', 101);
  assert.equal(t.allowed('asha', 'x', 102), false, 'the second wait is 2 seconds');
  assert.equal(t.allowed('asha', 'x', 103), true);
});

test('the wait stops at 60 seconds', () => {
  const t = createThrottle();
  let now = 0;
  for (let n = 1; n <= 8; n++) {
    while (!t.allowed('asha', 'x', now)) now++;
    t.failed('asha', 'x', now);
  }
  const last = now;
  assert.equal(t.allowed('asha', 'x', last + 59), false);
  assert.equal(t.allowed('asha', 'x', last + 60), true, 'after eight failures the wait is 60, not 128');
});

test('a success clears the count', () => {
  const t = createThrottle();
  t.failed('asha', 'home', 0);
  assert.equal(t.allowed('asha', 'home', 1), true);
  t.succeeded('asha', 'home');
  assert.equal(t.allowed('asha', 'home', 1), true);
});

test("another source's failures do not delay the owner", () => {
  const t = createThrottle();
  for (let s = 0; s < 6; s++) t.failed('asha', 'x', s * 100);
  assert.equal(t.allowed('asha', 'x', 501), false, 'the busy source waits');
  assert.equal(t.allowed('asha', 'home', 501), true, 'the owner, from home, does not');
});

test('a source with 20 failures in 600 seconds is refused on every account', () => {
  const t = createThrottle();
  for (let i = 0; i < 20; i++) t.failed(`user-${i}`, 'busy', i);
  assert.equal(t.allowed('someone-new', 'busy', 30), false);
  assert.equal(t.allowed('someone-new', 'quiet', 30), true, 'other sources are not affected');
  assert.equal(t.allowed('someone-new', 'busy', 600), true, 'the failure at 0 is no longer within the last 600 seconds');
});
A hint

Keep two maps. One, keyed by ` ${account}|${source} , holds the number of failures in a row and the time of the last one; the other, keyed by the source, holds the times of its failures. In allowed, check the pair's wait with Math.min(2 ** (n - 1), 60) first, then count the source's failure times that are greater than now - 600`.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Easy · JavaScript

A breached-password check by range

Write two functions of a breached-password check at sign-up.

breachCount(sha1Hex, getRange) receives the SHA-1 of the new password as 40 hex characters, in upper or lower case. getRange(prefix) stands in for the range service. Call it once, with the first five characters in upper case. It returns the answer as text: one SUFFIX:COUNT per line, the suffixes in upper case, the lines separated by \n or by \r\n. Return the count of the line whose suffix equals the other 35 characters of the hash, or 0 when no line does. A line with a count of 0 is padding and never counts as a match.

checkNewPassword(sha1Hex, getRange) returns 'refuse' when breachCount is above 0 and 'accept' otherwise.

Starter code · breach.mjs

/** How often the password with this SHA-1 appears in the range answer: 0 when it does not. */
export function breachCount(sha1Hex, getRange) {
  const answer = getRange(sha1Hex.slice(0, 5));
  return answer.includes(sha1Hex.slice(5)) ? 1 : 0;
}

/** 'refuse' for a breached password, 'accept' otherwise. */
export function checkNewPassword(sha1Hex, getRange) {
  return 'accept';
}
The sample tests · breach.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { breachCount, checkNewPassword } from './breach.mjs';

const HASH = '4513C6B0FDF4AE8A26B1972511F7D0ED6911BCC7';
const ANSWER = ['18B40AF47F492D549DC46C03C21BB00FA9E:14', '1C50884ADFFA01738A06E8B26034A7E6592:0', '6B0FDF4AE8A26B1972511F7D0ED6911BCC7:2047', 'FB68FEFD19AE3E8E5DF28FB36D4E020E267:2'].join('\n');

test('only the five upper-case characters are sent, once', () => {
  const asked = [];
  breachCount(HASH.toLowerCase(), (p) => (asked.push(p), ANSWER));
  assert.deepEqual(asked, ['4513C']);
});

test('a lower-case hash still finds its upper-case suffix', () => {
  assert.equal(breachCount(HASH.toLowerCase(), () => ANSWER), 2047);
});

test('a hash that is not in the answer counts 0', () => {
  assert.equal(breachCount('4513C0000000000000000000000000000000000', () => ANSWER), 0);
});

test('a padding line with a count of 0 is not a match', () => {
  assert.equal(breachCount('1C50884ADFFA01738A06E8B26034A7E6592'.padStart(40, '4513C'), () => ANSWER), 0);
});

test('lines may end with \\r\\n', () => {
  assert.equal(breachCount(HASH, () => ANSWER.split('\n').join('\r\n')), 2047);
});

test('the count is read from the matching line', () => {
  assert.equal(breachCount(HASH, () => ANSWER.replace(':2047', ':3')), 3);
});

test('a breached password is refused and another is accepted', () => {
  assert.equal(checkNewPassword(HASH, () => ANSWER), 'refuse');
  assert.equal(checkNewPassword('4513C0000000000000000000000000000000000', () => ANSWER), 'accept');
});
A hint

Upper-case the hash first, then split it with slice(0, 5) and slice(5). Split the answer with /\r?\n/, split each line at its :, and compare the whole suffix with ===; skip a line whose count, read with Number, is 0.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Hash Generator Hash a made-up password with SHA-1 and split it into the five characters that are sent and the 35 that stay.

Then check yourself, one question at a time, weakest idea first:

Practice that adapts to you

Practise this lesson

15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Which describes credential stuffing?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: Trying username and password pairs taken from the breach of another site

    OWASP's Credential Stuffing Prevention Cheat Sheet separates the three by what they try: pairs from another site (stuffing), one password across accounts (spraying) and many passwords on one account (brute force).

  2. Question 2 of 15 A sender tries Winter2025, then Winter2026, then Winter2026! on each of 500 accounts from a breach list, from many sources. Which shape is closest?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: Pairs from a breach, with a few attempts per account and many sources

    Varying a leaked password keeps the shape of stuffing: a few attempts per account, spread over many sources, so a per-account limit of five does not see it.

  3. Question 3 of 15 Your log shows 5 failures an hour on each of 30,000 accounts, from 9,000 sources, and 40 % of the names have no account. Which control from the lesson sees this best?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: A site-wide signal on the share of names with no account

    Many unknown names, few attempts per account and many sources is the shape of a breach list. The share of names with no account rises far above people's 1 %, and that is the signal to step up on.

  4. Question 4 of 15 A delay starts at 1 second after the first failure and doubles after each one, up to 60 seconds. How long is the wait after the sixth failure in a row, in seconds?

    Type a number.

    A hint

    After failure n the wait is 2 to the power n − 1, until it reaches 60.

    Show the answer: Question 4 of 15

    Answer: 32 seconds

    The waits are 1, 2, 4, 8, 16 and 32 seconds after failures 1 to 6. The seventh would be 64, so it is capped at 60.

  5. Question 5 of 15 Which throttle key stops 400 guesses on one account from one source without delaying the account's owner?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Account and source together

    Keying by account and source puts each sender in its own count, so a busy sender is delayed while the owner is not. A separate limit per source catches a sender that moves across accounts.

  6. Question 6 of 15 At most how many consecutive failed attempts on one account does NIST SP 800-63B-4 allow before the verifier must disable that authenticator?

    Type a number.

    Show the answer: Question 6 of 15

    Answer: 100 attempts

    The verifier must limit consecutive failed attempts on a single account to no more than 100, and may use challenges, growing waits and risk-based techniques to lower the chance that the real owner is locked out.

  7. Question 7 of 15 Which parts of an answer must match for an address with an account and one without, for the check to pass?

    Choose every answer that is right.

    Show the answer: Question 7 of 15

    Answer:

    • The time, modelled by the password hashes spent
    • Whether a cookie is set or a redirect happens
    • The status code

    Everything the requester can observe must match: status, headers, cookies, redirects, body and time. The mail may differ, because it goes to the owner of the address.

  8. Question 8 of 15 A reset form looks up the address, then mails a link only when the account exists, all before it answers. What is still wrong when the words are the same in both cases?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Sending the mail before answering makes the real account's answer slower

    Doing the lookup and the mail in the request makes the two cases take different times. Hand both cases to a queue and answer the same way at once.

  9. Question 9 of 15 When should a breached-password check run?

    Choose every answer that is right.

    Show the answer: Question 9 of 15

    Answer:

    • When a password is changed
    • At a password reset
    • At sign-up

    Check whenever a new password is set, once the field is complete, on the server.

  10. Question 10 of 15 Why does the Add-Padding header make the service add lines with a count of 0?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: So that the size of the encrypted answer says nothing about the prefix that was asked for

    Someone watching encrypted traffic could guess the prefix from the length of the answer. Padding to a random 800 to 1,000 lines removes that clue, and a correct client skips the count-0 lines.

  11. Question 11 of 15 Your app may not call outside services at all. How do you still meet ASVS 6.2.4 and 6.2.12?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: Keep a local list of common passwords and of breached hashes, and check against it on your own servers

    The range lookup is one way to use a breach list without sending passwords. A local copy of the list, kept up to date, does the same with no outside call.

  12. Question 12 of 15 Which fields do the lesson's detection rules need for each sign-in attempt?

    Choose every answer that is right.

    Show the answer: Question 12 of 15

    Answer:

    • The account named, or the fact that none exists
    • The source and whether the device was known
    • The time and the result

    Time, account or none, source, device and result are enough. A log that holds tried passwords would itself be a breach waiting to happen.

  13. Question 13 of 15 Rules 1 to 3 together caught all 57 opened accounts. Why still send the owner a notice for every right password from an unknown device?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Rules can miss a new shape, and the owner can tell a stranger's sign-in from their own

    Rule 4 is too noisy to block, with a precision of 29 %, and exactly right for a notice: ASVS 6.3.5 asks that users hear of suspicious sign-in attempts.

  14. Question 14 of 15 Which control does OWASP's Credential Stuffing Prevention Cheat Sheet call by far the best defence against most password-related attacks?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: Multi-factor authentication

    A second factor stops a right password from opening the account. The cheat sheet adds that it may be required only in some situations, such as a new device, to keep its cost low.

  15. Question 15 of 15 Why does the sign-in flow throttle before it checks the password, rather than after?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: A refused attempt then costs no password hash, so a flood cannot turn the slow hash into a denial of service

    The flow puts the cheap decisions first: throttles, then the same-work password check, then the risk signals and a step up, then the session and its notice.

Interview questions

Warm-up: what is credential stuffing, and how is it different from password spraying and brute force?

Credential stuffing tries username and password pairs that leaked from another site, betting that people reuse passwords: each account is tried once or twice, from many sources, and many of the names have no account on your site. Password spraying tries one or a few common passwords against many accounts, slowly, to stay under per-account limits. Brute force tries many passwords against one account. The difference matters because each control sees one shape: per-account delays stop brute force, per-source limits and common-password screening stop most spraying, and pairs spread thin are seen by site-wide signals such as a spike in names with no account. A second factor stops all three when a password is right.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Overnight, failed sign-ins on your site tripled. They are spread over 40,000 accounts, nearly every account failed only once, the attempts came from 9,000 sources, and 30 % of the names have no account. What is happening, what do you do in the first hour, and what do you change afterwards?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Your sign-up form answers "This email is already registered", and the reset form answers "No account found for that email". A colleague says both help users. How do you remove what they give away without leaving honest users confused, and how do you test that the fix holds?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How do credential stuffing, password spraying and brute force differ?

    Stuffing tests username and password pairs taken from the breach of another site: each account once or twice, from many sources, with many names that have no account here. Spraying tests one weak password against a large number of accounts. Brute force tests many passwords against one account. A hybrid tries variations of leaked passwords, such as Winter2025 becoming Winter2026.

  2. Which count sees which shape of automated sign-in traffic?

    Pairs from a breach: the share of names with no account, across the site. Spraying: failures on many accounts from one source. One-account guessing: failures on one account. People make 1 or 2 attempts on an account and about 1 % of their names are unknown, which is the normal shape. A control sees only the shape it counts.

  3. Why is "five wrong passwords lock the account" a poor control?

    It counts every sender's failures against the owner: in the lesson's model a delay keyed by the account alone refused 7 owners who typed nothing wrong. Key the delay by account and source together and add a separate limit per source. NIST SP 800-63B-4 sets the upper bound at 100 consecutive failures on one account, and the Top 10 says to limit or delay failures without creating a denial of service.

  4. What did each control stop in the lesson's model hour?

    A per-source limit cut the spray from 13 opened accounts to 4 but did nothing to the pairs, which sent only 4 attempts per source. Refusing common passwords at sign-up removed the spray's last 4. A code for unknown devices while names with no account spiked brought the pairs from 43 to 1. A second factor on every account brought every sender to 0, at the cost of asking everyone.

  5. How do sign-in, sign-up and reset avoid user enumeration?

    Give every address the same answer and the same work: same status, redirect, cookie, words and timing (the number of password hashes). Move the difference to the inbox: sign-up says a link has been emailed to the address provided, and for an address that already has an account it mails a notice in place of the link. ASVS 5.0.0 6.3.8 asks for this at level 3. Compare the whole answer in a test, not the text on the screen.

  6. How do you check a new password against breached ones without sending it anywhere?

    With k-anonymity and the Pwned Passwords range API: hash the password with SHA-1, send only the first five of the 40 hex characters, and compare your remaining 35 with the suffixes returned. Compare in one case, skip padding lines (count 0), call from your server and wait until the field is complete. ASVS 5.0.0 6.2.4 and 6.2.12 ask for the check at sign-up and every change, and a reset sets a new password too.

  7. Which sign-in log rules catch what a single attempt cannot, and what do they not need?

    A source that fails on 10 different accounts within 10 minutes (the spray); a spike in the share of names with no account (the pairs: noisy, so step up rather than block); a success after many failures on one account; and a right password from an unknown device (precision 29 % in the model, so notify and do not block: ASVS 6.3.5 and 6.3.7). The rules never need the password, and ASVS 16.2.5 says never to log credentials.

  8. Where do a second factor, a CAPTCHA and device recognition belong in a sign-in flow?

    A second factor is by far the best defence against most password attacks and can be required only for a new device or a risky action. A CAPTCHA is defence in depth, since tools break many of them, and success criterion 3.3.8 of WCAG 2.2 asks for another way through. Device recognition is a risk signal, never a substitute for authentication, because a client can fake its traits. Ship no default accounts (ASVS 6.3.2).

Key takeaways

  • Pairs from a breach, spraying and one-account guessing leave different shapes in a log, and each control sees only the shape it counts: pairs never failed twice on one account, and 46 % of their names had no account here.
  • Key delays by account and source and add a limit per source: keyed by the account alone, the delay refused 7 owners, and the per-source limit did nothing against pairs spread over 1,500 sources.
  • Give sign-in, sign-up and reset the same answer and the same work for every address, move the difference into the inbox, and keep a test that compares the whole answer.
  • Check new and reset passwords against breached ones with a range lookup that sends five hex characters, compare in one case and skip the padding.
  • Rules over the log catch what single attempts hide; use the noisy ones to step up or to notify, and put a second factor behind them, which brought every sender in the model to 0.

Terms in this lesson

credential stuffing
Trying username and password pairs that leaked from another site's breach against your sign-in, betting that people reuse passwords. Each account is tried once or twice, from many sources, and many of the names have no account on your site.
k-anonymity
Asking about a value without naming it: you send a short prefix of its hash that many hashes share, the service answers with all of them, and you compare the rest yourself. The Pwned Passwords range API works this way with five hex characters of a SHA-1.
password spraying
Trying one or a few common passwords against many accounts, slowly, so that no single account sees enough failures to trip a per-account limit. Screening common passwords at sign-up removes what it looks for.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.