The login page is the most attacked endpoint of any application, and the attacks are cheap: billions of leaked email and password pairs are freely traded, and a script can try them against your site in hours. OWASP's "Identification and Authentication Failures" (A07) covers everything that lets those attempts succeed. The password lesson covered hashing; this one covers the flow around it.
In this lesson you will learn how to slow attackers down without locking out real users, prevent account enumeration, add TOTP-based MFA in Node.js, and design a password reset that is not the weakest link.
Rate limiting is the first control, and it must be keyed on more than the IP, because stuffing attacks come from thousands of addresses:
const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10, // per key per window
keyGenerator: (req) => `${req.ip}:${String(req.body.email || "").toLowerCase()}`,
standardHeaders: "draft-7",
legacyHeaders: false
});
app.post("/login", loginLimiter, loginHandler);Add a second limiter per account regardless of IP, and escalate: after a few failures require a CAPTCHA or email confirmation rather than a hard lockout, which attackers abuse to lock real users out. Log every failure with account and IP.
The sample code returns the same message and takes the same time whether the email exists or not, because it always verifies a hash (a dummy one for unknown users). Apply the same rule wherever identity is checked:
| Endpoint | Leaky behaviour | Safe behaviour | |---|---|---| | Login | "User not found" | "Invalid email or password" | | Password reset | "No account for that email" | "If an account exists, we sent a link" |
Status codes must match too; a 404 for unknown users is as revealing as a message.
Time-based one-time passwords (TOTP) are the most widely supported second factor. Enrolment generates a secret, shows it as a QR code, and verifies one code before enabling it:
const { authenticator } = require("otplib");
const secret = authenticator.generateSecret(); // store encrypted, per user
const uri = authenticator.keyuri(user.email, "ShopExample", secret); // render as a QR code
// After the user scans it and types the first code:
if (authenticator.check(req.body.code, secret)) {
user.totpSecret = secret; // enable MFA
user.backupCodes = generateHashedBackupCodes(8); // one-time recovery codes
}Rate limit code verification (a six-digit code has only a million possibilities), issue hashed single-use backup codes, and require re-authentication for sensitive actions such as disabling MFA ("step-up"). Where you can, offer passkeys (WebAuthn): the credential is bound to your origin, so it cannot be phished.
The reset flow is a second login path and deserves the same care:
const token = crypto.randomBytes(32).toString("hex"); // emailed to the registered address only
await ResetToken.create({ userId: user.id, hash: sha256(token), expiresAt: Date.now() + 15 * 60_000 });Store only the hash, expire the token after 15 minutes, delete it after one use, and end all sessions when the password changes. Never use security questions; their answers are public.
Why does the login handler verify a dummy hash when the email does not exist?
otplib, protect it with rate limits and backup codes, and prefer passkeys.Next lesson: JWT Pitfalls and Token Security — what goes wrong when sessions become self-contained tokens.