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 Method | What Attacker Sees | Risk Level |
|---|---|---|
| Plain text | password123 | CRITICAL -- immediate access to all accounts |
| Simple hash (MD5/SHA) | 482c811da5d5b4bc... | HIGH -- rainbow tables crack common passwords in seconds |
| Salted hash (SHA + salt) | a1b2c3... + unique salt | MEDIUM -- 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
| Hashing | Encryption | |
|---|---|---|
| Direction | One-way -- cannot reverse | Two-way -- encrypt and decrypt |
| Purpose | Verify data integrity / passwords | Protect data that must be read later |
| Reversible? | No (by design) | Yes (with the key) |
| Use for passwords? | YES | NO -- if the key leaks, all passwords leak |
| Examples | bcrypt, SHA-256, argon2 | AES-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.
| Attack | Without Salt | With Salt |
|---|---|---|
| Rainbow table | Look up hash in table -- instant | Useless -- each hash needs its own table |
| Dictionary attack | Hash each dictionary word once, compare all users | Must hash each word per user per salt |
| Brute force | Still possible but faster without salt | Still 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 Rounds | Iterations (2^n) | Approx. Time (per hash) | Use Case |
|---|---|---|---|
| 8 | 256 | ~40ms | Development / testing |
| 10 | 1,024 | ~100ms | Minimum for production |
| 12 | 4,096 | ~250ms | Recommended for production |
| 14 | 16,384 | ~1s | High-security applications |
| 16 | 65,536 | ~4s | Extremely 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:
bcryptis a native C++ addon (faster). If you have build issues, usebcryptjs(pure JavaScript, slightly slower, zero native dependencies):npm install bcryptjsThe 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
| Attack | How It Works | bcrypt Defense |
|---|---|---|
| Brute force | Try every possible combination | Each attempt takes ~250ms; billions of years for strong passwords |
| Dictionary attack | Try common words/passwords | Same slowness applies; each guess is expensive |
| Rainbow table | Pre-computed hash lookup | Unique salt per password makes tables useless |
| GPU acceleration | Parallel hash computation | bcrypt is memory-hard and not easily parallelized on GPUs |
| Credential stuffing | Reuse leaked passwords from other sites | bcrypt cannot prevent this; use rate limiting + MFA |
12. bcrypt vs argon2 vs scrypt
| Feature | bcrypt | argon2 | scrypt |
|---|---|---|---|
| Year introduced | 1999 | 2015 (won Password Hashing Competition) | 2009 |
| Memory-hard | Partially | Yes (tunable memory) | Yes (tunable memory) |
| GPU resistance | Good | Excellent | Excellent |
| Adoption | Extremely widespread | Growing; recommended by OWASP | Moderate |
| Node.js library | bcrypt / bcryptjs | argon2 | scrypt (built-in crypto) |
| Ease of use | Very easy | Easy | Moderate |
| Recommendation | Production-ready; safe choice | Best choice for new projects | Good 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
- Never store plain-text passwords. Use bcrypt (or argon2) to produce one-way hashes.
- Salt is random data that makes each hash unique, even for identical passwords.
- Use 10-12 salt rounds in production -- slow enough to deter attackers, fast enough for users.
- Use a Mongoose pre-save hook with
isModified('password')to hash automatically. bcrypt.compareextracts the salt from the stored hash -- you do not store salt separately.- Return identical error messages for "user not found" and "wrong password" to prevent enumeration.
Explain-It Challenge
Explain without notes:
- Why is hashing (one-way) used for passwords instead of encryption (two-way)?
- Two users both set their password to
"hello123". With bcrypt, will their stored hashes be identical? Why or why not? - 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 ->