Skip to main content
Auth, Identity & Security
🔐 Auth & SecurityLesson 2 of 13

Passwords Done Right

Hashing, salting, bcrypt/argon2 — never, ever store a password as plain text.

Passwords Done Right

Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L

You've shipped a login page. Users are signing up. That password column in your database is one leaked S3 bucket, one misconfigured server, one SQL injection away from exposing every password your users have — and because humans reuse passwords, every account they own anywhere. The fix is not complicated, but it is unforgiving. Get it right once and it holds. Get it wrong once and the breach email writes itself.


⚠️ The vibe trap

You asked an AI to scaffold your auth and it generated users.password = req.body.password straight into the database insert. The code runs, logins work, the demo is clean — so you shipped it. This is the most common way real apps get completely destroyed in a breach. When your database leaks (and at some point, something leaks), an attacker gets every plaintext password you stored. They don't need to crack anything. They just log in — to your app, and to every other site where your users reused that password. One column, total catastrophe.


🔐 Hashing vs Encryption — Why One-Way Matters

Mental model: Encryption is a locked box with a key — someone with the key can open it and read the contents. Hashing is a meat grinder — you can push data through it and get a fixed-length output, but you cannot reverse the process. For passwords, you want the meat grinder. You never need to recover the original password. You only need to verify that what a user typed matches what was stored. One-way is a feature, not a limitation.

Why: If you encrypt passwords, your encryption key becomes the skeleton key to every user account. Steal the key, decrypt everything. With a proper hash, there is no key to steal.

// ❌ NEVER — plaintext storage
await db.query(
  'INSERT INTO users (email, password) VALUES ($1, $2)',
  [email, password]   // raw string from req.body — catastrophic
);

// ❌ NEVER — encryption (reversible, key = total exposure)
const encrypted = crypto.createCipheriv('aes-256-cbc', SECRET_KEY, iv)
  .update(password, 'utf8', 'hex');  // if SECRET_KEY leaks, every password decrypts

// ✅ Hashing is one-way — nothing to decrypt
const hashed = await bcrypt.hash(password, 12);  // (more on this below)
await db.query(
  'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
  [email, hashed]
);

Common mistake: Naming the column password when it stores a hash. Name it password_hash. It signals intent to every future developer (including future-you) and makes it obvious something is wrong if raw text ever shows up there.


🧂 Salting — Defeating Rainbow Tables and Duplicate Detection

Mental model: Two users pick the password hunter2. Without a salt, they both get the exact same hash. An attacker with a precomputed table of common-password hashes (a "rainbow table") cracks both instantly. A salt is a random string — unique per user, stored alongside the hash — that gets mixed into the password before hashing. Now hunter2 + salt-A produces a completely different hash than hunter2 + salt-B, and neither matches any precomputed table.

Why: Salting forces an attacker to crack each hash individually from scratch. No shortcut tables. No "oh, these three users all have the same hash so they share a password" leak.

// bcrypt handles salting automatically — the salt is embedded in the hash string
const hash = await bcrypt.hash('hunter2', 12);
// hash looks like: $2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
//                         ^^^                ^^^^^^^^^^^^^^^^^^^^
//                     cost factor            22-char salt, embedded
//                                            no separate column needed

// You can verify without knowing the salt — bcrypt extracts it from the stored hash
const isValid = await bcrypt.compare('hunter2', storedHash);  // true
const isValid2 = await bcrypt.compare('Hunter2', storedHash); // false — case sensitive

Common mistake: Generating one global salt and reusing it for every user. That defeats the entire purpose. Each bcrypt/argon2 call generates a fresh random salt automatically — never write your own salting logic on top of these libraries.


🐢 Slow Hashes — Why bcrypt/Argon2/scrypt and NOT md5/sha256

Mental model: MD5 and SHA-256 were designed to be fast — they hash gigabytes of file data in seconds, which is great for checksums and signatures. For passwords, speed is your enemy. A modern GPU can compute hundreds of millions of MD5 hashes per second. If your database leaks, an attacker can try the entire rockyou.txt wordlist (14 million passwords) against every user hash in under a second.

bcrypt, Argon2, and scrypt are intentionally slow. They run an internal work loop — the cost factor — that you control. The goal is to make each hash attempt take ~100–300 ms on your server. That's invisible to a legitimate user logging in once. It means an attacker checking a billion guesses offline takes years, not seconds.

// Install once: npm install bcrypt
import bcrypt from 'bcrypt';

// SIGNUP — hash before storing
async function createUser(email, plainPassword) {
  const COST = 12;  // 2^12 = 4096 rounds; tune so hashing takes ~100-250ms on your hardware
  const passwordHash = await bcrypt.hash(plainPassword, COST);

  await db.query(
    'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
    [email, passwordHash]
  );
}

// LOGIN — compare attempt against stored hash
async function verifyPassword(email, attempt) {
  const row = await db.query(
    'SELECT password_hash FROM users WHERE email = $1',
    [email]
  );
  if (!row) return false;  // user not found — still return false, not an error (timing safety)

  // bcrypt.compare is constant-time — no timing oracle
  return bcrypt.compare(attempt, row.password_hash);
}

Why: Cost factor 10 is the minimum you should consider today (2026). Cost factor 12 is a reasonable default for most apps. Cost factor 14+ is appropriate for high-value accounts. Run node -e "const b=require('bcrypt'); console.time('t'); b.hash('x',12).then(()=>console.timeEnd('t'))" on your actual server hardware to measure.

Common mistake: Using a cost factor of 1 or leaving it at a library default from 2012. Libraries sometimes default to 10 — that was fine when 2^10 was slow enough. Check what current hardware can do and bump accordingly.


🔑 Argon2 — The Modern Choice

bcrypt is battle-tested and fine. Argon2 is the winner of the Password Hashing Competition (2015) and is the current best practice for new systems. It has three tunable parameters: time cost, memory cost, and parallelism — making it resistant to both fast CPUs and GPU attacks.

// Install once: npm install argon2
import argon2 from 'argon2';

// SIGNUP
async function createUser(email, plainPassword) {
  const passwordHash = await argon2.hash(plainPassword, {
    type: argon2.argon2id,   // argon2id = hybrid, recommended default
    memoryCost: 65536,       // 64 MB — forces attacker to use expensive memory per guess
    timeCost: 3,             // 3 passes
    parallelism: 4,
  });
  await db.query(
    'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
    [email, passwordHash]
  );
}

// LOGIN
async function verifyPassword(email, attempt) {
  const row = await db.query(
    'SELECT password_hash FROM users WHERE email = $1',
    [email]
  );
  if (!row) return false;

  return argon2.verify(row.password_hash, attempt);
}

Common mistake: Mixing argon2d and argon2i in the same system. Use argon2id everywhere — it is the safe default that defends against both side-channel and GPU attacks.


🔒 Password Reset Done Safely

Mental model: "Forgot your password?" is one of the most exploited flows in web apps. The wrong pattern: look up their account, generate a new password, email it to them. Wrong in two ways — you are emailing a plaintext credential over a channel you don't control, and you are storing it in their inbox forever. The right pattern: generate a cryptographically random single-use token, store a hashed version of it with an expiry, email only the link containing the raw token. When they click it, verify the token, let them set a new password, immediately invalidate the token.

import crypto from 'crypto';
import bcrypt from 'bcrypt';

// STEP 1: Generate and send the reset link
async function requestPasswordReset(email) {
  const user = await db.query('SELECT id FROM users WHERE email = $1', [email]);
  // Always return the same success message whether user exists or not (no enumeration)
  if (!user) return { message: 'If that email is registered, a reset link is on its way.' };

  const rawToken = crypto.randomBytes(32).toString('hex');  // 64-char hex, unguessable
  const tokenHash = await bcrypt.hash(rawToken, 10);        // store hash, not raw token
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

  await db.query(
    'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, used) VALUES ($1, $2, $3, false)',
    [user.id, tokenHash, expiresAt]
  );

  // Email contains rawToken — the DB only holds the hash
  await sendEmail(email, `Reset your password: https://yourapp.com/reset?token=${rawToken}`);
  return { message: 'If that email is registered, a reset link is on its way.' };
}

// STEP 2: Consume the token and set the new password
async function consumeResetToken(rawToken, newPassword) {
  const tokens = await db.query(
    'SELECT id, user_id, token_hash, expires_at, used FROM password_reset_tokens WHERE used = false AND expires_at > NOW()'
  );

  // Find the matching token by comparing against stored hashes
  let matched = null;
  for (const t of tokens) {
    if (await bcrypt.compare(rawToken, t.token_hash)) { matched = t; break; }
  }
  if (!matched) throw new Error('Invalid or expired token.');

  const newHash = await bcrypt.hash(newPassword, 12);
  await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [newHash, matched.user_id]);
  await db.query('UPDATE password_reset_tokens SET used = true WHERE id = $1', [matched.id]);
  // Optionally invalidate ALL tokens for this user to block parallel reset attacks
  await db.query('UPDATE password_reset_tokens SET used = true WHERE user_id = $1', [matched.user_id]);
}

Common mistake: Making reset tokens that never expire, or that can be reused. Both allow an attacker who intercepts an old email to reset an account weeks later. Expire them (15–60 minutes is standard) and mark them used immediately on consumption.


🚦 Rate-Limiting Login — Brute Force Is a Real Attack

You have bcrypt slowing each check to 100 ms on your server. An attacker with a botnet doesn't care — they just send 10,000 requests in parallel across different IPs. Rate-limiting stops that at the network layer before your bcrypt even runs.

// Install once: npm install express-rate-limit
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15-minute window
  max: 10,                    // 10 attempts per IP per window
  message: { error: 'Too many login attempts. Try again in 15 minutes.' },
  standardHeaders: true,
  legacyHeaders: false,
});

// Apply only to the login route
app.post('/auth/login', loginLimiter, async (req, res) => {
  const { email, password } = req.body;
  const ok = await verifyPassword(email, password);
  // ✅ Same response time and same message whether user exists or not
  if (!ok) return res.status(401).json({ error: 'Invalid email or password.' });
  // ... issue session / token
});

Common mistake: Logging password in any form — console.log('Login attempt:', email, password), error logs, request logging middleware that dumps req.body. Audit every logger in your auth path. Passwords must never appear in logs, ever. Use a structured logger that has an explicit denylist for sensitive fields, or destructure req.body before logging to drop the password field.


🛠️ Your Mission

Open the app you have been building and audit the password path end-to-end.

  1. Find where you store passwords on signup. If it is a raw string going into the database column, stop — fix this first before anything else.
  2. Check which library you are using. If you see md5, sha1, sha256, or crypto.createHash applied to passwords, replace it with bcrypt or argon2.
  3. Find your login check. Replace any === string comparison with bcrypt.compare or argon2.verify.
  4. Find your "forgot password" flow. If you are emailing the user their current password, or generating a new plaintext password, replace it with the token flow above.
  5. Find every console.log, logger.info, or middleware that touches req.body in the auth path. Ensure the password field is stripped before anything is logged.
  6. Add the rate-limit middleware to your login route.
  7. Run through the Security Audit Checklist and check off every password-related item — at minimum: A02 (Cryptographic Failures), A07 (Identification and Authentication Failures).

✅ You're Done When…

  • Every password-related item on the Security Audit Checklist is checked off, including A02 Cryptographic Failures and A07 Authentication Failures
  • Passwords are stored as bcrypt or argon2id hashes with cost factor ≥ 12 (bcrypt) or memoryCost ≥ 64 MB (argon2id)
  • bcrypt.compare or argon2.verify is the only way a login check passes — no === on passwords anywhere in the codebase
  • Grep for console.log in every auth-related file — zero results contain the word password as a value
  • Password reset issues a single-use token with ≤ 60-minute expiry, stored as a hash, invalidated immediately after use
  • Login route has rate-limit middleware that blocks after ≤ 10 failures per 15-minute window
  • The column in your database is named password_hash, not password

➡️ Next: Sessions vs Tokens (JWT). Build It Right, Or Don't Build It At All. 🏛️

Pre-launch security · partner tool

Built it? Now scan it. The HYVE Audit finds security holes before launch — $55, and your code never leaves your machine.

Run the audit ↗

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.

Passwords Done Right — TOVCDI | HYVE CARES