
Salting & Pepper: How to Cook Passwords
Simple hashing gets cracked in 1 second. How Sprinkling Salt and Pepper blocks Rainbow Table attacks.

Simple hashing gets cracked in 1 second. How Sprinkling Salt and Pepper blocks Rainbow Table attacks.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

A deep dive into Robert C. Martin's Clean Architecture. Learn how to decouple your business logic from frameworks, databases, and UI using Entities, Use Cases, and the Dependency Rule. Includes Screaming Architecture and Testing strategies.

Two ways to escape a maze. Spread out wide (BFS) or dig deep (DFS)? Who finds the shortest path?

A comprehensive deep dive into client-side storage. From Cookies to IndexedDB and the Cache API. We explore security best practices for JWT storage (XSS vs CSRF), performance implications of synchronous APIs, and how to build offline-first applications using Service Workers.

I saw a news article about a company database getting hacked.
Article: "Fortunately, passwords were encrypted and remain secure."
Me: "Are they really safe though?"
Senior developer: "If they only used hashing, cracked in 1 second. If they used Salt, it'll last a bit longer."
That moment sent chills down my spine. How were passwords stored in my first project's database? I couldn't remember exactly, but I think I only used SHA-256 hashing. I'm pretty sure I didn't add Salt.
When building the signup feature in my first project:
Me: "Can't I just save passwords directly in the database?"
Senior: "Never! Storing plain text is a first-class security disaster."
Me: "Then how?"
Senior: "Hash + Salt + Pepper. It's a 3-step recipe."
That's when I started studying Salting. To be honest, as an early-stage startup founder, I was preoccupied with revenue concerns. Having to learn about security on top of everything felt overwhelming. But I eventually realized: this was it. No matter how good your service is, if you can't protect user passwords, everything can collapse in an instant.
Most importantly: "Why does this have to be so complicated?"
I initially understood it as "just encrypt the password." Then I learned it's not encryption (Encryption) but hashing (Hash), and even that alone isn't enough. My head was spinning.
My senior's analogy clicked everything into place:
"Ah, it's 3-layer security!"Storing raw meat (Plain text): "Save password as-is in the database. Anyone looking sees '1234' plain as day. Even DB admins see everything. Worst case scenario."
Ground meat (Hash): "Grind the password with a Hash function. '1234' → 'a3f9c12...' Cannot reverse engineer.
Problem: Hackers pre-calculate '1234' Hash values. Find 'a3f9c12...' in DB → Look up dictionary → 'Oh, it's 1234' → Cracked."
Seasoning with salt (Salt): "'1234' + random string 'zXy9' → Hash Now it's not in the hacker's dictionary. To crack one user, they must recalculate from scratch."
Sprinkling pepper (Pepper): "Salt + server secret key (Pepper). DB stolen but Pepper stored separately → Uncrackable."
This cooking analogy really landed for me. Just like you don't serve raw meat when cooking—you grind it, season with salt, add pepper—passwords need to go through multiple steps. If I were to summarize what I understood: "Design assuming your DB can be hacked."
-- ❌ Never do this!
CREATE TABLE users (
id INT,
username VARCHAR(50),
password VARCHAR(50) -- Plain text storage!
);
INSERT INTO users VALUES (1, 'ratia', '1234');
Problems:
2013 Adobe Hack:
- 150 million accounts leaked
- Passwords in plain text (partially)
- Class action lawsuit → $1.1M settlement
When I first learned about this incident, I was shocked: "A company as big as Adobe stored passwords in plain text?" Later I learned it was due to legacy systems from the early days. That hit home even harder: if you don't do it right from the start, fixing it later becomes incredibly difficult.
const crypto = require('crypto');
const password = '1234';
const hash = crypto.createHash('sha256')
.update(password)
.digest('hex');
console.log(hash);
// a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
Characteristics:
Hackers pre-calculate:
1234 → a665a45920...
password → 5e884898da...
qwerty → 65e84be33...
12345678 → ef797c81...
...
(millions of entries)
DB stolen → Hash discovered → Dictionary lookup → Cracked in 1 second
When I first learned about Rainbow Tables, I felt deflated: "Wow, such a simple attack actually works?" I thought hashing was secure, but it turns out people use predictable passwords, and if you pre-calculate those, game over.
const crypto = require('crypto');
// 1. Generate random Salt per user
const salt = crypto.randomBytes(16).toString('hex');
// salt: "3a7f2c9e1d8b4a5f..."
// 2. Combine password + Salt, then Hash
const password = '1234';
const hash = crypto.createHash('sha256')
.update(password + salt)
.digest('hex');
// 3. Store in DB
// users: [username, hash, salt]
User A: '1234' + Salt_A → Hash_A
User B: '1234' + Salt_B → Hash_B
Hash_A ≠ Hash_B // Same password, different Hash!
Result: Rainbow Tables become useless
The moment I understood this felt exhilarating. The key insight: even identical passwords produce different Hash values per user. From a hacker's perspective, they'd need to recalculate for every single user, making it practically impossible.
const bcrypt = require('bcrypt');
// Signup
async function signup(username, password) {
const saltRounds = 10; // Cost factor
const hash = await bcrypt.hash(password, saltRounds);
// Store in DB: hash includes salt!
await db.insert({ username, hash });
}
// Login
async function login(username, password) {
const user = await db.findByUsername(username);
const match = await bcrypt.compare(password, user.hash);
if (match) {
console.log('Login successful');
}
}
$2b$10$N9qo8uLOickgx2ZMRZoMye...
│ │ │ │
│ │ │ └─ Hash (31 chars)
│ │ └─ Salt (22 chars)
│ └─ Cost Factor (10 = 2^10 = 1024 rounds)
└─ Algorithm version (2b)
Features:
When I first used Bcrypt, what amazed me most was not having to manage Salt separately. The Salt is already embedded in the Hash value. So you only store the Hash in your DB, and during verification, bcrypt.compare() automatically extracts the Salt and compares.
DB Table:
username | hash | salt
ratia | a3f9c12... | zXy9...
Problem: If DB stolen, Salt stolen too!
const PEPPER = process.env.SECRET_PEPPER; // Environment variable
async function signup(username, password) {
const saltRounds = 10;
// Mix in Pepper first
const pepperedPassword = password + PEPPER;
const hash = await bcrypt.hash(pepperedPassword, saltRounds);
await db.insert({ username, hash });
// Pepper NOT stored in DB!
}
async function login(username, password) {
const user = await db.findByUsername(username);
const pepperedPassword = password + PEPPER;
const match = await bcrypt.compare(pepperedPassword, user.hash);
return match;
}
Storage Locations:
.env)Honestly, I initially thought: "Is Pepper really necessary?" But after witnessing several DB breach incidents, I came to appreciate Pepper's importance. Even if the DB is compromised, if Pepper remains secure, hackers are stuck.
const crypto = require('crypto');
console.time('SHA-256');
for (let i = 0; i < 100000; i++) {
crypto.createHash('sha256').update('1234').digest('hex');
}
console.timeEnd('SHA-256');
// SHA-256: 50ms
Problem: Hackers can try millions per second
const bcrypt = require('bcrypt');
console.time('Bcrypt');
for (let i = 0; i < 100; i++) { // Only 100 attempts
await bcrypt.hash('1234', 10);
}
console.timeEnd('Bcrypt');
// Bcrypt: 1000ms
Effect: Hackers can only try ~10 per second
This contrast was genuinely shocking. SHA-256 processes 100,000 hashes in 50ms, while Bcrypt takes 1000ms for just 100 hashes. That's a 1000x difference. Initially I wondered: "Why intentionally make it slower?" But then it clicked: Normal users experience 0.1 seconds added delay during one login, while hackers find millions of attempts impossible.
Cost 10: ~100ms (recommended)
Cost 12: ~400ms
Cost 14: ~1600ms
Selection Criteria:
const bcrypt = require('bcrypt');
// Development environment
const devCost = 10;
// Production (higher security)
const prodCost = 12;
const cost = process.env.NODE_ENV === 'production'
? prodCost
: devCost;
const hash = await bcrypt.hash(password, cost);
My service uses Cost 10. Login speed feels identical to users, and security is sufficient. If hardware improves later, I might increase the Cost Factor.
const argon2 = require('argon2');
// Signup
const hash = await argon2.hash(password);
// Login
const match = await argon2.verify(hash, password);
Advantages:
Argon2 addresses Bcrypt's weaknesses. While Bcrypt only uses CPU, Argon2 requires significant memory, preventing parallel attacks with GPUs or ASICs. However, my service still uses Bcrypt because it's already implemented and sufficiently secure.
// ❌ Wrong approach
const GLOBAL_SALT = "myapp_salt";
users.forEach(user => {
const hash = hash(user.password + GLOBAL_SALT);
});
Problem: Rainbow Tables become effective again
I made this mistake too. I thought: "Managing per-user Salts seems complicated, why not use a global Salt?" My senior shut that down: "Then Salt is meaningless." A global Salt means hackers only need to calculate once.
// ❌ No need to hide Salt
const salt = crypto.randomBytes(16);
// Salt being public is OK!
Reason: Only unpredictability matters for Salt
Initially I thought Salt should be hidden like passwords. Wrong. Salt can be public. What matters is "each user has different Salt." Even if hackers know the Salt, they still must calculate separately for every user, making attacks inefficient.
// ❌ Too fast
const hash = crypto.createHash('sha256')
.update(password)
.digest('hex');
Solution: Use Bcrypt/Argon2
This was my mistake in my first project. I only knew "SHA-256 is secure" but didn't realize it's unsuitable for password hashing because it's too fast.
Credential stuffing is when hackers take email/password pairs leaked from one service and try them on other services. Users often reuse passwords across multiple sites.
Hacker's leaked database:
user@email.com : password123
Then they try this pair on:
Even if a hacker has valid credentials from another breach, your properly salted database remains safe because:
// Detect credential stuffing attempts
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
async function login(username, password, ipAddress) {
// Check attempt count
const attempts = await redis.get(`login_attempts:${ipAddress}`);
if (attempts > MAX_LOGIN_ATTEMPTS) {
throw new Error('Too many attempts, try again later');
}
// Verify with Bcrypt (intentionally slow)
const user = await db.findByUsername(username);
const match = await bcrypt.compare(password + PEPPER, user.hash);
if (!match) {
await redis.incr(`login_attempts:${ipAddress}`);
await redis.expire(`login_attempts:${ipAddress}`, LOCKOUT_DURATION);
throw new Error('Invalid credentials');
}
// Success: clear attempts
await redis.del(`login_attempts:${ipAddress}`);
return user;
}
What if you inherit a system with weak password storage? You can't directly migrate hashes because you don't have the original passwords.
// Support both legacy (SHA-256) and modern (Bcrypt) systems
async function login(username, password) {
const user = await db.findByUsername(username);
// Check if using legacy hash
if (user.hash_type === 'sha256') {
const legacyHash = crypto.createHash('sha256')
.update(password)
.digest('hex');
if (legacyHash === user.hash) {
// Success! Upgrade to Bcrypt immediately
const newHash = await bcrypt.hash(password + PEPPER, 10);
await db.update(user.id, {
hash: newHash,
hash_type: 'bcrypt'
});
return user;
}
}
// Modern Bcrypt verification
if (user.hash_type === 'bcrypt') {
const match = await bcrypt.compare(password + PEPPER, user.hash);
if (match) return user;
}
throw new Error('Invalid credentials');
}
This strategy lets you migrate gradually without forcing password resets for all users.
PBKDF2 (Password-Based Key Derivation Function 2) is another key stretching algorithm, older than Bcrypt but still widely used.
const crypto = require('crypto');
// PBKDF2 example
function pbkdf2Hash(password, salt) {
return crypto.pbkdf2Sync(
password,
salt,
100000, // iterations
64, // key length
'sha512' // digest algorithm
).toString('hex');
}
// Usage
const salt = crypto.randomBytes(16).toString('hex');
const hash = pbkdf2Hash(password, salt);
Comparison:
Use PBKDF2 when:
Never use simple string comparison for password verification:
// ❌ VULNERABLE to timing attacks
function verifyPassword(input, stored) {
return input === stored; // BAD!
}
Why it's dangerous: String comparison short-circuits on first mismatch. Attackers can measure response times to guess passwords character by character.
const crypto = require('crypto');
// ✅ SAFE: constant-time comparison
function verifyPassword(input, stored) {
const inputBuffer = Buffer.from(input);
const storedBuffer = Buffer.from(stored);
if (inputBuffer.length !== storedBuffer.length) {
return false;
}
// crypto.timingSafeEqual always takes same time
return crypto.timingSafeEqual(inputBuffer, storedBuffer);
}
Good news: Bcrypt and Argon2 handle this internally. Their .compare() methods already use constant-time comparison.
| Item | Description |
|---|---|
| Plain text | Forbidden! Legal risk |
| Hash only | One-way, but Rainbow Table vulnerable |
| Salt | Random per user, OK to store in DB |
| Pepper | Global secret, separate storage |
| Algorithm | Bcrypt/Argon2 (slow Hash) |
| Cost Factor | 10~12 recommended |
| Migration | Gradual upgrade during login |
| Rate limiting | Defend against credential stuffing |
| Timing safety | Use constant-time comparison |
Initially I thought: "Just hash it, why complicate things?"
Now I understand:
"Assume your database will be breached"What I learned:
To hackers, that is.
Password security ultimately boils down to "making hackers give up." Salting is the first step in that defense. Perfect security doesn't exist, but you can at least make hackers think: "This service is too hard to crack."
I learned this the hard way when I realized my first project used only SHA-256. The moment my senior said "cracked in 1 second," I immediately rewrote the entire authentication system. That weekend scramble taught me more about password security than any textbook could.
The cooking analogy still guides my thinking: don't serve raw data, grind it with hashing, season with Salt, spice with Pepper. Only then can you serve passwords that are safe for storage—and utterly indigestible for attackers.