Skip to content

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.

// ❌ Never do this
db.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.

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 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.

Terminal window
npm install bcrypt
npm install --save-dev @types/bcrypt
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 string

SALT_ROUNDS controls the computational cost — higher means slower. 12 is a good default for 2024.

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 match

bcrypt.compare is timing-safe — it always takes the same amount of time regardless of whether the password matches. This prevents timing attacks.

src/controllers/auth.ts
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 })
}
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).

  1. Install bcrypt.
  2. Write a quick script that hashes 'test-password' and logs the result. Note how long it takes.
  3. Run bcrypt.hash with SALT_ROUNDS = 10 and SALT_ROUNDS = 14. Compare the time.
  4. Verify that bcrypt.compare('test-password', hash) returns true and bcrypt.compare('wrong', hash) returns false.
  • 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 = 12 is a reasonable default in 2024.