Password Hashing with bcrypt

Secure Password Storage for Modern Web Applications

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.

flowchart TD A[Authentication Security] --> B[Password Storage] B --> C[Plain Text] B --> D[Simple Hash] B --> E[Salted Hash] B --> F[Adaptive Hashing] F --> G[bcrypt] F --> H[Argon2] F --> I[PBKDF2] style A fill:#f9f9f9,stroke:#333,stroke-width:2px style B fill:#f9f9f9,stroke:#333,stroke-width:2px style C fill:#f8d7da,stroke:#842029,stroke-width:2px style D fill:#f8d7da,stroke:#842029,stroke-width:2px style E fill:#fff3cd,stroke:#664d03,stroke-width:2px style F fill:#d1e7dd,stroke:#0f5132,stroke-width:2px style G fill:#d1e7dd,stroke:#0f5132,stroke-width:2px style H fill:#d1e7dd,stroke:#0f5132,stroke-width:2px style I fill:#d1e7dd,stroke:#0f5132,stroke-width:2px

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:

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:

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.

sequenceDiagram participant P as Plain Text participant SH as Simple Hash (MD5/SHA1) participant SA as Salted Hash participant AH as Adaptive Hash (bcrypt) Note over P,AH: Password Storage Evolution P->>SH: Improvement: One-way transformation Note over SH: Vulnerability: Rainbow tables SH->>SA: Improvement: Unique salt per password Note over SA: Vulnerability: Fast computation SA->>AH: Improvement: Adjustable work factor Note over AH: Result: Slow, secure password verification

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

Anatomy of a bcrypt Hash

A bcrypt hash output looks like this:

$2b$10$kXp8M50RRGCfY5ANUFbNwe7KFzMG0WXOHnVl.EAh7XN9EMeaT2eS6

This string contains multiple components:

$2b$ Algorithm 10$ Cost kXp8M50RRGCfY5ANUFbNwe Salt (22 chars) 7KFzMG0WXOHnVl.EAh7XN9EMeaT2eS6 Hash (31 chars) Anatomy of a bcrypt Hash String

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.

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,
    });
};
            
sequenceDiagram participant Client participant Server participant UserModel participant Database alt Registration Flow Client->>Server: POST /api/auth/register {email, password, ...} Server->>UserModel: User.create(userData) UserModel->>UserModel: pre-save hook: Hash password with bcrypt UserModel->>Database: Save user with hashed password Database-->>UserModel: Return saved user UserModel-->>Server: User created Server->>Server: Generate JWT token Server-->>Client: Return token & success end alt Login Flow Client->>Server: POST /api/auth/login {email, password} Server->>UserModel: User.findOne({email}).select('+password') UserModel->>Database: Find user Database-->>UserModel: Return user with password UserModel-->>Server: Return user Server->>UserModel: user.matchPassword(password) UserModel->>UserModel: bcrypt.compare(password, hashedPassword) UserModel-->>Server: Password match result alt Password Matches Server->>Server: Generate JWT token Server-->>Client: Return token & success else Password Incorrect Server-->>Client: 401 Invalid credentials end end

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:


// 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:

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.

  1. Write a function that tests work factors from 8 to 15
  2. For each factor, create 10 hashes and calculate the average time
  3. Determine the optimal work factor for your application

Activity 2: Implement Password Validation

Implement a comprehensive password validation system.

  1. 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")
  2. Integrate it with your user registration flow
  3. 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.

  1. Implement user registration with bcrypt password hashing
  2. Create a login route that verifies passwords
  3. Add a "forgot password" flow with secure token generation
  4. Implement a password reset feature
  5. Add proper validation and error handling

Activity 4: Compare Hashing Algorithms

Create a comparison of different password hashing algorithms.

  1. Implement password hashing with:
    • Simple hashing (SHA-256)
    • Salted hashing
    • bcrypt
    • Argon2 (if available)
  2. Compare security features and performance
  3. 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:

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.

Additional Resources