troubleshooting2025-05-25·7·53/348

Node.js에서 bcryptjs 사용 시 async/await 실수

Common pitfalls when using bcryptjs with async/await in Node.js and how to avoid race conditions in password hashing.

Node.js에서 bcryptjs 사용 시 async/await 실수

Introduction

Password hashing with bcryptjs is a fundamental security requirement in web applications. However, the library's callback-based API combined with modern async/await patterns often leads to subtle bugs that can cause race conditions or incorrect password verification. This post covers the most common mistakes I have encountered.

Environment

node --version
# v20.11.0

npm list bcryptjs
# my-app@1.0.0
# +-- bcryptjs@2.4.3

Problem

Here is a typical registration endpoint that contains a subtle bug:

// BUGGY CODE
app.post('/register', async (req, res) => {
  const { email, password } = req.body;
  
  const hash = bcrypt.hash(password, 10, (err, hash) => {
    return hash;
  });
  
  // hash is undefined here because the callback hasn't fired yet!
  await db.query('INSERT INTO users (email, password) VALUES (?, ?)', [email, hash]);
  
  res.json({ message: 'User created' });
});

The error occurs because bcrypt.hash with a callback does not return the hash synchronously. The variable hash is assigned undefined, and the user is created with an empty password hash.

Analysis

bcryptjs provides three different APIs, and mixing them up causes problems:

// 1. Synchronous (blocking)
const hash = bcrypt.hashSync(password, 10);

// 2. Callback-based
bcrypt.hash(password, 10, (err, hash) => {
  // hash is available here
});

// 3. Promise-based
const hash = await bcrypt.hash(password, 10);

The mistake happens when developers try to use the callback API with async/await:

// WRONG: Callback inside async function
const hash = bcrypt.hash(password, 10, (err, hash) => hash);

// RIGHT: Pure async/await
const hash = await bcrypt.hash(password, 10);

Another common mistake is comparing passwords incorrectly:

// WRONG: Synchronous comparison in async function
const isMatch = bcrypt.compareSync(password, user.password_hash);

// RIGHT: Async comparison
const isMatch = await bcrypt.compare(password, user.password_hash);

Solution

Solution 1: Consistent async/await usage

const bcrypt = require('bcryptjs');

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

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

Solution 2: Complete registration flow

app.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Validate input
    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password required' });
    }
    
    // Check if user exists
    const existing = await db.query('SELECT id FROM users WHERE email = ?', [email]);
    if (existing.rows.length > 0) {
      return res.status(409).json({ error: 'Email already registered' });
    }
    
    // Hash password properly
    const hashedPassword = await bcrypt.hash(password, 12);
    
    // Save user
    await db.query(
      'INSERT INTO users (email, password_hash) VALUES (?, ?)',
      [email, hashedPassword]
    );
    
    res.status(201).json({ message: 'User created successfully' });
  } catch (error) {
    console.error('Registration error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

Solution 3: Promisified wrapper for legacy code

const bcrypt = require('bcryptjs');
const { promisify } = require('util');

const hashAsync = promisify(bcrypt.hash).bind(bcrypt);
const compareAsync = promisify(bcrypt.compare).bind(bcrypt);

async function handleLogin(email, password) {
  const user = await db.query('SELECT * FROM users WHERE email = ?', [email]);
  
  if (user.rows.length === 0) {
    throw new Error('User not found');
  }
  
  const isMatch = await compareAsync(password, user.rows[0].password_hash);
  if (!isMatch) {
    throw new Error('Invalid password');
  }
  
  return user.rows[0];
}

Solution 4: Utility module with proper error handling

// utils/auth.js
const bcrypt = require('bcryptjs');

const SALT_ROUNDS = 12;

module.exports = {
  async hashPassword(plainPassword) {
    if (!plainPassword || typeof plainPassword !== 'string') {
      throw new TypeError('Password must be a non-empty string');
    }
    return bcrypt.hash(plainPassword, SALT_ROUNDS);
  },
  
  async verifyPassword(plainPassword, hashedPassword) {
    if (!plainPassword || !hashedPassword) {
      return false;
    }
    return bcrypt.compare(plainPassword, hashedPassword);
  }
};

Lessons Learned

  1. Never mix callback and promise APIs - Choose one style and stick with it
  2. Always use await with bcrypt - Even if you do not need the result immediately
  3. Set salt rounds to at least 12 - 10 is the minimum, 12 is recommended for 2024+
  4. Handle errors explicitly - bcrypt can throw on malformed hashes
  5. Test with edge cases - Empty strings, unicode characters, and very long passwords

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.