NodeJSHashing Passwords (bcrypt/argon2)

Hashing Passwords (bcrypt/argon2)

Your database will eventually be exposed — through a breach, a leaked backup, a misconfigured port, or an insider. The only question is what an attacker finds when it happens. If passwords are stored in plaintext (or reversibly encrypted), every account is instantly compromised — and because people reuse passwords, so are their accounts everywhere else. The defense is hashing: a one-way transformation you can verify against but never reverse. This page explains why hashing (not encryption), why a slow hash, and how to use bcrypt and argon2 correctly.

Hashing is not encryption

Encryption

Hashing

Reversible?

Yes — with the key

No — one-way by design

Use for passwords?

No — key theft reveals all

Yes — even theft leaks only hashes

Verify a password

Decrypt and compare

Hash the input, compare hashes

NEVER store passwords in plaintext or reversible encryption
Encryption is reversible — whoever holds the key can recover every password. Storing passwords encrypted just moves the secret to the key, which is also stealable. The correct model is a **one-way hash**: you store `hash(password)`, and at login you hash the submitted password and compare. You never need to recover the original — and neither should an attacker be able to. If your "forgot password" flow can email you your *current* password, it's storing it reversibly: a critical vulnerability.
Why a general-purpose hash (SHA-256) is wrong here

Text
SHA-256 is FAST — that's the problem for passwords:
  a modern GPU computes BILLIONS of SHA-256 hashes per second,
  so an attacker brute-forces common passwords almost instantly.

Password hashes are deliberately SLOW + SALTED:
  bcrypt / argon2 take ~100-250ms each and use a per-password salt,
  making mass guessing economically infeasible.
Don't use MD5/SHA for passwords — they're built to be fast, which helps attackers
Fast hashes (MD5, SHA-1, SHA-256) are designed for speed and integrity checking — exactly the wrong property for passwords, because speed lets an attacker test billions of guesses per second against a stolen hash. **Password hashing functions** (bcrypt, argon2, scrypt, PBKDF2) are deliberately slow and tunable: you set a cost factor so each hash takes ~100–250ms. That's invisible at login but catastrophic for an attacker trying to crack millions of hashes.
Salts and why they're built in
A salt makes identical passwords hash differently — defeating rainbow tables
A **salt** is a random value mixed into each password before hashing, stored alongside the hash. Without it, two users with the same password get the same hash, and attackers can precompute a giant lookup table (a *rainbow table*) once and crack everyone. A unique per-password salt means the attacker must attack each hash individually, and precomputed tables are useless. The good news: **bcrypt and argon2 generate and embed the salt for you** — the salt is part of the output string. You don't manage it manually.
bcrypt — the proven default

Bash
npm install bcrypt        # native; or 'bcryptjs' (pure JS, no build step)

JS
import bcrypt from 'bcrypt'

const SALT_ROUNDS = 12               // cost factor — higher = slower = safer

// On signup — hash before storing:
async function hashPassword(plain) {
  return bcrypt.hash(plain, SALT_ROUNDS)   // returns "$2b$12$<salt><hash>"
}

// On login — compare submitted password to stored hash:
async function verify(plain, storedHash) {
  return bcrypt.compare(plain, storedHash) // true / false, salt read from hash
}
$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
  │  │  └─ 22-char salt + 31-char hash, all in one string
  │  └──── cost factor (12 → 2^12 iterations)
  └─────── algorithm identifier (bcrypt)
`bcrypt.compare` re-hashes with the stored salt — never compare strings yourself
The stored bcrypt string contains the algorithm, cost, salt, *and* hash. `bcrypt.compare` parses out the salt and cost, hashes the input the same way, and compares in constant time. Never hash the input and `===` it against the stored value yourself — you'd skip the embedded salt and open a timing side-channel. Tune `SALT_ROUNDS` so a hash takes ~250ms on your hardware (12 is a reasonable 2024+ default); raise it as hardware improves.
argon2 — the modern winner

npm install argon2

JS
import argon2 from 'argon2'

// Argon2id resists both GPU and side-channel attacks (the recommended variant):
const hash = await argon2.hash(plain, {
  type: argon2.argon2id,
  memoryCost: 19456,   // ~19 MB — memory-hard, defeats cheap GPU parallelism
  timeCost: 2,
  parallelism: 1,
})

const ok = await argon2.verify(hash, plain)   // note: (hash, plain) order
argon2id is the current best practice; bcrypt remains perfectly safe
**Argon2** won the Password Hashing Competition and is **memory-hard** — it requires significant RAM per hash, which neutralizes the cheap massive parallelism that GPUs/ASICs exploit. The **argon2id** variant is the OWASP-recommended default for new applications. That said, **bcrypt is not broken** — it's still a fine, widely-deployed choice. Pick argon2id for greenfield projects; keep bcrypt if it's already in place. Either beats any fast hash by orders of magnitude.
Putting it together: signup and login

JS
// SIGNUP
app.post('/signup', async (req, res) => {
  const { email, password } = req.body
  if (password.length < 8) return res.status(400).json({ error: 'Password too short' })
  const passwordHash = await bcrypt.hash(password, 12)
  await db.users.create({ email, passwordHash })   // store the HASH, never the password
  res.status(201).json({ id: /* ... */ })
})

// LOGIN
app.post('/login', async (req, res) => {
  const { email, password } = req.body
  const user = await db.users.findByEmail(email)
  // Same generic error whether the email or the password is wrong:
  const ok = user && await bcrypt.compare(password, user.passwordHash)
  if (!ok) return res.status(401).json({ error: 'Invalid email or password' })
  // ...establish a session / issue a token here
})
Return the SAME error for wrong email and wrong password
If "no such email" and "wrong password" return different messages (or noticeably different response times), an attacker can **enumerate** which emails have accounts. Always return a single generic message like *"Invalid email or password"* and the same `401`. To avoid a timing leak when the email doesn't exist, you can compare against a dummy hash so the work is constant either way. Never confirm that an account exists to an unauthenticated caller.
Rules
  • Hash on the server, always — never hash on the client and treat that as the password.

  • Enforce a minimum length (8+), and check against known-breached password lists where you can.

  • Let the library handle the salt — bcrypt/argon2 embed it; don’t roll your own.

  • Rehash on login when you raise the cost factor — upgrade old hashes transparently.

  • Never log or return the hash — it stays in the database, full stop.

  • Add a rate limit on login — slow brute-force attempts at the door too.

Hashing protects the stored secret; rate limiting protects the login endpoint
Slow hashing makes a *stolen database* expensive to crack. But an attacker can also guess passwords *through your live login endpoint* — which is fast unless you stop them. Pair password hashing with [rate limiting](/nodejs/rate-limiting) and account lockout/backoff so online guessing is throttled, and offer MFA for an extra factor. Defense in depth: protect the data at rest *and* the door it comes through.
Next
Now that users can log in, keep them logged in safely: [Sessions & Cookies](/nodejs/sessions).