Learn why passwords must never be stored in plain text and how to securely hash them with bcrypt.
If you store user passwords in plain text — exactly as the user typed them — you are one database breach away from catastrophe. And database breaches are not rare events that happen to careless companies. They happen to everyone.
In 2012, LinkedIn was breached. 6.5 million password hashes were leaked — but they used unsalted SHA-1, so most passwords were cracked within hours. In 2016, it was revealed that the actual breach affected 117 million accounts. In 2013, Adobe was breached — 153 million user records with passwords encrypted (not hashed) using 3DES in ECB mode, which preserved patterns. Security researchers cracked millions of passwords by analyzing repeated ciphertext blocks. In 2016, Yahoo disclosed that 3 billion accounts had been compromised in a 2013 breach.
Why are plain text passwords so catastrophic? Because users reuse passwords across sites. Studies consistently show that 50-65% of people use the same password for multiple accounts. If your little side project is breached and passwords are in plain text, attackers will immediately try those email/password combinations on Gmail, bank accounts, and social media. Your breach becomes a cascade of compromised accounts across the internet.
The solution is password hashing. A hash function takes a password as input and produces a fixed-length, seemingly random string as output. Crucially, hashing is a one-way operation — you can compute the hash from the password, but you cannot reverse the hash back into the password. When a user logs in, you hash the password they provide and compare it to the stored hash. If they match, the password is correct. You never need to know or store the actual password.
Even if an attacker steals your entire database, they get hashes — not passwords. They would need to crack each hash individually, which (with the right hashing algorithm) takes an enormous amount of computational time.
These two terms are often confused, but they serve fundamentally different purposes:
Encryption is two-way. You encrypt data with a key and can decrypt it back to the original with the same key (symmetric) or a paired key (asymmetric). Encryption is for data you need to read later: credit card numbers stored for recurring payments, personal messages in a chat app, medical records in a hospital system. If the encryption key is compromised, all encrypted data is immediately readable.
Hashing is one-way. You hash data and get a fixed-length output. There is no key, no way to reverse it. The same input always produces the same hash, but you cannot work backwards from the hash to the input. Hashing is for data you only need to verify, not read: passwords, file integrity checksums, digital signatures.
Why not use encryption for passwords? Because if you can decrypt passwords, so can an attacker who gets your encryption key. There is no legitimate reason to ever read a user's password — you only need to verify that a candidate password matches. One-way hashing eliminates the risk entirely: even with full database access, there is no key to steal.
Not all hash functions are suitable for passwords:
The key insight: slowness is a feature, not a bug. For a legitimate login attempt, waiting 100ms for bcrypt is imperceptible. For an attacker trying millions of passwords, it makes brute-force attacks computationally infeasible.
bcrypt combines three powerful concepts to make password hashing secure: the Blowfish cipher, a random salt, and a configurable cost factor.
Salt — A salt is a random string generated for each password before hashing. Without a salt, identical passwords produce identical hashes. If 1,000 users all use the password "password123", they all have the same hash. An attacker can precompute a giant table of common password hashes (called a rainbow table) and look up hashes instantly. With a salt, each user's hash is unique even if their passwords are identical, because a different random salt is mixed into each one. Rainbow tables become useless — the attacker would need a separate table for every possible salt value, which is computationally impossible.
bcrypt generates a 16-byte (128-bit) random salt for every password automatically. You do not need to generate or store the salt separately — bcrypt embeds it directly in the hash string.
Cost Factor (Salt Rounds) — The cost factor determines how many times the hashing algorithm iterates. It is expressed as a power of 2: a cost factor of 10 means 2^10 = 1,024 iterations. A cost factor of 12 means 2^12 = 4,096 iterations. Higher cost = slower hashing = more secure. The typical recommendation is 10-12 for most applications. You can increase it over time as hardware gets faster.
As a rough benchmark: cost factor 10 takes about 100ms, cost factor 12 takes about 300ms, cost factor 14 takes about 1 second. For a single login, any of these is fine. For an attacker trying millions of passwords, the difference is enormous.
The bcrypt hash format:
$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
Breaking this down:
$2b$ — The bcrypt algorithm version10$ — The cost factor (10 rounds)N9qo8uLOickgx2ZMRZoMye — The 22-character salt (base64-encoded)IjZAgcfl7p92ldGxad68LJZdL17lhWy — The 31-character hash (base64-encoded)Because the salt is embedded in the hash string, you only need to store one value per user. When verifying a password, bcrypt extracts the salt from the stored hash, re-hashes the candidate password with the same salt, and compares the results.
const bcrypt = require('bcryptjs');
// ── Hashing a Password ──────────────────────────────
// Method 1: Two-step (generate salt, then hash)
const salt = await bcrypt.genSalt(10); // Generate a salt with cost factor 10
const hash = await bcrypt.hash('myPassword123', salt);
console.log(hash);
// $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// Method 2: Shorthand (pass cost factor directly)
const hash2 = await bcrypt.hash('myPassword123', 10);
// bcrypt generates the salt internally — same result
// ── Verifying a Password ────────────────────────────
// compare() extracts the salt from the hash, re-hashes the
// candidate password with that salt, and checks if they match
const isMatch = await bcrypt.compare('myPassword123', hash);
console.log(isMatch); // true
const isWrong = await bcrypt.compare('wrongPassword', hash);
console.log(isWrong); // false
// ── Complete Registration Flow ──────────────────────
async function registerUser(name, email, plainPassword) {
// 1. Hash the password (NEVER store plainPassword)
const hashedPassword = await bcrypt.hash(plainPassword, 12);
// 2. Save user to database with the HASH, not the plain password
const user = await User.create({
name,
email,
password: hashedPassword, // Store the hash
});
return user;
}
// ── Complete Login Flow ─────────────────────────────
async function loginUser(email, plainPassword) {
// 1. Find the user by email
const user = await User.findOne({ email });
if (!user) {
throw new Error('Invalid email or password'); // Same message for both
}
// 2. Compare the provided password with the stored hash
const isPasswordCorrect = await bcrypt.compare(plainPassword, user.password);
if (!isPasswordCorrect) {
throw new Error('Invalid email or password'); // Don't reveal which is wrong
}
// 3. Password is correct — generate JWT and return
const token = jwt.sign({ id: user._id }, config.jwtSecret);
return { user, token };
}In a real application, you do not want to manually hash the password every time you create or update a user. Mongoose provides pre-save hooks (also called middleware) that run automatically before a document is saved to the database. This is the perfect place to hash passwords — the hashing happens transparently, and no other code in your application needs to know about it.
const bcrypt = require('bcryptjs');
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true, minlength: 8 },
});
// Hash password before saving
userSchema.pre('save', async function(next) {
// Only hash if the password field was modified
// (prevents re-hashing when updating name or email)
if (!this.isModified('password')) return next();
// Hash with cost factor 12
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Instance method to compare passwords
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
// Never include password in JSON output
userSchema.methods.toJSON = function() {
const obj = this.toObject();
delete obj.password;
return obj;
};
const User = mongoose.model('User', userSchema);Now creating a user is clean and secure:
const user = await User.create({ name: 'Alice', email: 'alice@example.com', password: 'secret123' });
// Password is automatically hashed before saving — you never see the plain textAnd verifying a password is equally clean:
const isMatch = await user.comparePassword('secret123');
// true — bcrypt handles salt extraction and comparison internallyThe toJSON method ensures that even if you accidentally send a user object in an API response, the password hash is stripped out. Defense in depth — even your own mistakes cannot leak sensitive data.
Notice the isModified('password') check in the pre-save hook. Without it, every time you update any field on the user document (name, email, profile picture), the already-hashed password would be hashed again, corrupting it. The user would be locked out of their account. This is a subtle but critical bug that catches many developers.
Why is bcrypt preferred over MD5 or SHA for password hashing?