Skip to main content

🧂 Password Hashing with bcrypt

A password stored as plaintext is a breach waiting to happen — the day your database leaks, every account is compromised. In this lesson you'll learn why hashing (not encryption) is the right tool, what a salt and a cost factor actually do, and how to hash and verify passwords correctly with bcrypt in Node.js.

Week 9 · Day 1 (Monday: Authentication Basics) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why passwords must be hashed, never stored in plaintext or reversibly encrypted
  • Describe how a salt defeats rainbow tables and identical-password leakage
  • Explain how an adaptive cost factor keeps hashing slow enough to resist brute force
  • Read the anatomy of a bcrypt hash string
  • Hash and verify passwords with bcrypt.hash and bcrypt.compare (async)
  • Integrate bcrypt into a user model without ever leaking the hash

Estimated Time: 60 minutes

Practice: Build a hash-and-verify module and reason about cost-factor trade-offs.

In This Lesson

Why Never Store Plaintext

Imagine your app's users table leaks — through a SQL injection, a misconfigured backup, or a stolen laptop. If passwords are stored as plaintext, the attacker instantly owns every account. Worse, because people reuse passwords, they now own those users' email, banking, and social accounts too. Your one breach becomes hundreds of breaches.

The fix is hashing: a one-way transformation. You never store the password — you store a fingerprint of it. When a user logs in, you hash what they typed and compare fingerprints. Done right, even the people running the server can't recover the original password.

⚠️ Hashing is not encryption

Encryption is reversible — anyone with the key can get the plaintext back. That's wrong for passwords, because the key becomes a single point of catastrophic failure. Hashing is deliberately one-way: there is no "decrypt." You verify by re-hashing and comparing, never by reversing.

📉 Real breaches, real lessons

  • LinkedIn (2012): millions of unsalted SHA-1 hashes leaked and were cracked quickly.
  • Adobe (2013): ~153M records with reversibly-encrypted passwords and plaintext hints.
  • Sony PSN (2011): ~77M accounts, passwords reportedly not properly hashed.

The common thread: weak or missing hashing turned a data leak into a credential catastrophe.

The Evolution of Password Storage

Password storage improved in stages, each fixing the previous weakness. Understanding the ladder explains exactly why bcrypt sits at the top.

sequenceDiagram participant P as Plaintext participant H as Fast Hash (MD5/SHA) participant S as Salted Hash participant A as Adaptive Hash (bcrypt) Note over P,A: Each step fixes the previous weakness P->>H: Make it one-way Note over H: Still fast + rainbow-table vulnerable H->>S: Add a unique salt per user Note over S: Unique hashes, but still too fast S->>A: Add an adjustable cost factor Note over A: Deliberately slow = brute force impractical

1. Plaintext — the cardinal sin

// NEVER do this. The breach IS the password list.
const user = { email: 'a@b.com', password: 'hunter2' };

2. Fast hashing — a half-measure

const crypto = require('crypto');
// MD5 / SHA-256 are FAST — great for file checksums, terrible for passwords.
const hash = crypto.createHash('sha256').update('hunter2').digest('hex');
// Problems:
//  - Attackers compute BILLIONS of these per second on a GPU.
//  - Precomputed "rainbow tables" reverse common passwords instantly.
//  - Identical passwords produce identical hashes -> patterns leak.

3. Salted hashing — closer

A salt is a unique random value mixed into each password before hashing. Now two users with the password hunter2 get different hashes, and prebuilt rainbow tables are useless. But plain salted SHA is still too fast to compute — a motivated attacker just brute-forces each salted hash directly.

4. Adaptive hashing — the answer

Adaptive (deliberately slow) hash functions add a tunable cost factor. They bake in salting and make each hash take a measurable amount of time — fast enough for one login, painfully slow for billions of guesses. The main choices are bcrypt, argon2, and PBKDF2. This course uses bcrypt; argon2id is an excellent modern alternative.

Salt & the Storage Model

Here's the mental model for what actually gets saved. bcrypt generates a random salt, combines it with the password, runs the slow hash, and stores the salt inside the resulting string — so you never manage the salt separately.

Password plus a random salt runs through bcrypt to produce a stored hash containing the salt password "hunter2" random salt 🎲 unique bcrypt cost = 12 stored hash salt + digest Only the stored hash is saved — never the plaintext.
The salt lives inside the output, so verifying later needs only the stored string and the submitted password.

Anatomy of a bcrypt hash

A stored bcrypt hash is a single self-describing string. Every part you need to verify a password later is embedded in it:

// $2b$12$eImiTXuWVxfM37uY4JANjQ.9Tq0aFq7uS8mQ0oX0k8n1a2b3c4d5e
//  |  |  |                     |
//  |  |  |                     +-- 31-char digest (the actual hash)
//  |  |  +------------------------ 22-char salt (base64)
//  |  +--------------------------- cost factor: 12  (2^12 rounds)
//  +------------------------------ algorithm id: 2b (bcrypt)

Because the algorithm, cost, and salt all travel with the digest, bcrypt.compare can re-derive the hash from a plaintext guess with no extra bookkeeping on your side.

Understanding bcrypt

bcrypt is a password-hashing function based on the Blowfish cipher, designed specifically for storing passwords. Its defining feature is the cost factor (also called work factor or rounds).

The cost factor

  • The cost is an exponent: cost n means 2^n internal rounds.
  • Each +1 in cost roughly doubles the time to hash.
  • Slow is the point: a legitimate login can afford ~250 ms; an attacker guessing billions cannot.
  • As hardware gets faster, you raise the cost to keep pace.
CostRounds (2^n)Approx. time*Verdict
8256~15 msToo fast today
101,024~65 msAcceptable minimum
124,096~250 msRecommended default
1416,384~1 sHigh security, slower UX

*Times vary wildly by hardware — always benchmark on your production server and aim for roughly 250 ms.

🏦 Analogy: a vault with a time lock

Plaintext is cash in an unlocked drawer. A fast hash is a cheap padlock a pro picks in seconds. bcrypt is a bank vault with a time lock: even with the right tools, each attempt costs real time — so mass cracking becomes impractical. The cost factor is how thick you make the vault walls, and you thicken them as thieves' tools improve.

⚠️ The 72-byte limit

bcrypt only considers the first 72 bytes of input. For normal passwords this never matters. If you must support very long passphrases, pre-hash with SHA-256 first, then bcrypt the result — but for a typical app, don't over-engineer it.

Using bcrypt in Node.js

Install the maintained native package. (bcryptjs is a pure-JS fallback with the same API if you can't build native modules.)

// npm install bcrypt

Hashing a password

Always use the async API in a web server — the sync version blocks the event loop for the whole ~250 ms, freezing every other request.

const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12; // cost factor

async function hashPassword(plainPassword) {
  // bcrypt.hash generates a salt internally and embeds it in the output.
  const hash = await bcrypt.hash(plainPassword, SALT_ROUNDS);
  return hash; // e.g. "$2b$12$eImiTX...c4d5e"  -> store THIS
}

// Example
(async () => {
  const stored = await hashPassword('Str0ng!Pass');
  console.log(stored);
  // $2b$12$eImiTXuWVxfM37uY4JANjQ.9Tq0aFq7uS8mQ0oX0k8n1a2b3c4d5e
})();

Verifying a password

bcrypt.compare extracts the salt and cost from the stored hash, re-hashes the guess, and compares in constant time (resisting timing attacks). You never handle the salt yourself.

async function verifyPassword(plainPassword, storedHash) {
  // Returns true only if plainPassword hashes to storedHash.
  return bcrypt.compare(plainPassword, storedHash);
}

(async () => {
  const stored = await bcrypt.hash('Str0ng!Pass', 12);
  console.log(await verifyPassword('Str0ng!Pass', stored)); // true
  console.log(await verifyPassword('wrong-guess', stored));  // false
})();

Output

true
false

✅ Why compare, never re-hash-and-equal manually

Don't hash the guess yourself and use === — you'd need the original salt and you'd lose the constant-time comparison. bcrypt.compare does both correctly. Trust it.

Integrating with a User Model

In a real app you hash once, at the point a password is set, and verify on login. Here's a clean pattern with Mongoose that hashes in a pre('save') hook and never returns the hash to callers.

const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const SALT_ROUNDS = 12;

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,   // normalize before storing
    trim: true,
  },
  password: {
    type: String,
    required: true,
    select: false,     // never returned by default queries
  },
});

// Hash the password whenever it is set or changed.
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next(); // skip if unchanged
  this.password = await bcrypt.hash(this.password, SALT_ROUNDS);
  next();
});

// Instance method to verify a login attempt.
userSchema.methods.verifyPassword = function (candidate) {
  return bcrypt.compare(candidate, this.password);
};

module.exports = mongoose.model('User', userSchema);

Using it on login — note the generic error and the explicit re-selection of the hidden password field:

async function login(req, res) {
  const email = String(req.body.email || '').toLowerCase().trim();
  const { password } = req.body;

  // Must re-select password because the schema hides it.
  const user = await User.findOne({ email }).select('+password');

  // Same generic message whether the email or the password was wrong,
  // so attackers cannot tell which emails are registered (no enumeration).
  const ok = user && (await user.verifyPassword(password));
  if (!ok) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  // Success: issue a session or token here. NEVER return user.password.
  res.json({ message: 'Logged in' });
}

⚠️ Never log or return the hash

Keep select: false on the password, strip it from any serialized user object, and make sure it never lands in logs, error responses, or API payloads. A leaked hash is still an offline cracking target.

Practice & Quiz

🏋️ Exercise 1: A tiny auth module

Goal: Complete register and checkLogin using bcrypt's async API and a cost factor of 12.

const bcrypt = require('bcrypt');

async function register(password) {
    // TODO: return the bcrypt hash of password (cost 12)
}

async function checkLogin(password, storedHash) {
    // TODO: return true if password matches storedHash
}
💡 Hint

bcrypt.hash(password, 12) returns a promise for the hash; bcrypt.compare(password, storedHash) returns a promise for a boolean. Remember to await or return the promises.

✅ Solution
const bcrypt = require('bcrypt');

async function register(password) {
    return bcrypt.hash(password, 12);
}

async function checkLogin(password, storedHash) {
    return bcrypt.compare(password, storedHash);
}

🏋️ Exercise 2: Reason about cost

Goal: If cost 12 takes about 250 ms on your server, roughly how long will cost 14 take, and why might that be too slow for a busy login endpoint?

✅ Solution

Each +1 doubles the time, so cost 14 is about 4× cost 12 — roughly 1 second per hash. On a high-traffic login route that ties up a worker for a full second per attempt, hurting throughput and user experience. The goal is the highest cost that keeps logins comfortably fast (aim ~250 ms) — raise it over time as hardware improves.

🎯 Quick Quiz

Question 1: Why is a fast hash like SHA-256 a poor choice for passwords?

Question 2: What does a salt primarily prevent?

Question 3: How do you verify a login with bcrypt?

Best Practices & Pitfalls

✅ Do

  • Use bcrypt (or argon2id) with a cost factor around 12
  • Use the async API (bcrypt.hash / bcrypt.compare) so you don't block the event loop
  • Let bcrypt generate the salt for you — it's embedded in the output
  • Hash once when the password is set (e.g. a pre('save') hook)
  • Store passwords with select: false and strip them from responses and logs
  • Enforce a minimum password strength before hashing, and normalize email

❌ Don't

  • Store plaintext, or "encrypt" passwords reversibly
  • Use fast hashes (MD5/SHA-1/SHA-256) alone for passwords
  • Reuse one global salt for every user — salts must be unique
  • Roll your own hash-and-compare with === instead of bcrypt.compare
  • Return a different error for "no such user" vs "wrong password" (enables enumeration)
  • Log the hash, or send it in an API response

✅ argon2 in one line

OWASP lists argon2id as its first choice and bcrypt as a strong, battle-tested alternative. Both are correct choices for this course; bcrypt is used here for its simplicity and ubiquity.

Summary

🎉 Key Takeaways

  • Passwords are hashed, never stored plaintext or encrypted — hashing is one-way
  • A salt makes each hash unique, defeating rainbow tables and hiding reused passwords
  • An adaptive cost factor keeps hashing slow enough to stop brute force; ~12 is a good default
  • Use bcrypt.hash and bcrypt.compare (async) — the salt rides inside the hash
  • Keep the hash out of queries, logs, and responses; return generic auth errors

📚 Additional Resources

🚀 What's Next?

You can now store and verify credentials safely. Next you'll wire this into a complete User Registration Flow — validating and normalizing input, checking for duplicate accounts, hashing on the way in, and returning safe responses without ever exposing sensitive data.

🧂 Hashing mastered!

Your users' passwords are now safe even if the database walks out the door. That's the whole job of this lesson.