Node.js REST API · Security

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.

Intermediate 7 min Lesson 16 of 25
0/25 in Node.js REST API
On this page · 7 min · Intermediate

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#

Text Illustrative
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#

JavaScriptpassword-security.js Illustrative
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);
Expected output
"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#

  1. checkPassword enforces length, the 72-byte bcrypt limit, a blocklist and “no email name”, but no composition rules.
  2. The reset token is 32 random bytes (43 base64url characters); only its SHA-256 hash is stored.
  3. A weak new password is rejected without consuming the token.
  4. A successful reset deletes the token, so the same link cannot be used twice.
  5. 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#

Storing reset tokens in plain text

A database leak would let attackers reset any account with a pending reset. Store a hash.

Different answers for unknown emails

“No account with that email” on forgot-password leaks who your users are.

Forced complexity rules and expiry

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?
/^(.)\1+$/ matches repeated characters; check if "01234567890123456789".includes(password).
Show solution
JavaScript Illustrative
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#

Password Security quizMultiple choice Question 1 of 3

How should password-reset tokens be stored?

Score so far: 0/0

Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.