bcrypt Complete Guide | Secure Password Hashing in Node.js

bcrypt Complete Guide | Secure Password Hashing in Node.js

이 글의 핵심

bcrypt is a password hashing library designed to be slow and computationally expensive, making brute-force attacks impractical. It's the industry standard for secure password storage.

Introduction

bcrypt is a password-hashing function designed for secure password storage. Unlike fast hash functions (MD5, SHA256), bcrypt is intentionally slow, making brute-force attacks impractical.

Why bcrypt?

Insecure (never do this):

const password = 'mypassword123';
const hash = crypto.createHash('sha256').update(password).digest('hex');

Problems:

  • ❌ Too fast (billions of hashes per second)
  • ❌ No salt (vulnerable to rainbow tables)
  • ❌ Not designed for passwords

Secure (with bcrypt):

const bcrypt = require('bcrypt');
const hash = await bcrypt.hash('mypassword123', 10);

Benefits:

  • ✅ Intentionally slow
  • ✅ Automatic salting
  • ✅ Adaptive (increase cost over time)

1. Installation

npm install bcrypt

2. Basic Usage

Hash a Password

const bcrypt = require('bcrypt');

async function hashPassword(password) {
  const saltRounds = 10;
  const hash = await bcrypt.hash(password, saltRounds);
  return hash;
}

// Usage
const hash = await hashPassword('mypassword123');
console.log(hash);
// $2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy

Verify a Password

async function verifyPassword(password, hash) {
  const match = await bcrypt.compare(password, hash);
  return match;
}

// Usage
const isValid = await verifyPassword('mypassword123', hash);
console.log(isValid); // true

const isInvalid = await verifyPassword('wrongpassword', hash);
console.log(isInvalid); // false

3. Salt Rounds (Cost Factor)

The salt rounds parameter determines how slow the hash function is. Each increase by 1 doubles the time.

// Fast (less secure)
await bcrypt.hash(password, 8);   // ~40ms

// Balanced (recommended)
await bcrypt.hash(password, 10);  // ~150ms

// Secure (slower)
await bcrypt.hash(password, 12);  // ~600ms

// Very secure (slow)
await bcrypt.hash(password, 14);  // ~2400ms

Recommendation: Start with 10, increase as hardware improves.

4. Manual Salt Generation

async function hashWithCustomSalt(password) {
  // Generate salt
  const salt = await bcrypt.genSalt(10);
  console.log(salt); // $2b$10$N9qo8uLOickgx2ZMRZoMye
  
  // Hash with salt
  const hash = await bcrypt.hash(password, salt);
  return hash;
}

Usually not needed — bcrypt.hash() does this automatically.

5. Synchronous API

// Hash (blocks event loop - not recommended)
const hash = bcrypt.hashSync('password123', 10);

// Verify (blocks event loop - not recommended)
const isValid = bcrypt.compareSync('password123', hash);

Warning: Use async versions in production to avoid blocking.

6. User Registration

const express = require('express');
const bcrypt = require('bcrypt');
const db = require('./database');

const app = express();
app.use(express.json());

app.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Validate password strength
    if (password.length < 8) {
      return res.status(400).json({ error: 'Password too short' });
    }
    
    // Check if user exists
    const existingUser = await db.findUserByEmail(email);
    if (existingUser) {
      return res.status(409).json({ error: 'Email already exists' });
    }
    
    // Hash password
    const hashedPassword = await bcrypt.hash(password, 10);
    
    // Save user
    const user = await db.createUser({
      email,
      password: hashedPassword,
    });
    
    res.status(201).json({ id: user.id, email: user.email });
  } catch (error) {
    res.status(500).json({ error: 'Registration failed' });
  }
});

7. User Login

app.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Find user
    const user = await db.findUserByEmail(email);
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    // Verify password
    const isValid = await bcrypt.compare(password, user.password);
    if (!isValid) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    // Generate token (JWT, session, etc.)
    const token = generateAuthToken(user);
    
    res.json({ token, user: { id: user.id, email: user.email } });
  } catch (error) {
    res.status(500).json({ error: 'Login failed' });
  }
});

8. Password Reset

const crypto = require('crypto');

// Generate reset token
app.post('/forgot-password', async (req, res) => {
  const { email } = req.body;
  
  const user = await db.findUserByEmail(email);
  if (!user) {
    // Don't reveal if email exists
    return res.json({ message: 'If email exists, reset link sent' });
  }
  
  // Generate random token
  const resetToken = crypto.randomBytes(32).toString('hex');
  const resetTokenHash = await bcrypt.hash(resetToken, 10);
  const expiry = Date.now() + 3600000; // 1 hour
  
  await db.saveResetToken(user.id, resetTokenHash, expiry);
  
  // Send email with resetToken (not hash!)
  await sendResetEmail(email, resetToken);
  
  res.json({ message: 'If email exists, reset link sent' });
});

// Reset password
app.post('/reset-password', async (req, res) => {
  const { token, newPassword } = req.body;
  
  // Find user by token
  const resetData = await db.findResetToken();
  
  // Verify token
  const isValidToken = await bcrypt.compare(token, resetData.hash);
  if (!isValidToken || Date.now() > resetData.expiry) {
    return res.status(400).json({ error: 'Invalid or expired token' });
  }
  
  // Hash new password
  const hashedPassword = await bcrypt.hash(newPassword, 10);
  
  // Update user
  await db.updatePassword(resetData.userId, hashedPassword);
  await db.deleteResetToken(resetData.userId);
  
  res.json({ message: 'Password reset successful' });
});

9. Sequelize Integration

const { DataTypes } = require('sequelize');
const bcrypt = require('bcrypt');

const User = sequelize.define('User', {
  email: {
    type: DataTypes.STRING,
    unique: true,
    allowNull: false,
  },
  password: {
    type: DataTypes.STRING,
    allowNull: false,
  },
}, {
  hooks: {
    // Hash password before save
    beforeCreate: async (user) => {
      if (user.password) {
        user.password = await bcrypt.hash(user.password, 10);
      }
    },
    beforeUpdate: async (user) => {
      if (user.changed('password')) {
        user.password = await bcrypt.hash(user.password, 10);
      }
    },
  },
});

// Instance method to verify password
User.prototype.verifyPassword = async function(password) {
  return await bcrypt.compare(password, this.password);
};

// Usage
const user = await User.create({
  email: 'user@example.com',
  password: 'password123', // Automatically hashed
});

const isValid = await user.verifyPassword('password123');

10. Mongoose Integration

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

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
  },
  password: {
    type: String,
    required: true,
  },
});

// Hash password before save
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

// Instance method to verify password
userSchema.methods.verifyPassword = async function(password) {
  return await bcrypt.compare(password, this.password);
};

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

// Usage
const user = new User({
  email: 'user@example.com',
  password: 'password123',
});
await user.save(); // Password automatically hashed

const isValid = await user.verifyPassword('password123');

11. Security Best Practices

1. Never Log Passwords

// Bad
console.log('Password:', password);
logger.info('User registered', { password });

// Good
console.log('User registered');
logger.info('User registered', { email: user.email });

2. Rate Limit Login Attempts

const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests
  message: 'Too many login attempts, try again later',
});

app.post('/login', loginLimiter, async (req, res) => {
  // Login logic
});

3. Use Constant-Time Comparison

bcrypt.compare() already uses constant-time comparison to prevent timing attacks. Never use === to compare hashes.

// Bad - vulnerable to timing attacks
if (hash === storedHash) { }

// Good - constant time
await bcrypt.compare(password, hash);

4. Enforce Strong Passwords

function isStrongPassword(password) {
  return (
    password.length >= 8 &&
    /[A-Z]/.test(password) &&
    /[a-z]/.test(password) &&
    /[0-9]/.test(password) &&
    /[^A-Za-z0-9]/.test(password)
  );
}

5. Increase Cost Factor Over Time

async function rehashIfNeeded(user, password) {
  const currentCost = 10;
  const hash = user.password;
  
  // Extract cost from hash
  const cost = parseInt(hash.split('$')[2], 10);
  
  if (cost < currentCost) {
    const newHash = await bcrypt.hash(password, currentCost);
    await db.updatePassword(user.id, newHash);
  }
}

12. Performance Optimization

1. Don’t Hash in Request Handler

// Bad - blocks all requests
app.post('/register', (req, res) => {
  const hash = bcrypt.hashSync(password, 14); // Blocks!
});

// Good - non-blocking
app.post('/register', async (req, res) => {
  const hash = await bcrypt.hash(password, 14);
});

2. Use Worker Threads for Heavy Load

const { Worker } = require('worker_threads');

function hashPasswordWorker(password, saltRounds) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./hash-worker.js', {
      workerData: { password, saltRounds },
    });
    
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

13. Testing

const bcrypt = require('bcrypt');

describe('Password Hashing', () => {
  it('should hash password', async () => {
    const password = 'password123';
    const hash = await bcrypt.hash(password, 10);
    
    expect(hash).not.toBe(password);
    expect(hash).toMatch(/^\$2[ab]\$/);
  });
  
  it('should verify correct password', async () => {
    const password = 'password123';
    const hash = await bcrypt.hash(password, 10);
    
    const isValid = await bcrypt.compare(password, hash);
    expect(isValid).toBe(true);
  });
  
  it('should reject incorrect password', async () => {
    const password = 'password123';
    const hash = await bcrypt.hash(password, 10);
    
    const isValid = await bcrypt.compare('wrongpassword', hash);
    expect(isValid).toBe(false);
  });
  
  it('should generate unique hashes', async () => {
    const password = 'password123';
    const hash1 = await bcrypt.hash(password, 10);
    const hash2 = await bcrypt.hash(password, 10);
    
    expect(hash1).not.toBe(hash2); // Different salts
  });
});

Summary

bcrypt provides secure password storage:

  • Slow by design - resistant to brute force
  • Automatic salting - prevents rainbow tables
  • Adaptive - increase cost over time
  • Industry standard - proven and trusted
  • Easy to use - simple API

Key Takeaways:

  1. Never store plain passwords
  2. Use cost factor 10-12
  3. Always use async methods
  4. Implement rate limiting
  5. Enforce strong passwords

Next Steps:

  • Implement JWT Auth
  • Secure with Helmet
  • Build Express API

Resources: