Hashing Passwords with bcrypt
Storing plain text passwords is one of the most dangerous mistakes a developer can make. If your database is ever compromised, every user’s password is exposed. bcrypt makes this safe.
Why you never store plain text passwords
Section titled “Why you never store plain text passwords”// ❌ Never do thisdb.prepare('INSERT INTO users (username, password) VALUES (?, ?)').run( 'alice', 'mypassword123' // stored in plain text in the database)If an attacker reads your database (SQL injection, backup leak, misconfigured permissions), they have every user’s actual password — which they’ll try on Gmail, banking apps, and everywhere else.
Hashing vs encryption
Section titled “Hashing vs encryption”Encryption is reversible — you can decrypt to get the original. Hashing is one-way — you can’t reverse a hash to get the password.
With hashing, the server never needs to know the actual password. It only needs to verify: “does this input produce the same hash?“
bcrypt
Section titled “bcrypt”bcrypt is a password hashing algorithm designed to be deliberately slow. A hash takes ~100ms to compute. This is a feature — it makes brute-force attacks impractical.
npm install bcryptnpm install --save-dev @types/bcryptHashing a password
Section titled “Hashing a password”import bcrypt from 'bcrypt'
const SALT_ROUNDS = 12
async function hashPassword(password: string): Promise<string> { return bcrypt.hash(password, SALT_ROUNDS)}
const hash = await hashPassword('mypassword123')// '$2b$12$eImiTXuWVxfM37uY4JANjQ....' — 60-character stringSALT_ROUNDS controls the computational cost — higher means slower. 12 is a good default for 2024.
Verifying a password
Section titled “Verifying a password”On login, compare the submitted password against the stored hash:
async function verifyPassword(password: string, hash: string): Promise<boolean> { return bcrypt.compare(password, hash)}
const isValid = await verifyPassword('mypassword123', storedHash)// true — passwords match
const isInvalid = await verifyPassword('wrongpassword', storedHash)// false — passwords don't matchbcrypt.compare is timing-safe — it always takes the same amount of time regardless of whether the password matches. This prevents timing attacks.
Registration flow
Section titled “Registration flow”import bcrypt from 'bcrypt'import { db } from '../db/index.js'import { signToken } from '../utils/jwt.js'
const SALT_ROUNDS = 12
export async function register(req: Request, res: Response) { const { username, password } = req.body
// Check if username is taken const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username) if (existing) { return res.status(409).json({ error: 'Username already taken' }) }
// Hash the password const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
// Create the user const result = db.prepare( 'INSERT INTO users (username, password_hash) VALUES (?, ?)' ).run(username, passwordHash)
// Issue a JWT const token = signToken({ userId: result.lastInsertRowid, username })
res.status(201).json({ token, userId: result.lastInsertRowid, username })}Login flow
Section titled “Login flow”export async function login(req: Request, res: Response) { const { username, password } = req.body
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) if (!user) { // Use the same error message for both "user not found" and "wrong password" // Don't reveal which one is true (user enumeration attack) return res.status(401).json({ error: 'Invalid username or password' }) }
const isValid = await bcrypt.compare(password, user.password_hash) if (!isValid) { return res.status(401).json({ error: 'Invalid username or password' }) }
const token = signToken({ userId: user.id, username: user.username }) res.json({ token, userId: user.id, username: user.username })}Note the same error message for “user not found” and “wrong password” — this prevents user enumeration (attackers checking whether an account exists).
Exercise
Section titled “Exercise”- Install
bcrypt. - Write a quick script that hashes
'test-password'and logs the result. Note how long it takes. - Run
bcrypt.hashwithSALT_ROUNDS = 10andSALT_ROUNDS = 14. Compare the time. - Verify that
bcrypt.compare('test-password', hash)returnstrueandbcrypt.compare('wrong', hash)returnsfalse.
- Never store plain text passwords — always hash with bcrypt.
bcrypt.hash(password, saltRounds)→ a one-way hash string.bcrypt.compare(password, hash)→ boolean — whether the password matches.- Use the same error message for “user not found” and “wrong password” — prevents user enumeration.
SALT_ROUNDS = 12is a reasonable default in 2024.