Password Hashing with bcrypt

Difficulty: Intermediate

Storing passwords in plain text is one of the most dangerous security mistakes a developer can make. If an attacker gains access to your database, every user's credentials are instantly compromised. Password hashing solves this by transforming passwords into irreversible fixed-length strings using a one-way cryptographic function. Even if the hash is stolen, the original password cannot be recovered from it.

bcrypt is the industry-standard algorithm for password hashing in Node.js applications. Unlike simple hashing algorithms like MD5 or SHA-256, bcrypt is specifically designed for passwords. It incorporates a salt (a random string added to the password before hashing) automatically, which means two users with the same password will have different hashes. This prevents rainbow table attacks where precomputed hashes are used to reverse common passwords.

The 'cost factor' or 'salt rounds' parameter in bcrypt controls how computationally expensive the hashing process is. A salt rounds value of 10 means the hashing function runs 2^10 (1024) iterations. Higher values make brute-force attacks slower but also make your login process slower. A value of 10-12 is considered a good balance for most applications. As hardware gets faster, you can increase this value.

In the Node.js ecosystem, you have two main choices: the native `bcrypt` package (which uses C++ bindings) and `bcryptjs` (a pure JavaScript implementation). The native bcrypt is faster but requires compilation during installation, which can cause issues on some systems. bcryptjs is slower but works everywhere without any native dependencies, making it ideal for serverless deployments and Docker containers.

The typical workflow is: when a user registers, hash their password with bcrypt.hash() and store only the hash. When they log in, use bcrypt.compare() to check their provided password against the stored hash. Never try to decrypt the hash - bcrypt.compare() handles the comparison internally by hashing the input with the same salt and comparing the results.

Code examples

Hashing and Comparing Passwords with bcryptjs

const bcrypt = require('bcryptjs');

async function registerUser(password) {
  const saltRounds = 10;
  const hashedPassword = await bcrypt.hash(password, saltRounds);
  console.log('Original:', password);
  console.log('Hashed:', hashedPassword);
  console.log('Hash length:', hashedPassword.length);
  return hashedPassword;
}

async function loginUser(password, storedHash) {
  const isMatch = await bcrypt.compare(password, storedHash);
  console.log('Password match:', isMatch);
  return isMatch;
}

async function main() {
  const hash = await registerUser('mySecurePass123');
  await loginUser('mySecurePass123', hash);
  await loginUser('wrongPassword', hash);
}

main();

bcrypt.hash() generates a salt and hashes the password in one step. The resulting hash string contains the algorithm identifier ($2a$), the cost factor (10), the salt, and the hash itself. bcrypt.compare() extracts the salt from the stored hash and uses it to hash the input password for comparison.

Synchronous vs Asynchronous bcrypt

const bcrypt = require('bcryptjs');

// Synchronous (blocks the event loop - avoid in production)
const salt = bcrypt.genSaltSync(10);
const hashSync = bcrypt.hashSync('password123', salt);
const matchSync = bcrypt.compareSync('password123', hashSync);
console.log('Sync hash:', hashSync.substring(0, 30) + '...');
console.log('Sync match:', matchSync);

// Asynchronous (preferred - non-blocking)
async function asyncExample() {
  const salt = await bcrypt.genSalt(12);
  console.log('Generated salt:', salt);
  const hash = await bcrypt.hash('password123', salt);
  console.log('Async hash:', hash.substring(0, 30) + '...');
  const match = await bcrypt.compare('password123', hash);
  console.log('Async match:', match);
}

asyncExample();

The synchronous methods (genSaltSync, hashSync, compareSync) block the event loop and should only be used in scripts or during startup. In request handlers, always use the async versions to keep your server responsive.

Password Hashing in an Express Registration Route

const express = require('express');
const bcrypt = require('bcryptjs');
const app = express();
app.use(express.json());

// Simulated database
const users = [];

app.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;

    // Check if user exists
    if (users.find(u => u.email === email)) {
      return res.status(400).json({ error: 'Email already registered' });
    }

    // Hash password with cost factor 10
    const hashedPassword = await bcrypt.hash(password, 10);

    // Store user with hashed password
    const user = { id: users.length + 1, email, password: hashedPassword };
    users.push(user);

    res.status(201).json({ message: 'User registered', userId: user.id });
  } catch (error) {
    res.status(500).json({ error: 'Registration failed' });
  }
});

app.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = users.find(u => u.email === email);

    if (!user || !(await bcrypt.compare(password, user.password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    res.json({ message: 'Login successful', userId: user.id });
  } catch (error) {
    res.status(500).json({ error: 'Login failed' });
  }
});

console.log('Auth routes configured');

This shows a realistic Express registration and login flow. The register route hashes the password before storage. The login route uses bcrypt.compare() to verify credentials. Notice the generic error message for invalid credentials - never reveal whether the email or password was wrong.

Key points

Concepts covered

hashing, salting, bcrypt, bcryptjs, password security, compareSync