Episode 3 — NodeJS MongoDB Backend Architecture / 3.14 — Authentication and Authorization

3.14.b — Password Security with Bcrypt

In one sentence: Never store plain-text passwords -- use bcrypt to produce a salted, adaptive hash that is intentionally slow to compute, defending against brute-force, dictionary, and rainbow-table attacks.

Navigation: <- 3.14.a Authentication vs Authorization | 3.14.c -- Session vs Token Authentication ->


1. Why never store plain-text passwords

If an attacker gains access to your database (SQL injection, leaked backup, insider threat), every user's password is instantly compromised.

Storage MethodWhat Attacker SeesRisk Level
Plain textpassword123CRITICAL -- immediate access to all accounts
Simple hash (MD5/SHA)482c811da5d5b4bc...HIGH -- rainbow tables crack common passwords in seconds
Salted hash (SHA + salt)a1b2c3... + unique saltMEDIUM -- safe against rainbow tables but fast GPUs crack quickly
bcrypt$2b$12$LJ3m4ys...LOW -- intentionally slow; salt built in; cost factor adjustable

The rule: If your database leaks, a properly bcrypt-hashed password should take years to crack per password.


2. Hashing vs encryption

HashingEncryption
DirectionOne-way -- cannot reverseTwo-way -- encrypt and decrypt
PurposeVerify data integrity / passwordsProtect data that must be read later
Reversible?No (by design)Yes (with the key)
Use for passwords?YESNO -- if the key leaks, all passwords leak
Examplesbcrypt, SHA-256, argon2AES-256, RSA

Key insight: You never need to see the original password. You only need to verify that the user's input matches the stored hash.


3. What bcrypt does

bcrypt is an adaptive hashing function designed specifically for passwords.

How bcrypt works internally

1. Generate a random SALT (16 bytes)
2. Combine: password + salt
3. Run Blowfish cipher repeatedly (2^costFactor times)
4. Output: algorithm + cost + salt + hash (all in one string)

Anatomy of a bcrypt hash

$2b$12$LJ3m4ys7sRGzQk6U5A8Xdu.HkFN4kjLmG5aO4P9yqE7h1vRHxSKy
 │  │  │                       │
 │  │  │                       └── 31 chars: the actual hash
 │  │  └── 22 chars: the salt (base64 encoded)
 │  └── cost factor: 12 (2^12 = 4096 iterations)
 └── algorithm version: 2b

The salt is stored inside the hash string -- you do not need to store it separately.


4. Salt: preventing rainbow table attacks

A salt is random data mixed with the password before hashing.

Without salt:

hash("password123") → always the same → attacker pre-computes a lookup table

With salt:

hash("password123" + "x9Kp2mQ...") → unique result
hash("password123" + "bR7nZ1a...") → completely different result

Even two users with the same password get different hashes because each has a unique salt.

AttackWithout SaltWith Salt
Rainbow tableLook up hash in table -- instantUseless -- each hash needs its own table
Dictionary attackHash each dictionary word once, compare all usersMust hash each word per user per salt
Brute forceStill possible but faster without saltStill possible; bcrypt makes each attempt slow

5. Salt rounds (cost factor)

The cost factor (or salt rounds) controls how many times the hashing algorithm iterates.

Salt RoundsIterations (2^n)Approx. Time (per hash)Use Case
8256~40msDevelopment / testing
101,024~100msMinimum for production
124,096~250msRecommended for production
1416,384~1sHigh-security applications
1665,536~4sExtremely sensitive (may hurt UX)

Recommendation: Use 10-12 for most applications. The cost factor should make hashing noticeably slow (100-300ms) but not painful for the user.

Future-proofing: As hardware gets faster, increase the cost factor. bcrypt is adaptive -- this is its superpower.


6. Installing bcrypt

npm install bcrypt

Note: bcrypt is a native C++ addon (faster). If you have build issues, use bcryptjs (pure JavaScript, slightly slower, zero native dependencies):

npm install bcryptjs

The API is identical.


7. Hashing a password

const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12; // cost factor

async function hashPassword(plainTextPassword) {
  // bcrypt.hash generates a salt AND hashes in one call
  const hashedPassword = await bcrypt.hash(plainTextPassword, SALT_ROUNDS);
  console.log(hashedPassword);
  // $2b$12$LJ3m4ys7sRGzQk6U5A8Xdu.HkFN4kjLmG5aO4P9yqE7h1vRHxSKy
  return hashedPassword;
}

// Alternative: generate salt separately (rarely needed)
async function hashWithExplicitSalt(plainTextPassword) {
  const salt = await bcrypt.genSalt(SALT_ROUNDS);
  const hash = await bcrypt.hash(plainTextPassword, salt);
  return hash;
}

8. Comparing passwords

async function verifyPassword(plainTextPassword, storedHash) {
  // bcrypt.compare extracts the salt from the hash automatically
  const isMatch = await bcrypt.compare(plainTextPassword, storedHash);
  return isMatch; // true or false
}

// Usage in a login handler
async function login(req, res) {
  const { email, password } = req.body;

  const user = await User.findOne({ email });
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Password matches -- issue token or create session
  const token = generateToken(user);
  res.json({ token, user: { id: user._id, name: user.name } });
}

Security note: Always return the same error message for "user not found" and "wrong password." This prevents user enumeration attacks.


9. Mongoose pre-save hook for automatic hashing

The cleanest pattern: hash the password before saving to the database, automatically.

const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12;

const userSchema = new mongoose.Schema({
  name:     { type: String, required: true, trim: true },
  email:    { type: String, required: true, unique: true, lowercase: true },
  password: { type: String, required: true, minlength: 8 },
  role:     { type: String, enum: ['user', 'admin', 'moderator'], default: 'user' },
}, { timestamps: true });

// --- Pre-save hook: hash password before saving ---
userSchema.pre('save', async function (next) {
  // Only hash if password is new or modified
  if (!this.isModified('password')) return next();

  try {
    this.password = await bcrypt.hash(this.password, SALT_ROUNDS);
    next();
  } catch (err) {
    next(err);
  }
});

// --- Instance method: compare password ---
userSchema.methods.comparePassword = async function (candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

// --- Remove password from JSON output ---
userSchema.methods.toJSON = function () {
  const obj = this.toObject();
  delete obj.password;
  return obj;
};

const User = mongoose.model('User', userSchema);
module.exports = User;

Usage:

// Registration -- password is hashed automatically
const user = await User.create({
  name: 'Alice',
  email: 'alice@example.com',
  password: 'securePassword123',
});
// user.password is now "$2b$12$..." (hashed)

// Login -- use the instance method
const user = await User.findOne({ email });
const isMatch = await user.comparePassword(req.body.password);

Why isModified('password')? Without this check, the password gets re-hashed every time you save the document (even when updating the name), turning the already-hashed value into a double-hash that can never be matched.


10. Password validation rules

Hash after validation. Enforce rules on the plain-text password before bcrypt processes it.

const Joi = require('joi');

const passwordSchema = Joi.object({
  password: Joi.string()
    .min(8)               // minimum 8 characters
    .max(128)             // maximum 128 (bcrypt has a 72-byte limit)
    .pattern(/[A-Z]/)     // at least one uppercase letter
    .pattern(/[a-z]/)     // at least one lowercase letter
    .pattern(/[0-9]/)     // at least one digit
    .required()
    .messages({
      'string.min': 'Password must be at least 8 characters',
      'string.max': 'Password must not exceed 128 characters',
      'string.pattern.base': 'Password must include uppercase, lowercase, and a digit',
    }),
});

bcrypt's 72-byte limit: bcrypt silently truncates input beyond 72 bytes. For very long passwords, pre-hash with SHA-256 before bcrypt (but this is rarely needed in practice with a 128-char max).


11. Common password attacks and how bcrypt defends

AttackHow It Worksbcrypt Defense
Brute forceTry every possible combinationEach attempt takes ~250ms; billions of years for strong passwords
Dictionary attackTry common words/passwordsSame slowness applies; each guess is expensive
Rainbow tablePre-computed hash lookupUnique salt per password makes tables useless
GPU accelerationParallel hash computationbcrypt is memory-hard and not easily parallelized on GPUs
Credential stuffingReuse leaked passwords from other sitesbcrypt cannot prevent this; use rate limiting + MFA

12. bcrypt vs argon2 vs scrypt

Featurebcryptargon2scrypt
Year introduced19992015 (won Password Hashing Competition)2009
Memory-hardPartiallyYes (tunable memory)Yes (tunable memory)
GPU resistanceGoodExcellentExcellent
AdoptionExtremely widespreadGrowing; recommended by OWASPModerate
Node.js librarybcrypt / bcryptjsargon2scrypt (built-in crypto)
Ease of useVery easyEasyModerate
RecommendationProduction-ready; safe choiceBest choice for new projectsGood alternative
// argon2 example (for comparison)
const argon2 = require('argon2');

const hash = await argon2.hash('password123');
const isMatch = await argon2.verify(hash, 'password123');

Bottom line: bcrypt is battle-tested and perfectly safe. argon2 is the newer gold standard. Both are vastly better than MD5/SHA for passwords.


13. Complete registration + login example

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('./models/User'); // schema from Section 9

const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET; // always from environment variable

// --- REGISTER ---
router.post('/register', async (req, res) => {
  try {
    const { name, email, password } = req.body;

    // 1. Check if user already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' });
    }

    // 2. Create user (password hashed by pre-save hook)
    const user = await User.create({ name, email, password });

    // 3. Issue token
    const token = jwt.sign({ userId: user._id, role: user.role }, JWT_SECRET, {
      expiresIn: '24h',
    });

    res.status(201).json({ token, user }); // password excluded by toJSON
  } catch (err) {
    res.status(500).json({ error: 'Registration failed' });
  }
});

// --- LOGIN ---
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    // 1. Find user (include password for comparison)
    const user = await User.findOne({ email }).select('+password');
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // 2. Compare passwords
    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // 3. Issue token
    const token = jwt.sign({ userId: user._id, role: user.role }, JWT_SECRET, {
      expiresIn: '24h',
    });

    res.json({ token, user });
  } catch (err) {
    res.status(500).json({ error: 'Login failed' });
  }
});

module.exports = router;

14. Key takeaways

  1. Never store plain-text passwords. Use bcrypt (or argon2) to produce one-way hashes.
  2. Salt is random data that makes each hash unique, even for identical passwords.
  3. Use 10-12 salt rounds in production -- slow enough to deter attackers, fast enough for users.
  4. Use a Mongoose pre-save hook with isModified('password') to hash automatically.
  5. bcrypt.compare extracts the salt from the stored hash -- you do not store salt separately.
  6. Return identical error messages for "user not found" and "wrong password" to prevent enumeration.

Explain-It Challenge

Explain without notes:

  1. Why is hashing (one-way) used for passwords instead of encryption (two-way)?
  2. Two users both set their password to "hello123". With bcrypt, will their stored hashes be identical? Why or why not?
  3. What does isModified('password') prevent in the Mongoose pre-save hook, and what would happen without it?

Navigation: <- 3.14.a Authentication vs Authorization | 3.14.c -- Session vs Token Authentication ->