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
- Node 22 or later. Save the server below as
auth-2fa-demo.js. Two accounts, a threshold of 5 and a lock of 15 seconds. totp.jsfrom How to test totp in the same directory, since this server also serves the two factor checks.- curl 8 or later.
%header{retry-after}needs curl 7.84 or newer and prints an empty value when the header is absent.
// 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
- Step 1.
Post six wrong passwords for a real account, printing the status and
retry-afterfor 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; doneattempt 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 -dfolds body and status onto one line. A threshold off by one against the specification is a finding. - 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/loginHTTP/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 requiredThe 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.
- 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; donewrong 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]hunter2is bob's real password and it is refused. The two routes differ in where the lock is tested, not in whether it exists. - 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; doneattempt 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
401answers, no counter, no lock. Beside step 1, wherealiceproduces a failure count and then a403, the form answers "does this account exist" for any name. - 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; donewrong 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.
- 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
bobwas 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. - 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.
- 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; donewrong 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
What to check next
- How to test two factor authentication: the code form on the same server, which this counter misses.
- How to test totp: the size of the space an attempt limit protects.
- How to test login with curl: the cookie handling behind every request here.
- How to test login functionality: the wrong-credential cases repeated here in bulk.
- How to check rate limit headers: the same idea per client, not per account.
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.
Related on this site
intermediate10 minpublished updated Maks Verny