Password Security
Hashing is only part of password security. You also need sensible password rules, a reset flow that cannot be abused, and a way to log out every session when a password changes.
On this page · 7 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Related tutorials
Interview questions
Learning objectives#
- Apply modern (NIST-style) password rules
- Block common and personal passwords
- Build a secure password-reset flow
- Invalidate existing sessions after a password change
Explanation#
Modern guidance (NIST SP 800-63B): require length (at least 8, better 12+), allow long passphrases and every character, block known-breached and common passwords, and drop forced composition rules (“1 capital, 1 symbol”) and periodic forced changes.
bcrypt only uses the first 72 bytes of a password. Limit length to 72 bytes (or pre-hash consistently, or use argon2id). Note “bytes” — emoji and many non-Latin characters take several bytes each.
Reset tokens: generate 32 random bytes, email the token, store only its SHA-256 hash with an expiry (15–60 minutes). Using the token: look up the hash, check expiry, set the new password, delete the token (single use).
The “forgot password” endpoint should always answer the same (“If an account exists, we sent an email”) to avoid revealing accounts, and be rate-limited.
After a reset or change, invalidate other sessions: delete refresh tokens, or bump a tokenVersion stored with the user and included in tokens.
Syntax#
POST /api/auth/forgot-password { email } → 202 always (same message)
POST /api/auth/reset-password { token, password } → 204 | 400 invalid/expired token
password_resets: token_hash CHAR(64) PK, user_id, expires_at
Code example: Password rules and a secure reset flow#
import { randomBytes, createHash } from 'node:crypto';
import bcrypt from 'bcryptjs';
// 1. Password rules: length + blocklist + personal info, no composition rules
const COMMON = new Set(['password', 'password123', '12345678', 'qwerty123', 'learnhub123', 'iloveyou']);
function checkPassword(password, user) {
const problems = [];
if (password.length < 12) problems.push('use at least 12 characters');
if (Buffer.byteLength(password) > 72) problems.push('use at most 72 bytes');
if (COMMON.has(password.toLowerCase())) problems.push('this password is too common');
if (password.toLowerCase().includes(user.email.split('@')[0])) problems.push('do not include your email name');
return problems;
}
const user = { id: 1, email: 'asha@example.com', passwordHash: await bcrypt.hash('old-passphrase-2024', 10), tokenVersion: 1 };
for (const candidate of ['password123', 'asha-is-great-2026', 'correct horse battery staple']) {
const problems = checkPassword(candidate, user);
console.log(`"${candidate}" →`, problems.length ? problems.join('; ') : 'OK');
}
// 2. Reset tokens: email the token, store only its hash
const sha256 = text => createHash('sha256').update(text).digest('hex');
const resetTokens = new Map(); // token_hash → { userId, expiresAt }
function requestReset(user, now) {
const token = randomBytes(32).toString('base64url');
resetTokens.set(sha256(token), { userId: user.id, expiresAt: now + 15 * 60_000 });
return token; // goes into the email link only
}
async function resetPassword(token, newPassword, now) {
const entry = resetTokens.get(sha256(token));
if (!entry || entry.expiresAt < now) return 'invalid or expired link';
const problems = checkPassword(newPassword, user);
if (problems.length) return 'rejected: ' + problems.join('; ');
resetTokens.delete(sha256(token)); // single use
user.passwordHash = await bcrypt.hash(newPassword, 10);
user.tokenVersion++; // every old session is now invalid
return 'password changed';
}
const now = Date.now();
const token = requestReset(user, now);
console.log('token length:', token.length, '| stored as hash:', !resetTokens.has(token));
console.log('weak password: ', await resetPassword(token, 'short', now));
console.log('good password: ', await resetPassword(token, 'new sunny passphrase 7', now + 60_000));
console.log('reuse same link: ', await resetPassword(token, 'another long passphrase', now + 120_000));
const late = requestReset(user, now);
console.log('after 16 minutes:', await resetPassword(late, 'another long passphrase', now + 16 * 60_000));
console.log('new password works:', await bcrypt.compare('new sunny passphrase 7', user.passwordHash), '| tokenVersion:', user.tokenVersion);
"password123" → use at least 12 characters; this password is too common "asha-is-great-2026" → do not include your email name "correct horse battery staple" → OK token length: 43 | stored as hash: true weak password: rejected: use at least 12 characters good password: password changed reuse same link: invalid or expired link after 16 minutes: invalid or expired link new password works: true | tokenVersion: 2
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- checkPassword enforces length, the 72-byte bcrypt limit, a blocklist and “no email name”, but no composition rules.
- The reset token is 32 random bytes (43 base64url characters); only its SHA-256 hash is stored.
- A weak new password is rejected without consuming the token.
- A successful reset deletes the token, so the same link cannot be used twice.
- Expired tokens fail; bumping tokenVersion logs out every existing session.
Real-world use case#
LearnHub checks new passwords against the Have I Been Pwned range API (k-anonymity: only the first 5 characters of the SHA-1 hash are sent) and emails reset links that expire after 15 minutes.
Common mistakes#
A database leak would let attackers reset any account with a pending reset. Store a hash.
“No account with that email” on forgot-password leaks who your users are.
They push users to P@ssw0rd1! patterns and sticky notes. Length plus a blocklist works better.
Best practices#
- 12+ characters, blocklist, no composition rules
- Respect bcrypt’s 72-byte limit
- Hashed, expiring, single-use reset tokens
- Invalidate sessions after password changes
Remember this#
Long > complex. Block common passwords.
Reset token: random, hashed, short-lived, single use.
Password changed → log out every session.
Practice exercise#
Your task: Extend checkPassword so passwords made of one repeated character (“aaaaaaaaaaaa”) or a simple digit sequence (“123456789012”) are rejected.
Need a hint?
Show solution
function isTrivial(password) {
return /^(.)\1+$/.test(password) || '01234567890123456789'.includes(password);
}
console.log(isTrivial('aaaaaaaaaaaa')); // true
console.log(isTrivial('123456789012')); // true
console.log(isTrivial('blue lamp river 42')); // false
Mini quiz#
How should password-reset tokens be stored?
Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions
My notes#
Saved in this browser.