
Credential stuffing is a stolen username and password from one breach, tried on your login.
The attacker is not guessing. They are replaying pairs that already worked somewhere else. If your users reuse passwords, a clean bcrypt hash on your side does not save the account.
The usual mistake is a CAPTCHA on the form and nothing on the API, or a lockout that only keys on username so one password can walk the whole list slowly. Rate limits, breach-password checks, and MFA close different parts of the same path.
This page is how stuffing actually looks in logs, what Have I Been Pwned’s range API is for, and the controls that belong on a login you expose to the internet.
hibp 15.2.1 published on 18 January 2026. The range check it wraps has been free since Pwned Passwords V2.It walked rainbow tables and a reflection attack and called that stuffing.
Stuffing is a username and password pair that already leaked somewhere else, replayed at your login. Guessing many secrets against one mailbox is a different ticket. Cracking your stored hashes is a different ticket. A stuffing write-up that only covers guessing never showed the reuse shape. The control in 2026 is: do not accept a known-breached secret, do not let /login be a cheap oracle, and do not treat a password as the only factor.
Pair this page with cookie sessions for __Host-session and regenerate, with JWT in Express for the verifier you should usually skip, and with the secure coding checklist for the rest of the request. A stolen cookie after a stuffed login is XSS.
A pair that leaked on another site is only a problem if your login accepts it. Cap POST /login, require a second factor, and refuse a HIBP hit before you mint a session.
SecureCoding
Reuse is the whole attack
A person used the same secret on a forum that later dumped hashes, and on your app. Someone else now has that pair. They POST it to your login. If you only compare the argon2id hash and return a session, you just authenticated a breach from a site you do not run. That is stuffing. Password spraying is many mailboxes, one common secret. Brute force is many secrets, one mailbox. Do not mix the three in a runbook.
NIST SP 800-63B-4 is dated July 2025 on the publication PDF. The nist.gov record lists 1 August 2025. Verifiers SHALL compare prospective secrets against a list that contains values known to be commonly used, compromised, or expected. SP 800-63B-4 for that SHALL. HIBP’s own NIST page points at the same requirement. Composition rules such as one upper and one digit are the opposite of that document. Length is the rule. A breach corpus is the blocklist.
Your job is not to reconstruct how a third-party dump was used. Your job is to make a reused secret fail closed on your origin, and to make a successful factor mint a new session you can delete. The session page is that cookie. This page is the login gate in front of it.
Hash with a slow scheme. argon2 0.45.1 published on 21 July 2026. Use argon2id. Store only passwordHash. Compare with argon2.verify. A fast SHA-1 of the password in your database is not a stuffing defense. It is a second dump waiting to happen. The range check compares the plaintext the user just typed, on the server, against HIBP. Then you throw that plaintext away. The row you keep is the argon2id string.
import argon2 from "argon2";
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const match = user && await argon2.verify(user.passwordHash, password);
if (!match) {
req.log.info({ email, result: "401" }, "login");
res.status(401).send("Unauthorized");
return;
}
if (await knownBreached(password)) {
req.log.info({ email, result: "401-pwned" }, "login");
await startReset(user.id);
res.status(401).send("Unauthorized");
return;
}
Passkeys and a named second factor
"We have MFA" is not a control. 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. Email is not an out-of-band channel under that document.
W3C Web Authentication Level 3 Candidate Recommendation Snapshot dated 26 May 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, run the same regenerate path you use for a password. The passkey proved the person. The session id still needs a new row.
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
const rpID = "app.example.com";
const origin = "https://app.example.com";
app.get("/webauthn/login/options", async function options(req, res) {
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
});
req.session.webauthnLogin = options.challenge;
res.json(options);
});
async function userIdForCredential(id) {
const row = await loadPasskey(id);
return row.userId;
}
app.post("/webauthn/login/verify", async (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;
}
req.session.regenerate((err) => {
if (err) {
res.status(500).send("Unavailable");
return;
}
req.session.userId = await userIdForCredential(req.body.id);
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 stuffed password plus a prompt the bot cannot answer is still better than password-only. A stuffed password plus no prompt is still just a password form.
Cap /login
express-rate-limit 8.6.2 published on 4 August 2026. npm page for that date. Cap attempts per account and per IP. A botnet can rotate addresses, so an IP cap alone is not enough. An account cap alone is a lockout against one mailbox. Use both. Same 401 body for a missing user and a bad password so the response is not a user oracle.
import { rateLimit } from "express-rate-limit";
const loginLimit = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
keyGenerator: (req) => {
const email = String(req.body?.email || "").toLowerCase();
return `${req.ip}:${email}`;
},
});
app.post("/login", loginLimit, loginHandler);
Ten tries per 15 minutes is a starting cap, not a standard. Tune it when support tickets say a shared NAT is colliding. A CAPTCHA is friction. It does not replace a second factor or a breach check. Do not raise the limit to 200 so the dashboard looks quiet. Do not skip the cap on password-reset. Reset is a second login.
Log email, result, ip, and userAgent. Never log the password, the Authorization header, or the session id. A09:2025 is Security Logging and Alerting Failures. A spike of 401s across many mailboxes is the alert. A successful login from a new country after ten failures is the alert. The secret itself is not a log field.
Check the range API
HIBP API v3 page. Pwned Passwords is free and does not take an API key. You send the first five characters of a SHA-1 hash. The service returns suffixes and counts. You compare the rest of the hash locally. That is k-anonymity. The docs use prefix 21BD1 as the worked example. They also document an Add-Padding: true header 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 exports pwnedPassword. npm readme for that export name. It wraps the same range call. Use it on the server. Do not send the plaintext to the browser’s copy of the library as your only check. The browser can be skipped.
import { createHash } from "node:crypto";
import { pwnedPassword } from "hibp";
async function knownBreached(password) {
const count = await pwnedPassword(password, { addPadding: true });
return count > 0;
}
// Equivalent range call if you do not want the helper:
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 password, then start your reset mail for that userId if the verify had succeeded. adameasterling’s comment is that reset flow. A register path that accepts a breached secret is how stuffing gets a fresh account on your site with a password the lists already have.
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. A local copy of the hash corpus is the offline option the API page documents. Download it on a schedule if you cannot take a remote dependency on the hot path.
Session after the factor
A stuffed POST that passes every gate still needs a session design that you can kill. Prefer a server row behind __Host-session. express-session 1.19.0, updated 22 January 2026, still names the cookie connect.sid and omits SameSite unless you set it. Name it __Host-session. httpOnly, secure, sameSite: "lax", path: "/", no Domain. Run req.session.regenerate after the password or passkey holds. Write userId on the new row. Destroy the row on logout and on password change. The session guide is the flags.
A JWT in localStorage is a portable secret for the whole TTL. Logout is not a feature of RFC 7519. Stuffing plus a long-lived blob is a stolen session you cannot delete. Reach for jose only when a peer host must accept a signed blob without your store. Pin algorithms, aud, iss, and exp. The JWT page is that verifier. A first-party browser app in 2026 is not that peer.
Cookie-authenticated POST after that row exists still needs a CSRF check. One sentence is enough: read Sec-Fetch-Site on mutating routes. Do not add a JWT to dodge that check. Do not put the password in the JWT.
Prove your own login
You are proving handlers you run. You are not stuffing a third-party site. You are not replaying anyone else’s mailbox.
- 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. Do not use a colleague’s secret.
- Register a passkey on your account. Sign in with it. Confirm a new
sidin DevTools afterregenerate. - Copy as cURL a state-changing request from your own session. Add
Sec-Fetch-Site: cross-site. Expect 403.
rg -n "loginLimit|pwnedPassword|knownBreached|session\\.regenerate|connect\\.sid" \
--glob '!node_modules'
If pwnedPassword only exists on register, login still accepts a reused secret. If connect.sid is the cookie name, the session page is unfinished. If the dummy appears in the log, rotate that test secret and fix the redact list before you keep going.
Questions we keep getting
Does a password manager on the client replace these checks?
It helps the person who uses one. It does not help the person who reused a string, and it does not run on your server. Keep the range check and the cap. Offer a passkey so the manager is not the only unique secret.
Should I lock the account after ten failures?
A hard lock is a denial of service against that mailbox. Prefer a timed cap, a stepped delay, and a second factor. If you lock, unlock through a signed reset, not through a call-center password you read aloud.
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.



