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.3Problem
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
- Never mix callback and promise APIs - Choose one style and stick with it
- Always use
awaitwith bcrypt - Even if you do not need the result immediately - Set salt rounds to at least 12 - 10 is the minimum, 12 is recommended for 2024+
- Handle errors explicitly - bcrypt can throw on malformed hashes
- 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.