
A07:2025 Authentication Failures still names automated guessing. I opened the OWASP page on 22 August 2026. CWE-307 is improper restriction of excessive authentication attempts.
Pair this page with credential stuffing when the pair already leaked somewhere else, and with 2026 auth for the session row and the named second factor. Cookie flags live on session management.
Guessing is not stuffing
Guessing is many candidate secrets against one mailbox you already know. A dictionary hit is a candidate that appears on a common list. Stuffing is a username and password pair that already leaked on another site, replayed at yours. Do not mix the three in a runbook. The stuffing page is reuse. This page is the oracle that answers too cheaply.
A07 is explicit. The application is weak if it permits automated guessing that is not quickly blocked, if it allows default or well-known passwords, if it lets a new account keep a already-breached secret, or if it has no real multi-factor step. The prevent list on that page is the outline here: MFA, a breached-secret check, NIST length, the same failure text, a delay that is not a lockout weapon, and a new session id after a factor you trust.
NIST SP 800-63B-4 is dated July 2025 on the publication PDF. The nist.gov record lists 1 August 2025. Single-factor passwords SHALL be at least 15 characters. Verifiers SHALL compare prospective secrets against values known to be commonly used or compromised. Composition rules such as one upper and one digit are the opposite of that document. I opened SP 800-63B-4 for those SHALLs.
This page will not walk a guessing run against anyone else’s origin. It will not name a wordlist. It will not time a hash. The job is to make your /login a poor oracle.
Cap POST /login
express-rate-limit 8.6.2 published on 4 August 2026. I opened the npm page for that date. The option is now limit, not max. standardHeaders is "draft-8". The in-process store is the default. Two Node processes do not share it. Redis is the store this page means in production. passOnStoreError defaults to false. Leave that default so a dead store fails closed.
Do not put one global 100-per-15-minutes bucket on the whole app and call login done. Login, password reset, and one-time codes need a tighter limiter. Key on IP plus the normalized email. An IP cap alone loses to a botnet. An email cap alone is a lockout against one mailbox from a shared NAT. Use both.
const { rateLimit } = require("express-rate-limit");
const loginLimit = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
passOnStoreError: false,
keyGenerator: (req) => {
const email = String(req.body && req.body.email || "").toLowerCase();
return `${req.ip}:${email}`;
},
});
app.post("/login", loginLimit, loginHandler);
app.post("/password/reset", loginLimit, resetHandler);
Ten tries per 15 minutes is a starting cap, not a standard. Tune it when support tickets say a campus NAT is colliding. Do not raise it to 200 so the dashboard looks quiet. Reset is a second login. Put loginLimit on that route too.
Backoff without locking the mailbox
A07 says: limit or increasingly delay failed tries, but be careful not to create a denial of service. A hard lock after ten failures is a weapon against that address. Prefer a stepped delay stored next to the user row, plus the IP+email cap above. Unlock through a signed reset, not through a call-center password you read aloud.
const BACKOFF_MS = [0, 0, 0, 2000, 8000, 30000, 120000];
async function accountBackoff(user) {
if (!user) return { blocked: false };
const fails = Number(user.failedLogins || 0);
const wait = BACKOFF_MS[Math.min(fails, BACKOFF_MS.length - 1)];
const elapsed = Date.now() - Number(user.lastFailedAt || 0);
if (wait > 0 && elapsed < wait) {
return { blocked: true, retryAfter: Math.ceil((wait - elapsed) / 1000) };
}
return { blocked: false };
}
async function recordFailure(user) {
if (!user) return;
await updateUser(user.id, {
failedLogins: Number(user.failedLogins || 0) + 1,
lastFailedAt: Date.now(),
});
}
async function clearFailures(user) {
await updateUser(user.id, { failedLogins: 0, lastFailedAt: 0 });
}
accountBackoff, recordFailure, and clearFailures are the named trio. Call them from loginHandler only. A missing user still takes the same wall-clock path as a bad secret so the status line is not an oracle. You can hash a dummy and compare it when the row is missing. You cannot skip the delay only for unknown emails.
When blocked is true, return 429 with Retry-After set to retryAfter. Skip a locked-account sentence in the body. The 429 from loginLimit already taught the client to wait. A HTML form can show a generic try-later line.
A passkey removes the oracle
A07’s first prevent line is multi-factor, so automated guessing and stolen-secret reuse fail. Name the authenticator. A password that already works on another site still works on yours if you never ask for a second factor. A passkey is phishing-resistant and is not portable the way a reused string is. TOTP is a named fallback when the device has no platform authenticator. SMS over the phone network is a restricted authenticator in 63B-4.
I opened the W3C Web Authentication Level 3 Candidate Recommendation Snapshot dated 26 May 2026. I could not confirm a Recommendation as of 22 August 2026. Use the shipped navigator.credentials.get. @simplewebauthn/server 13.3.2 published on 24 June 2026. Store credential.id, publicKey, and counter. After a successful assertion, regenerate the session the same way you do after a password.
const {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = require("@simplewebauthn/server");
const rpID = "app.example.com";
const origin = "https://app.example.com";
app.get("/webauthn/login/options", async function options(req, res) {
const opts = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
});
req.session.webauthnLogin = opts.challenge;
res.json(opts);
});
app.post("/webauthn/login/verify", async function verify(req, res) {
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.webauthnLogin,
expectedOrigin: origin,
expectedRPID: rpID,
credential: await loadPasskey(req.body.id),
});
if (!verification.verified) {
res.status(401).send("Unauthorized");
return;
}
const userId = await userIdForCredential(req.body.id);
await clearFailures({ id: userId, failedLogins: 0 });
req.session.regenerate((err) => {
if (err) {
res.status(500).send("Unavailable");
return;
}
req.session.userId = userId;
req.session.save(() => res.json({ verified: true }));
});
});
Offer the passkey as a first-class login, not as a sticker after a password that already succeeded. A guessed string plus a prompt the bot cannot answer is still better than password-only. A guessed string plus no prompt is the old page. The auth guide is the longer session story. This section is why guessing stops paying.
HIBP blocks a known dictionary hit
A dictionary check you invent will rot. HIBP’s range API is the corpus A07 points at when it says validate against known-breached credentials. I opened the HIBP API v3 page. Pwned Passwords is free and does not take a key. You send the first five characters of a SHA-1 hash. The service returns suffixes and counts. You compare the rest locally. That is k-anonymity. Send Add-Padding: true so response size is not a signal. Send a User-Agent that names your app. The docs say a missing user agent may get 403.
hibp 15.2.1 published on 18 January 2026. It exports pwnedPassword. I opened the npm readme for that export. Use it on the server. Do not send the plaintext to a browser copy as your only check. The browser can be skipped.
const { createHash } = require("node:crypto");
const { pwnedPassword } = require("hibp");
async function knownBreached(password) {
const count = await pwnedPassword(password, { addPadding: true });
return count > 0;
}
async function knownBreachedRange(password) {
const sha1 = createHash("sha1").update(password, "utf8").digest("hex").toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const res = await fetch("https://api.pwnedpasswords.com/range/" + prefix, {
headers: {
"User-Agent": "app.example.com-login",
"Add-Padding": "true",
},
});
if (!res.ok) throw new Error("pwned-range");
const body = await res.text();
for (const line of body.split("\n")) {
const [suf, count] = line.trim().split(":");
if (suf === suffix && Number(count) > 0) return true;
}
return false;
}
Call knownBreached on set-password and on a password that just verified. If it returns true, do not mint a session. Return 401 with the same body you use for a bad secret, then start reset mail for that userId if the verify had succeeded. A register path that accepts a breached secret is how a dictionary list becomes a fresh account on your site.
If HIBP is down, fail open on login only if you still require a passkey or TOTP for that account. Fail closed on set-password. I could not confirm an SLA. A local copy of the hash corpus is the offline option the API page documents.
Hash the secret you keep with argon2 0.45.1, published 21 July 2026. Use argon2id. Store only passwordHash. Compare with argon2.verify. A fast SHA-1 in your database is not a guessing defense. It is a second dump. The range check uses SHA-1 because that is the HIBP index. The row you keep is still argon2id.
Same 401, no secret in the log
A07 says use the same message for every login outcome. Unauthorized is enough. Skip “email missing” and “password wrong” variants. Keep the byte length stable. A missing user still runs argon2.verify against a dummy hash you minted at boot so the wall clock matches a real miss.
const argon2 = require("argon2");
const DUMMY_HASH = process.env.LOGIN_DUMMY_ARGON2;
async function loginHandler(req, res) {
const email = String(req.body.email || "").toLowerCase();
const password = String(req.body.password || "");
const user = await findUserByEmail(email);
const gate = await accountBackoff(user);
if (gate.blocked) {
res.set("Retry-After", String(gate.retryAfter));
res.status(429).send("Try later");
return;
}
const hash = user ? user.passwordHash : DUMMY_HASH;
const match = await argon2.verify(hash, password);
if (!user || !match) {
await recordFailure(user);
req.log.info({ email, result: "401" }, "login");
res.status(401).send("Unauthorized");
return;
}
if (await knownBreached(password)) {
await startReset(user.id);
req.log.info({ email, result: "401-pwned" }, "login");
res.status(401).send("Unauthorized");
return;
}
await clearFailures(user);
req.session.regenerate((err) => {
if (err) {
res.status(500).send("Unavailable");
return;
}
req.session.userId = user.id;
req.session.save(() => res.json({ ok: true }));
});
}
Log email, result, and ip. Never log the password, the Authorization header, or the session id. A09:2025 is Security Logging and Alerting Failures. A spike of 401s on one mailbox is the guessing alert. A spike across many mailboxes is the stuffing page. The secret itself is not a log field.
POST /login
|
v
loginLimit ip + email, 429
|
v
accountBackoff stepped delay, 429
|
v
argon2id verify same 401 body
|
v
knownBreached hit: 401, reset mail
|
v
passkey or TOTP if you require it
|
v
regenerate session
userId on the new row
Prove the gates on your origin
You are proving handlers you run. You are not guessing against a third-party site. You are not replaying anyone else’s mailbox. You are not publishing a list.
- POST
/loginon your origin with a dummy password for an address you own. Expect 401 and a log line that does not contain the dummy. - Repeat until
loginLimitreturns 429. If you never see 429, the limiter is not on that route. - On set-password, submit a well-known breached string you chose from HIBP’s own documentation examples, on an account you own. Expect reject.
- Register a passkey on your account. Sign in with it. Confirm a new
sidafterregenerate.
test("eleventh login is 429", async () => {
for (let i = 0; i < 10; i += 1) {
await request(app)
.post("/login")
.send({ email: "ada@your-app.example", password: "wrong" });
}
const last = await request(app)
.post("/login")
.send({ email: "ada@your-app.example", password: "wrong" });
expect(last.status).toEqual(429);
});
rg -n "loginLimit|accountBackoff|knownBreached|pwnedPassword|session\\.regenerate" \
--glob '!node_modules'
If pwnedPassword only exists on register, login still accepts a list hit. If the dummy appears in the log, rotate that test secret and fix the redact list. Identifiers stay loginLimit, accountBackoff, knownBreached, and loginHandler.
Questions we keep getting
Is a longer password enough if I skip the cap?
No. Length helps a stolen hash. It does not stop an online oracle. lazyjones’s line still holds. Put loginLimit on the route. Then require a passkey.
Should I lock the account after ten failures?
A hard lock is a hostage against that mailbox. Prefer loginLimit, accountBackoff, and a second factor. If you lock the password, still accept a passkey or a signed reset.
Is checking HIBP leaking the password to Troy Hunt?
Not if you use the range API. Five characters of the SHA-1 go on the wire. The suffix compare stays on your host. Sending the plaintext or the full hash to any third party is a different product. Do not do that.



