Store passwords safely — why you never keep the plain text, how to hash and check a password with bcrypt, what a salt is, and why a fast hash like SHA-256 is the wrong tool for passwords.
A hash is a one-way fingerprint of some text: easy to compute forwards, practically impossible to reverse. You store the hash of a password, never the password. The reason is simple: databases leak. If yours does and you stored plain text, every account is instantly compromised — and because people reuse passwords, so are their accounts elsewhere. With hashes, the attacker gets fingerprints they still have to crack. "Hashing" is just turning the password into that fingerprint before it ever touches your database.
// NEVER do this — a leaked table hands over every account:
// users(email, password) -> ("ada@x.com", "hunter2")
// Instead store only a one-way hash. You can CHECK a password
// against it at login, but you can never read the original back:
// users(email, passwordHash)
// -> ("ada@x.com", "$2b$12$Q9....e6u") // bcrypt hashbcrypt is the standard, safe choice for password hashing. You call hash() once when a user signs up and store the result; at login you call compare(), which hashes the attempt the same way and checks it for you. Two things bcrypt handles automatically: it mixes in a random "salt" (unique per password, so two users with the same password get different hashes — this defeats precomputed "rainbow table" attacks), and it stores that salt inside the hash string, so compare() just works. The cost factor (12 here) sets how slow it is on purpose — more on that next.
$ pnpm add bcryptjsimport bcrypt from 'bcryptjs'
// On SIGN-UP: hash once, store the result (the salt is baked in).
export async function hashPassword(plain: string) {
return bcrypt.hash(plain, 12) // 12 = cost factor (work per hash)
}
// On LOGIN: compare the attempt against the stored hash.
export async function checkPassword(plain: string, hash: string) {
return bcrypt.compare(plain, hash) // true / false — never decrypts
}
// Using them in a sign-up, then a login:
const passwordHash = await hashPassword('hunter2') // -> store this
const ok = await checkPassword('hunter2', passwordHash) // -> trueSHA-256 is a hash too — so why not use it for passwords? Because it is fast, and fast is exactly wrong here: an attacker with a leaked hash can try billions of guesses per second. Password hashes are deliberately slow. bcrypt, scrypt, and Argon2 are "password hashing" functions built to take real time (and, for scrypt/Argon2, real memory) per guess, so cracking is expensive. Rule of thumb: use bcrypt (or Argon2/scrypt) for passwords; SHA-256 is still the right tool for non-password jobs like file checksums and signatures, where speed is a feature.
// WRONG for passwords — SHA is fast, so guessing is cheap:
// import { createHash } from 'crypto'
// createHash('sha256').update(password).digest('hex')
// RIGHT for passwords — deliberately slow, salted, hard to crack:
// bcrypt — battle-tested, simple, the safe default (used above)
// scrypt — slow AND memory-hard (Node has crypto.scrypt built in)
// argon2 — modern winner of the password-hashing competition
// SHA-256 is still correct OUTSIDE passwords, e.g. a file checksum:
import { createHash } from 'crypto'
const checksum = createHash('sha256').update(fileBytes).digest('hex')