How to test account lockout after failed logins

Drive one account past the failure threshold with a loop of wrong passwords, printing the status and body of each request. On the target below the fifth failure answers 403 with retry-after: 15. Then ask the three questions a counter cannot answer: what is locked, what the correct password does, who is told.

Why check this

This runs on staging sign-off after any change to the login path, and whenever someone raises the threshold to cut support tickets. Run it against a target you own: a lockout test is a burst of failed logins, and sending one at a service that is not yours is an attack.

Three findings decide whether a lockout helps or hurts. Keyed on the account name, it lets anyone lock a named user out from anywhere. Applied only on the failure path, it lets a correct password walk through. Worded differently from the answer for a name that does not exist, it turns the login form into an account directory.

Prerequisites

// auth-2fa-demo.js - local target for the 2FA and lockout checks. Node 22, no dependencies.
// Needs totp.js from "How to test totp" in the same directory.
const http = require('node:http');
const crypto = require('node:crypto');
const { totp, verify } = require('./totp.js');

const USERS = { alice: 'correct-horse', bob: 'hunter2' };
const SECRET = 'JBSWY3DPEHPK3PXP';        // alice's TOTP secret, the one the authenticator app holds
const MAX_FAILS = 5, LOCK_S = 15;

const sessions = new Map();               // sid -> { user, mfa }
const fails = new Map();                  // key -> { n, until }
const used = new Map();                   // user -> the TOTP counter already spent

const text = { 'content-type': 'text/plain' };
const now = () => Math.floor(Date.now() / 1000);
const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;
const form = (req) => new Promise((r) => { let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(new URLSearchParams(s))); });
const addr = (req) => req.headers['x-forwarded-for'] || '127.0.0.1';   // a test hook. Never trust this header from the internet.
const left = (key) => { const f = fails.get(key); return f && f.until > now() ? f.until - now() : 0; };

function countFail(key) {
  const f = fails.get(key) ?? { n: 0, until: 0 };
  f.n += 1;
  if (f.n >= MAX_FAILS) f.until = now() + LOCK_S;
  fails.set(key, f);
  return f;
}

http.createServer(async (req, res) => {
  const p = new URL(req.url, 'http://127.0.0.1').pathname;
  const f = req.method === 'POST' ? await form(req) : new URLSearchParams();
  const u = f.get('username') ?? '', pw = f.get('password') ?? '';
  const key = p === '/login-ip' ? `ip:${addr(req)}` : `user:${u}`;
  const send = (code, msg, extra = {}) => res.writeHead(code, { ...text, ...extra }).end(`${msg}\n`);

  // First factor. /login is the defective one: the lock is tested only on the wrong-password path.
  if ((p === '/login' || p === '/login-strict' || p === '/login-ip') && req.method === 'POST') {
    if (p !== '/login' && left(key)) return send(403, `Account ${u} is locked. Try again in ${left(key)} s`, { 'retry-after': left(key) });
    if (!Object.hasOwn(USERS, u)) return send(401, 'Invalid username or password');
    if (USERS[u] === pw) {
      fails.delete(key);
      const id = crypto.randomBytes(16).toString('hex');
      sessions.set(id, { user: u, mfa: false });
      return send(200, 'Password accepted. Second factor required', { 'set-cookie': `sid=${id}; Path=/; HttpOnly; SameSite=Lax` });
    }
    const rec = countFail(key);
    if (rec.until > now()) return send(403, `Account ${u} is locked. Try again in ${left(key)} s`, { 'retry-after': left(key) });
    return send(401, `Invalid username or password (failure ${rec.n} of ${MAX_FAILS})`);
  }

  // Second factor. /2fa replays and never counts attempts. /2fa-strict spends the counter and counts.
  if ((p === '/2fa' || p === '/2fa-strict') && req.method === 'POST') {
    const s = sessions.get(sid(req));
    if (!s) return send(401, 'No session. Post the password first');
    const w = verify(SECRET, f.get('code') ?? '', { window: 1 });
    if (p === '/2fa-strict') {
      if (left(`2fa:${s.user}`)) return send(403, `Second factor locked. Try again in ${left(`2fa:${s.user}`)} s`);
      if (w === null) { const r = countFail(`2fa:${s.user}`); return send(401, `Invalid code (failure ${r.n} of ${MAX_FAILS})`); }
      const counter = Math.floor((now() + w * 30) / 30);
      if (used.get(s.user) === counter) return send(401, `Code already used for counter ${counter}`);
      used.set(s.user, counter);
      fails.delete(`2fa:${s.user}`);
      s.mfa = true;
      return send(200, `Signed in as ${s.user}, counter ${counter} spent`);
    }
    if (w === null) return send(401, 'Invalid code');
    s.mfa = true;
    return send(200, `Signed in as ${s.user}, matched at offset ${w}`);
  }

  // /account is the defect under test: it asks for a session, not for a verified session.
  if (p === '/account' || p === '/account-strict') {
    const s = sessions.get(sid(req));
    if (!s) return send(401, 'Not signed in');
    if (p === '/account-strict' && !s.mfa) return send(401, 'Second factor not presented');
    return send(200, `Account page for ${s.user} (mfa=${s.mfa})`);
  }

  if (p === '/state') return send(200, JSON.stringify([...fails].map(([k, v]) => `${k} n=${v.n} lockedFor=${Math.max(0, v.until - now())}s`)));
  return send(404, 'Not found');
}).listen(8657, () => console.log('auth 2fa demo on http://127.0.0.1:8657'));

Start it and note the Windows process id.

node auth-2fa-demo.js &
netstat -ano | grep 8657

/login-ip reads the client address from X-Forwarded-For so one machine can act as two clients. A public server must not trust that header.

Steps

  1. Step 1.

    Post six wrong passwords for a real account, printing the status and retry-after for each.

    for i in 1 2 3 4 5 6; do printf 'attempt %d: ' "$i"; curl -s -w ' [%{http_code} retry-after=%header{retry-after}]\n' --data 'username=alice&password=wrong' http://127.0.0.1:8657/login | tr -d '\n'; echo; done
    
    attempt 1: Invalid username or password (failure 1 of 5) [401 retry-after=]
    attempt 2: Invalid username or password (failure 2 of 5) [401 retry-after=]
    attempt 3: Invalid username or password (failure 3 of 5) [401 retry-after=]
    attempt 4: Invalid username or password (failure 4 of 5) [401 retry-after=]
    attempt 5: Account alice is locked. Try again in 15 s [403 retry-after=15]
    attempt 6: Account alice is locked. Try again in 15 s [403 retry-after=15]

    The threshold is 5, not 6: the fifth failure locks. tr -d folds body and status onto one line. A threshold off by one against the specification is a finding.

  2. Step 2.

    While the lock runs, post the correct password.

    curl -s -i --data 'username=alice&password=correct-horse' http://127.0.0.1:8657/login
    
    HTTP/1.1 200 OK
    content-type: text/plain
    set-cookie: sid=ca460fb15c90548ec18a9e9023745888; Path=/; HttpOnly; SameSite=Lax
    Date: Fri, 11 Sep 2026 20:57:58 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    Password accepted. Second factor required

    The account was locked a second earlier and this request signed in. The lock is tested only on the wrong-password branch, so the guess that succeeds is the one it never stops.

  3. Step 3.

    Repeat against a route that tests the lock before the password, using the second account.

    for pw in wrong wrong wrong wrong wrong hunter2; do printf '%-14s ' "$pw"; curl -s -w ' [%{http_code} retry-after=%header{retry-after}]\n' --data "username=bob&password=$pw" http://127.0.0.1:8657/login-strict | tr -d '\n'; echo; done
    
    wrong          Invalid username or password (failure 1 of 5) [401 retry-after=]
    wrong          Invalid username or password (failure 2 of 5) [401 retry-after=]
    wrong          Invalid username or password (failure 3 of 5) [401 retry-after=]
    wrong          Invalid username or password (failure 4 of 5) [401 retry-after=]
    wrong          Account bob is locked. Try again in 15 s [403 retry-after=15]
    hunter2        Account bob is locked. Try again in 15 s [403 retry-after=15]

    hunter2 is bob's real password and it is refused. The two routes differ in where the lock is tested, not in whether it exists.

  4. Step 4.

    Run the same six attempts against a username that does not exist.

    for i in 1 2 3 4 5 6; do printf 'attempt %d: ' "$i"; curl -s -w ' [%{http_code} retry-after=%header{retry-after}]\n' --data 'username=ghost&password=wrong' http://127.0.0.1:8657/login | tr -d '\n'; echo; done
    
    attempt 1: Invalid username or password [401 retry-after=]
    attempt 2: Invalid username or password [401 retry-after=]
    attempt 3: Invalid username or password [401 retry-after=]
    attempt 4: Invalid username or password [401 retry-after=]
    attempt 5: Invalid username or password [401 retry-after=]
    attempt 6: Invalid username or password [401 retry-after=]

    Six identical 401 answers, no counter, no lock. Beside step 1, where alice produces a failure count and then a 403, the form answers "does this account exist" for any name.

  5. Step 5.

    Move to the route that counts per client address and lock one address out.

    for pw in wrong wrong wrong wrong wrong correct-horse; do printf '%-14s ' "$pw"; curl -s -H 'X-Forwarded-For: 203.0.113.10' -w ' [%{http_code}]\n' --data "username=alice&password=$pw" http://127.0.0.1:8657/login-ip | tr -d '\n'; echo; done
    
    wrong          Invalid username or password (failure 1 of 5) [401]
    wrong          Invalid username or password (failure 2 of 5) [401]
    wrong          Invalid username or password (failure 3 of 5) [401]
    wrong          Invalid username or password (failure 4 of 5) [401]
    wrong          Account alice is locked. Try again in 15 s [403]
    correct-horse  Account alice is locked. Try again in 15 s [403]

    From this address the result matches step 3, and the message still names the account.

  6. Step 6.

    While that lock runs, sign in as the same user from another address.

    curl -s -H 'X-Forwarded-For: 203.0.113.20' -w ' [%{http_code}]\n' --data 'username=alice&password=correct-horse' http://127.0.0.1:8657/login-ip | tr -d '\n'
    
    Password accepted. Second factor required [200]

    Same account, same second, another address, and it works. In step 3 bob was locked out of every address at once. Per address costs an attacker one address per five guesses. Per account costs the user their own login.

  7. Step 7.

    Wait out the lock window, then read the server's counter state.

    node -e "setTimeout(()=>{},16000)" && curl -s http://127.0.0.1:8657/state
    
    ["user:bob n=5 lockedFor=0s","ip:203.0.113.10 n=5 lockedFor=0s"]

    The lock expired and the count did not. Both keys still sit at 5, the threshold. Most servers expose no such route, so step 8 infers it instead.

  8. Step 8.

    Send one more wrong password from that address, then the correct one.

    for pw in wrong correct-horse; do printf '%-14s ' "$pw"; curl -s -H 'X-Forwarded-For: 203.0.113.10' -w ' [%{http_code}]\n' --data "username=alice&password=$pw" http://127.0.0.1:8657/login-ip | tr -d '\n'; echo; done
    
    wrong          Account alice is locked. Try again in 15 s [403]
    correct-horse  Account alice is locked. Try again in 15 s [403]

    One failure after the window re-locks for the full 15 seconds, because the counter was never reset. Stop the server by the id you noted: powershell -Command "Stop-Process -Id 31944 -Force".

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 403 with retry-after at the documented attempt | The threshold matches the specification | Record the attempt number and move to the next question. | | A correct password accepted during the lock | The lock guards the failure branch only | Raise it. The lockout stops every guess except the successful one. | | The named account locked from every address | The counter is keyed on the username | Anyone can lock any user by name. Ask for a per address key as well. | | A real name answers 403, an unknown name 401 | The lockout confirms which accounts exist | One message and one status for both, and no failure count in the body. | | The count surviving the lock window | The window expires, the counter does not | One failure re-locks for a full window, as step 8 shows. Reset on expiry. | | No lock after twenty wrong passwords | There is no threshold on this route | Check the second factor route separately. They rarely share a counter. |

Common mistakes

Sign: The lockout test passes, and an attacker with the right password is never stopped.Cause: The lock is evaluated inside the wrong-password branch, so the success path skips it. Step 2 signs in one second after step 1 locked the account. Test the correct password during the lock, every time.
Sign: A per account lockout is signed off as the secure option.Cause: It hands anyone a denial of service against a named user: five wrong passwords from any address and that user cannot log in. Step 6 shows the per address alternative letting the real user through while the attacking address stays blocked.
Sign: The lock window elapses and the next single mistake locks the account again.Cause: Expiring the window without clearing the counter leaves it at the threshold. Step 7 reads n=5 with lockedFor=0s, and step 8 re-locks on one failure. Ask which of the two the specification means.
Sign: A helpful lockout message becomes an account directory.Cause: Step 1 answers 403 with the account name, step 4 answers 401 for a name that does not exist. The difference is enough to enumerate users, and the failure count in the body tells an attacker how many guesses remain.

What to check next

FAQ

How to check if the lockout counter resets?

Wait out the window, send one wrong password, read the status. An immediate lock means the counter survived, which is step 8. A failure 1 of 5 means it was cleared. Then check what a successful login does to it.

How many failed attempts should lock an account?

Whatever the specification says, tested to the attempt. The number matters less than what is keyed, what the correct password does and what the response reveals. A threshold of 5 keyed by name is worse than 20 keyed by address.

How to test failed login attempts safely?

Against your own process. The server in Prerequisites exists for that. A burst of failed logins at a service you do not own is an attack, and on shared staging it locks out colleagues.

Should the lockout response say the account is locked?

Only if the same words answer a name that does not exist. Step 1 and step 4 differ, and that difference is the leak.

Does a lockout replace rate limiting?

No. A lockout counts one route, per account or per address. Rate limiting counts requests across routes, including the second factor form.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny