Introduction
In our previous lecture, we discussed authentication and authorization as the foundation of web application security. Today, we'll dive deeper into one of the most critical aspects of authentication: secure password storage using bcrypt.
Storing passwords securely is essential for protecting user accounts. If your database is compromised, properly hashed passwords can mean the difference between a minor incident and a catastrophic breach affecting all your users.
Real-World Password Breaches
Password security is not theoretical—major companies have experienced breaches with serious consequences:
- LinkedIn (2012): 6.5 million SHA-1 hashed passwords leaked; many were cracked quickly due to missing salt
- Adobe (2013): 153 million user records exposed with poorly encrypted passwords
- Yahoo (2013-2014): 3 billion user accounts compromised, including poorly hashed passwords
- Equifax (2017): 147 million Americans affected in a breach exposing personal information
These incidents highlight why implementing proper password hashing like bcrypt is crucial for any application storing user credentials.
The Evolution of Password Storage
Plain Text: The Cardinal Sin
Storing passwords in plain text is the most dangerous approach. If an attacker gains access to your database, they immediately have everyone's passwords.
// WARNING: NEVER DO THIS!
{
"username": "johndoe",
"email": "john@example.com",
"password": "MySecretPassword123" // Stored as plain text
}
Simple Hashing: A First Step
Hashing converts a password into a fixed-length string of characters that appears random. With a proper hashing algorithm, it should be practically impossible to reverse the process.
const crypto = require('crypto');
function hashPassword(password) {
return crypto.createHash('md5').update(password).digest('hex');
}
// Example output for "password123"
// Output: 482c811da5d5b4bc6d497ffa98491e38
However, simple hashing algorithms like MD5 and SHA-1 have several vulnerabilities:
- They are fast to compute, making brute-force attacks feasible
- They're vulnerable to rainbow table attacks (pre-computed tables of hash values)
- Identical passwords produce identical hashes
Salted Hashing: Adding Complexity
A salt is a random string that is added to the password before hashing. This ensures that identical passwords don't produce the same hash.
const crypto = require('crypto');
function hashPassword(password) {
// Generate a random salt
const salt = crypto.randomBytes(16).toString('hex');
// Hash the password with the salt
const hash = crypto.createHash('sha256')
.update(password + salt)
.digest('hex');
// Return both the salt and the hash
return { salt, hash };
}
function verifyPassword(password, salt, hash) {
const calculatedHash = crypto.createHash('sha256')
.update(password + salt)
.digest('hex');
return calculatedHash === hash;
}
Salting addresses rainbow table attacks and ensures that identical passwords have different hashes. However, it doesn't solve the speed problem—attackers with powerful hardware can still compute billions of hashes per second.
Adaptive Hashing Functions: Modern Protection
Adaptive (or "slow") hashing functions are designed to be deliberately computationally intensive. They include:
- bcrypt: Based on the Blowfish cipher, specifically designed for password hashing
- Argon2: Winner of the 2015 Password Hashing Competition, designed to be resistant to GPU attacks
- PBKDF2: Password-Based Key Derivation Function that applies a pseudorandom function multiple times
These algorithms use a work factor (or cost) that can be adjusted as hardware gets faster, ensuring that the hashing process remains slow enough to deter attackers but fast enough for legitimate authentication.
Understanding bcrypt
What is bcrypt?
bcrypt is a password-hashing function designed by Niels Provos and David Mazières in 1999. It's based on the Blowfish cipher and specifically designed for long-term password storage. bcrypt remains one of the most recommended solutions for password hashing due to its security features.
Key Features of bcrypt
- Built-in Salt: Automatically generates and stores a random salt with each hash
- Adjustable Work Factor: Allows increasing computational cost as hardware improves
- Fixed Output Length: Always produces the same length output regardless of input
- Slow by Design: Deliberately consumes computational resources to slow down brute-force attacks
Anatomy of a bcrypt Hash
A bcrypt hash output looks like this:
$2b$10$kXp8M50RRGCfY5ANUFbNwe7KFzMG0WXOHnVl.EAh7XN9EMeaT2eS6
This string contains multiple components:
- $2b$: Algorithm identifier indicating the bcrypt version
- 10$: The work factor (cost) - 2^10 iterations
- kXp8M50RRGCfY5ANUFbNwe: The 22-character salt (encoded in Base64)
- 7KFzMG0WXOHnVl.EAh7XN9EMeaT2eS6: The 31-character hash (encoded in Base64)
The Work Factor
The work factor (also called cost) is a crucial parameter in bcrypt. It determines how computationally intensive the hashing process will be.
- Each increment of the work factor doubles the computational cost
- A higher work factor makes brute-force attacks significantly more difficult
- The optimal work factor depends on your server resources and security requirements
- Today, a work factor of 10-12 is often recommended, but this should increase over time
Work Factor Performance Comparison
Here's a rough idea of how different work factors might perform on modern hardware:
| Work Factor | Iterations (2^n) | Approximate Time to Hash |
|---|---|---|
| 8 | 256 | ~15ms |
| 10 | 1,024 | ~60ms |
| 12 | 4,096 | ~250ms |
| 14 | 16,384 | ~1s |
Note: Actual performance will vary based on hardware. You should benchmark on your production servers to find the right balance between security and performance.
Implementing bcrypt in Node.js
Installing bcrypt
To use bcrypt in a Node.js application, we'll use the bcryptjs package, a pure JavaScript implementation that works across platforms without native dependencies.
npm install bcryptjs
Basic Usage: Hashing Passwords
Here's how to hash a password using bcrypt:
const bcrypt = require('bcryptjs');
async function hashPassword(plainTextPassword) {
// Generate a salt
// The number 10 is the work factor (2^10 iterations)
const salt = await bcrypt.genSalt(10);
// Hash the password with the salt
const hash = await bcrypt.hash(plainTextPassword, salt);
return hash;
}
// Example usage
async function example() {
const password = 'MySecurePassword123';
const hashedPassword = await hashPassword(password);
console.log(hashedPassword);
// Output: $2a$10$XxPL8BgRXC9TRyFPWvIdQeXbM9K7wKTaQVLo7XyKwLzg7uo/FU9ai
}
example();
You can also generate the salt and hash in a single step:
async function hashPassword(plainTextPassword) {
// The salt is generated internally by bcrypt
// and combined with the hash
const hash = await bcrypt.hash(plainTextPassword, 10);
return hash;
}
Verifying Passwords
To verify a password against a stored hash:
async function verifyPassword(plainTextPassword, hashedPassword) {
// Returns true if the password matches, false otherwise
const isMatch = await bcrypt.compare(plainTextPassword, hashedPassword);
return isMatch;
}
// Example usage
async function loginExample() {
const storedHash = '$2a$10$XxPL8BgRXC9TRyFPWvIdQeXbM9K7wKTaQVLo7XyKwLzg7uo/FU9ai';
// Correct password
const isMatch1 = await verifyPassword('MySecurePassword123', storedHash);
console.log('Correct password:', isMatch1); // true
// Incorrect password
const isMatch2 = await verifyPassword('WrongPassword', storedHash);
console.log('Incorrect password:', isMatch2); // false
}
loginExample();
Synchronous vs. Asynchronous API
bcrypt provides both synchronous and asynchronous APIs. The asynchronous API is recommended for web applications to avoid blocking the event loop:
// Asynchronous (recommended)
const hashedPassword = await bcrypt.hash(password, 10);
const isMatch = await bcrypt.compare(password, hashedPassword);
// Synchronous (not recommended for web servers)
const hashedPasswordSync = bcrypt.hashSync(password, 10);
const isMatchSync = bcrypt.compareSync(password, hashedPasswordSync);
Important Note on bcrypt and Password Length
bcrypt has a maximum password length of 72 bytes. If you need to support longer passwords, consider pre-hashing the password with a fast algorithm like SHA-256 and then passing the result to bcrypt:
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
async function hashLongPassword(password) {
// Pre-hash with SHA-256 to support longer passwords
const passwordHash = crypto
.createHash('sha256')
.update(password)
.digest('hex');
// Then hash with bcrypt
return bcrypt.hash(passwordHash, 10);
}
Integrating bcrypt with Express and MongoDB
User Model with bcrypt
Here's how to integrate bcrypt into a Mongoose user model:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const UserSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
},
email: {
type: String,
required: true,
unique: true,
match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please enter a valid email'],
},
password: {
type: String,
required: true,
minlength: 6,
select: false, // Don't return password by default in queries
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user',
},
createdAt: {
type: Date,
default: Date.now,
},
});
// Middleware: Hash password before saving
UserSchema.pre('save', async function(next) {
// Only hash the password if it's modified (or new)
if (!this.isModified('password')) {
return next();
}
try {
// Generate salt and hash
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// Method to compare passwords
UserSchema.methods.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = mongoose.model('User', UserSchema);
Authentication Controller
Now let's implement an authentication controller that uses the bcrypt-equipped User model:
const User = require('../models/User');
const ErrorResponse = require('../utils/errorResponse');
const asyncHandler = require('../middleware/async');
const jwt = require('jsonwebtoken');
// @desc Register a user
// @route POST /api/v1/auth/register
// @access Public
exports.register = asyncHandler(async (req, res, next) => {
const { username, email, password, role } = req.body;
// Create user - password hashing happens in the pre-save hook
const user = await User.create({
username,
email,
password,
role,
});
sendTokenResponse(user, 201, res);
});
// @desc Login user
// @route POST /api/v1/auth/login
// @access Public
exports.login = asyncHandler(async (req, res, next) => {
const { email, password } = req.body;
// Validate credentials
if (!email || !password) {
return next(new ErrorResponse('Please provide email and password', 400));
}
// Find the user - include password in this query
const user = await User.findOne({ email }).select('+password');
// Check if user exists
if (!user) {
return next(new ErrorResponse('Invalid credentials', 401));
}
// Check if password matches
const isMatch = await user.matchPassword(password);
if (!isMatch) {
return next(new ErrorResponse('Invalid credentials', 401));
}
sendTokenResponse(user, 200, res);
});
// Helper function to create token and send response
const sendTokenResponse = (user, statusCode, res) => {
// Create token
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
);
const options = {
expires: new Date(
Date.now() + process.env.JWT_COOKIE_EXPIRE * 24 * 60 * 60 * 1000
),
httpOnly: true,
};
// Add secure flag in production
if (process.env.NODE_ENV === 'production') {
options.secure = true;
}
res
.status(statusCode)
.cookie('token', token, options)
.json({
success: true,
token,
});
};
Password Reset Implementation
Here's how you might implement a secure password reset functionality using bcrypt:
// Add to User model
UserSchema.add({
resetPasswordToken: String,
resetPasswordExpire: Date
});
// Add to auth controller
// @desc Forgot password
// @route POST /api/v1/auth/forgotpassword
// @access Public
exports.forgotPassword = asyncHandler(async (req, res, next) => {
const user = await User.findOne({ email: req.body.email });
if (!user) {
return next(new ErrorResponse('No user with that email', 404));
}
// Generate reset token
const resetToken = crypto.randomBytes(20).toString('hex');
// Hash token and set to resetPasswordToken field
user.resetPasswordToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
// Set expire - 10 minutes
user.resetPasswordExpire = Date.now() + 10 * 60 * 1000;
await user.save({ validateBeforeSave: false });
// Send email with reset link (implementation omitted)
// ...
res.status(200).json({
success: true,
message: 'Reset token email sent'
});
});
// @desc Reset password
// @route PUT /api/v1/auth/resetpassword/:resettoken
// @access Public
exports.resetPassword = asyncHandler(async (req, res, next) => {
// Get hashed token
const resetPasswordToken = crypto
.createHash('sha256')
.update(req.params.resettoken)
.digest('hex');
// Find user by token and check expiration
const user = await User.findOne({
resetPasswordToken,
resetPasswordExpire: { $gt: Date.now() }
});
if (!user) {
return next(new ErrorResponse('Invalid or expired token', 400));
}
// Set new password - hashing will happen in the pre-save hook
user.password = req.body.password;
// Clear reset fields
user.resetPasswordToken = undefined;
user.resetPasswordExpire = undefined;
await user.save();
sendTokenResponse(user, 200, res);
});
bcrypt Security Considerations
Choosing the Right Work Factor
The work factor should be high enough to be secure, but not so high that it impacts your application's performance. Consider:
- Higher work factors provide better security but increase computation time
- The work factor should increase as hardware improves
- For most web applications in 2025, a work factor of 10-12 is reasonable
- Consider benchmarking on your specific hardware
// Simple benchmark function to help determine optimal work factor
async function benchmarkBcrypt() {
console.log('Benchmarking bcrypt work factors...');
for (let factor = 8; factor <= 15; factor++) {
const start = Date.now();
// Generate hash with current work factor
await bcrypt.hash('benchmark_password', factor);
const end = Date.now();
const duration = end - start;
console.log(`Work factor ${factor}: ${duration}ms`);
}
}
Timing Attacks and Constant-Time Comparison
Timing attacks try to extract information by measuring how long operations take. bcrypt includes protection against these attacks by using constant-time comparison for password verification.
Store Passwords Separately
Consider storing highly sensitive information like passwords in a separate database or with additional encryption for defense in depth.
Never Log or Display Passwords
Ensure passwords (plain text or hashed) never appear in:
- Application logs
- Error messages
- User interfaces
- API responses
Password Migration Strategy
If transitioning from a weaker hashing algorithm to bcrypt, implement a gradual migration:
async function verifyPasswordWithMigration(user, password) {
// If already using bcrypt, simply verify
if (user.passwordVersion === 'bcrypt') {
return await bcrypt.compare(password, user.password);
}
// For old hashing method (e.g., MD5)
if (user.passwordVersion === 'md5') {
const oldHash = crypto.createHash('md5').update(password).digest('hex');
// If old hash matches
if (oldHash === user.password) {
// Update to bcrypt while user is logging in
user.password = await bcrypt.hash(password, 10);
user.passwordVersion = 'bcrypt';
await user.save();
return true;
}
return false;
}
}
Analogy: Safe Deposit Box vs. Vault
Think of password storage like different levels of security at a bank:
- Plain text passwords are like keeping cash in an unlocked drawer - anyone who gains access can immediately take it
- Simple hashing is like a basic safe with a simple lock - it provides some security, but professional thieves can crack it quickly
- Salted hashing is like a safe deposit box - more secure, but still vulnerable to determined attacks with the right tools
- bcrypt is like a bank vault with a time lock - even with the right tools, it forces attackers to spend significant time on each attempt, making mass cracking impractical
The work factor in bcrypt is like adjusting how thick the vault walls are - as tools for breaking in improve, you make the walls thicker to maintain the same level of security.
Beyond bcrypt: Alternative Approaches
Argon2: The New Champion
Argon2 won the Password Hashing Competition in 2015 and is designed to be even more resistant to attacks, particularly against specialized hardware:
const argon2 = require('argon2');
async function hashPassword(password) {
try {
// Key parameters:
// - memoryCost: memory usage in KiB (higher = more secure)
// - timeCost: number of iterations (higher = more secure)
// - parallelism: number of threads to use
const hash = await argon2.hash(password, {
type: argon2.argon2id, // Hybrid of argon2i and argon2d
memoryCost: 16384, // 16 MB
timeCost: 3, // 3 iterations
parallelism: 2 // 2 threads
});
return hash;
} catch (err) {
console.error('Error hashing password', err);
throw err;
}
}
async function verifyPassword(password, hash) {
try {
return await argon2.verify(hash, password);
} catch (err) {
console.error('Error verifying password', err);
throw err;
}
}
PBKDF2: The NIST Recommendation
PBKDF2 is recommended by NIST (National Institute of Standards and Technology) and is available in Node.js's crypto module:
const crypto = require('crypto');
const util = require('util');
// Promisify PBKDF2 function
const pbkdf2 = util.promisify(crypto.pbkdf2);
async function hashPassword(password) {
// Generate a random salt
const salt = crypto.randomBytes(16);
// Key parameters:
// - Iterations: 310,000 is NIST's recommendation for PBKDF2-HMAC-SHA256 in 2023
// - Key length: 32 bytes (256 bits)
// - Digest: SHA-512
const key = await pbkdf2(password, salt, 310000, 32, 'sha512');
// Convert to Base64 and format for storage
const hashString = `pbkdf2_sha512$310000$${salt.toString('base64')}$${key.toString('base64')}`;
return hashString;
}
async function verifyPassword(password, hashString) {
// Parse the hash string
const parts = hashString.split('$');
const iterations = parseInt(parts[1], 10);
const salt = Buffer.from(parts[2], 'base64');
const storedKey = Buffer.from(parts[3], 'base64');
// Calculate hash with the same parameters
const key = await pbkdf2(password, salt, iterations, storedKey.length, 'sha512');
// Compare in constant time to prevent timing attacks
return crypto.timingSafeEqual(key, storedKey);
}
Comparing Hashing Algorithms
| Algorithm | Strengths | Considerations | Best Use Cases |
|---|---|---|---|
| bcrypt |
- Well-established and battle-tested - Integrated salt generation - Adjustable work factor |
- 72-byte password limit - Limited memory hardness - Slower on some platforms |
- Most web applications - When compatibility is important |
| Argon2 |
- Modern design against various attacks - Configurable memory, time, and parallelism - No practical password length limit |
- Less battle-tested than bcrypt - Implementation quality varies - More complex to configure optimally |
- High-security applications - When memory-hardness is important - Newer systems without legacy concerns |
| PBKDF2 |
- Available in standard libraries - NIST recommended - No password length limit |
- Less memory-hard (vulnerable to specialized hardware) - Requires more iterations to match bcrypt security |
- When standard compliance is required - When minimal dependencies are important |
Industry Recommendations
Major security organizations recommend:
- OWASP: Argon2id as the first choice, then bcrypt, then PBKDF2
- NIST: PBKDF2 with at least 310,000 iterations (as of SP 800-63B)
- Industry practice: bcrypt with a work factor of at least 10
The general consensus is that all three are acceptable if configured properly, but Argon2 offers the best protection against modern attacks.
Practice Activities
Activity 1: Benchmark bcrypt Work Factors
Create a script to benchmark different bcrypt work factors on your development machine.
- Write a function that tests work factors from 8 to 15
- For each factor, create 10 hashes and calculate the average time
- Determine the optimal work factor for your application
Activity 2: Implement Password Validation
Implement a comprehensive password validation system.
- Create a password validator that checks for:
- Minimum length (at least 8 characters)
- Complexity (uppercase, lowercase, numbers, special characters)
- Common password check (reject passwords like "password123")
- Integrate it with your user registration flow
- Provide meaningful feedback to users about password requirements
Activity 3: Build a Complete Authentication System
Create a complete authentication system with registration, login, and password reset.
- Implement user registration with bcrypt password hashing
- Create a login route that verifies passwords
- Add a "forgot password" flow with secure token generation
- Implement a password reset feature
- Add proper validation and error handling
Activity 4: Compare Hashing Algorithms
Create a comparison of different password hashing algorithms.
- Implement password hashing with:
- Simple hashing (SHA-256)
- Salted hashing
- bcrypt
- Argon2 (if available)
- Compare security features and performance
- Create a report on your findings
Conclusion
Secure password storage is a critical aspect of web application security. Using bcrypt or a similar adaptive hashing function is essential for protecting user credentials.
Key takeaways from this lecture:
- Never store passwords in plain text or using simple hashing algorithms
- bcrypt is a strong, battle-tested solution for password hashing
- The work factor should be adjusted based on your security requirements and hardware capabilities
- Implementation is straightforward with most modern frameworks and libraries
- Always keep security best practices in mind when handling user credentials
As computing power increases and attack methods evolve, it's important to stay updated on security recommendations. What's secure today may not be sufficient tomorrow, so periodic review and updates to your security practices are essential.