Password Hashing with bcrypt
Why You Must Never Store Plain Text Passwords
Imagine your database is breached. If passwords are stored in plain text, every single user's password is instantly exposed — not just on your platform, but on every other site where they reused that password.
This has happened to major companies. In 2012, LinkedIn had 117 million passwords stolen. The breach was catastrophic in part because they used weak password storage. In contrast, if you store passwords as secure hashes, a breach exposes only the hashes — which are computationally infeasible to reverse.
The rule is absolute: never store a password in plain text, encrypted, or with a simple hash like MD5 or SHA-1.
What is Hashing?
Hashing is a one-way transformation. You put a password in, you get a fixed-length string out. Crucially, you cannot reverse a hash to get the original password back.
"mypassword123" → hash → "$2b$12$X9Jz8fKL..."
"$2b$12$X9Jz8fKL..." → ??? → cannot reverse
When a user logs in, instead of decrypting the stored hash, you hash the provided password and compare the two hashes. If they match, the password is correct.
The Problem with Simple Hashes
Simple hash functions like MD5 and SHA-1 are fast — that is their flaw. Attackers use rainbow tables (precomputed lookup tables of hashes) and brute-force attacks to crack millions of hashes per second on modern hardware.
bcrypt was specifically designed for password hashing. Its key properties:
- Slow by design — bcrypt is intentionally slow, making brute-force attacks take years instead of seconds
- Salted — bcrypt automatically generates a unique random salt (random data) for each password before hashing, so two users with the same password get completely different hashes
- Adaptive cost factor — you control how slow bcrypt is; as hardware gets faster, you increase the cost factor to compensate
Understanding the Cost Factor
The cost factor (also called work factor or rounds) determines how many iterations bcrypt runs. Each increase by 1 doubles the computation time.
Cost 10: ~100ms per hash (good for most applications)
Cost 12: ~400ms per hash (stronger, used for sensitive data)
Cost 14: ~1600ms per hash (very strong)
A cost factor of 12 is a common modern recommendation. At cost 12, an attacker attempting to crack passwords by brute force would take years even with powerful hardware.
Using bcrypt in Node.js
Install the package:
npm install bcrypt
Hashing a Password
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12;
async function hashPassword(plainPassword) {
const hash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
return hash;
// Returns something like: "$2b$12$X9Jz8fKLmn3rPqR7..."
}
// Example
const hashed = await hashPassword('mySecretPassword');
console.log(hashed);
// "$2b$12$X9Jz8fKLmn3rPqR7vWz8OuCZr9HkP..."
The returned hash includes the cost factor and salt baked in — you only need to store this single string.
Comparing a Password
async function checkPassword(plainPassword, storedHash) {
const isMatch = await bcrypt.compare(plainPassword, storedHash);
return isMatch; // true or false
}
// Example
const isMatch = await checkPassword('mySecretPassword', storedHash);
console.log(isMatch); // true
const wrongMatch = await checkPassword('wrongPassword', storedHash);
console.log(wrongMatch); // false
bcrypt.compare() extracts the salt from the stored hash, re-hashes the provided password with the same salt, and compares the results. You never need to manage salts manually.
Full Register and Login Example
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const router = express.Router();
const SALT_ROUNDS = 12;
// POST /auth/register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({ message: 'Password must be at least 8 characters' });
}
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'Email already in use' });
}
// Hash the password before saving
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
const user = await User.create({ name, email, password: passwordHash });
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.status(201).json({
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// POST /auth/login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user — select('+password') because password field may be excluded by default
const user = await User.findOne({ email }).select('+password');
// Same error message for both "not found" and "wrong password" — prevents email enumeration
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ message: 'Invalid email or password' });
}
const token = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
Hiding the Password Field by Default
In your Mongoose User schema, exclude the password from query results by default:
const userSchema = new mongoose.Schema({
name: String,
email: String,
password: {
type: String,
required: true,
select: false, // Never return password in queries unless explicitly requested
},
});
With select: false, a call to User.find() will never include the password field — you must explicitly opt in with .select('+password') when you need it for comparison.
Password Reset Flow
When a user forgets their password, you should never email them their current password (because you cannot retrieve it). Instead:
- Generate a random reset token, hash it, and store the hash in the database with an expiry time
- Send the plain reset token to the user's email
- When they click the link, find the user by the hashed token, verify it has not expired
- Allow them to set a new password, then delete the reset token
This pattern means even if your database is breached, the stored reset tokens are hashed and cannot be used.
Practice Exercise
Implement secure password handling in your project:
- Add a
passwordfield to your User schema withselect: false - Add a pre-save hook using
userSchema.pre('save', ...)that automatically hashes the password whenever it changes (usethis.isModified('password')to check) - Create a
POST /auth/registerroute with password length validation (minimum 8 characters) - Create a
POST /auth/loginroute with the same error message for wrong email and wrong password - Test that even if you query the user directly, the password field is not returned
- Bonus: implement a password change route
PATCH /auth/change-passwordthat requires the current password before allowing a change
Try it yourself
Key Takeaways
- Never store passwords in plain text, encrypted form, or with fast hashes like MD5 or SHA-1.
- bcrypt is intentionally slow with an adjustable cost factor — each increment doubles computation time, protecting against brute-force attacks.
- bcrypt automatically generates and embeds a unique salt in every hash, ensuring identical passwords produce different hashes.
- Use bcrypt.hash() to create hashes on registration and bcrypt.compare() to verify passwords on login — never try to 'decrypt' a hash.
- Always use the same error message for 'user not found' and 'wrong password' to prevent attackers from discovering which emails are registered.
Quick Quiz
1.What is the purpose of a salt in password hashing?
2.Why is bcrypt preferred over SHA-256 for storing passwords?
3.In Mongoose, what does 'select: false' on a password field do?
4.What bcrypt cost factor is commonly recommended for modern web applications?
Ready to go further?
CareerEx gives you structured 12-week training, live classes every Saturday and Sunday, real tutor feedback, and a certificate. Join the next cohort.
Join CareerEx