Why Password Security Matters
Passwords remain the most common authentication mechanism. When a database breach occurs, the attacker gets the stored password representations. If passwords are stored in plain text, every account is immediately compromised. If they are hashed with a weak algorithm, the attacker can crack them within hours. Proper password security ensures that even after a breach, the passwords remain computationally infeasible to recover.
Breach Scenario:
Plain text storage: attacker gets passwords instantly
MD5/SHA-256 hashing: attacker cracks most in hours (GPU brute force)
bcrypt (cost 12): attacker cracks ~25,000 guesses/sec per GPU
argon2id (tuned): attacker cracks ~1,000 guesses/sec per GPU
Time to crack "P@ssw0rd123" (common password):
MD5: < 1 second
SHA-256: < 1 second
bcrypt: ~3 seconds
argon2id: ~30 seconds
Time to crack "kX9#mP2$vL7!nQ4" (random 15-char):
MD5: centuries
SHA-256: centuries
bcrypt: centuries
argon2id: centuries (even more so)
The goal is not to make cracking impossible — it is to make it so slow that cracking even one password takes an impractical amount of time and resources.
Password Hashing: The Core Defense
Hashing is a one-way function. You can convert a password to a hash, but you cannot convert the hash back to a password. When a user logs in, you hash their input and compare it to the stored hash.
Registration:
password "hello123" --> hash function --> "$2b$12$LJ3m4y..." --> store in DB
Login:
input "hello123" --> hash function --> "$2b$12$LJ3m4y..."
Compare with stored hash --> match --> authenticated
Attacker:
stolen hash "$2b$12$LJ3m4y..." --> cannot reverse to "hello123"
Must try every possible password through the hash function (brute force)
Why General-Purpose Hashes Fail
SHA-256 and MD5 were designed for integrity checking — they need to be fast. A modern GPU can compute billions of SHA-256 hashes per second. Password hashing algorithms are deliberately slow to make brute-force attacks impractical.
+------------------+------------------------+----------------------------------+
| Algorithm | Hashes/sec (GPU) | Time to Try 10 Billion Passwords |
+------------------+------------------------+----------------------------------+
| MD5 | ~10,000,000,000 | ~1 second |
| SHA-256 | ~3,000,000,000 | ~3 seconds |
| bcrypt (cost 12) | ~25,000 | ~4.6 days |
| scrypt (default) | ~10,000 | ~11.6 days |
| argon2id (tuned) | ~1,000 | ~116 days |
+------------------+------------------------+----------------------------------+
bcrypt
bcrypt has been the industry standard since 1999. It includes a built-in salt, a configurable cost factor, and has been battle-tested for decades.
How bcrypt Works
Input: password + cost factor (e.g., 12)
1. Generate random 16-byte salt
2. Derive key using Blowfish cipher, iterated 2^cost times
3. Output: $2b$12$<22-char salt><31-char hash>
Example output:
$2b$12$LJ3m4ys3Lg5Rfz7mJQhOae6EYjqMHx.dpUJqMMkYjnL1MoZxVkS8u
| | | |
| | salt (22 chars) hash (31 chars)
| cost factor (12 = 2^12 = 4096 iterations)
algorithm identifier
bcrypt Implementation
const bcrypt = require('bcrypt');
// Configuration
const BCRYPT_COST = 12; // 2^12 = 4,096 iterations
// Cost 10 = ~100ms, Cost 12 = ~300ms, Cost 14 = ~1s
// Choose based on your server capacity
// Hash a password
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, BCRYPT_COST);
}
// Verify a password
async function verifyPassword(plaintext, storedHash) {
return bcrypt.compare(plaintext, storedHash);
}
// Complete registration flow
async function registerUser(email, password) {
// 1. Validate password strength
const strengthErrors = validatePasswordStrength(password);
if (strengthErrors.length > 0) {
throw new Error(strengthErrors.join(', '));
}
// 2. Check for existing user
const existing = await db.users.findByEmail(email);
if (existing) {
// Don't reveal whether email exists
// Still hash to prevent timing attacks
await bcrypt.hash(password, BCRYPT_COST);
throw new Error('Registration failed');
}
// 3. Hash and store
const passwordHash = await hashPassword(password);
const user = await db.users.create({
email,
passwordHash,
createdAt: new Date(),
});
return user;
}
// Complete login flow
async function loginUser(email, password) {
const user = await db.users.findByEmail(email);
if (!user) {
// Hash anyway to prevent timing attacks
// (attacker cannot distinguish "user not found" from "wrong password"
// by measuring response time)
await bcrypt.hash(password, BCRYPT_COST);
throw new Error('Invalid credentials');
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
throw new Error('Invalid credentials');
}
return user;
}
bcrypt limitation: Maximum input length is 72 bytes. Passwords longer than 72 bytes are truncated. This rarely matters in practice (72 bytes is very long for a password), but be aware.
Argon2
Argon2 won the Password Hashing Competition (PHC) in 2015. It offers three variants:
+------------------+-----------------------------------------------------+
| Variant | Designed For |
+------------------+-----------------------------------------------------+
| Argon2d | Resistance to GPU attacks (data-dependent memory) |
| Argon2i | Resistance to side-channel attacks (data-independent)|
| Argon2id | Hybrid — recommended for password hashing |
+------------------+-----------------------------------------------------+
Argon2id is recommended for password hashing because it combines GPU resistance and side-channel resistance.
Argon2 Parameters
+------------------+------------------+-----------------------------------+
| Parameter | What It Controls | Recommended Value |
+------------------+------------------+-----------------------------------+
| memoryCost | Memory usage | 65536 (64 MB) minimum |
| timeCost | Iterations | 3 minimum |
| parallelism | CPU threads | 1-4 (depends on server) |
| hashLength | Output size | 32 bytes |
+------------------+------------------+-----------------------------------+
Argon2 Implementation
const argon2 = require('argon2');
// Configuration — tune these based on your server
const ARGON2_CONFIG = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
hashLength: 32, // 32-byte output
};
async function hashPassword(plaintext) {
return argon2.hash(plaintext, ARGON2_CONFIG);
}
async function verifyPassword(plaintext, storedHash) {
try {
return await argon2.verify(storedHash, plaintext);
} catch {
return false;
}
}
// Check if hash needs rehashing (e.g., after increasing cost)
async function needsRehash(storedHash) {
return argon2.needsRehash(storedHash, ARGON2_CONFIG);
}
// Login with automatic rehashing
async function loginWithRehash(email, password) {
const user = await db.users.findByEmail(email);
if (!user) {
await hashPassword(password); // Timing attack prevention
throw new Error('Invalid credentials');
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) throw new Error('Invalid credentials');
// Rehash if config has changed (e.g., increased cost)
if (await needsRehash(user.passwordHash)) {
const newHash = await hashPassword(password);
await db.users.update(user.id, { passwordHash: newHash });
}
return user;
}
scrypt
scrypt is a memory-hard password hashing function. It was designed to be resistant to hardware brute-force attacks by requiring large amounts of memory.
const crypto = require('crypto');
const { promisify } = require('util');
const scryptAsync = promisify(crypto.scrypt);
const SCRYPT_CONFIG = {
N: 16384, // CPU/memory cost (power of 2)
r: 8, // Block size
p: 1, // Parallelization
keyLength: 64, // Output length in bytes
};
async function hashPassword(plaintext) {
const salt = crypto.randomBytes(32);
const derivedKey = await scryptAsync(plaintext, salt, SCRYPT_CONFIG.keyLength, {
N: SCRYPT_CONFIG.N,
r: SCRYPT_CONFIG.r,
p: SCRYPT_CONFIG.p,
});
// Store salt and hash together
return `${salt.toString('hex')}:${derivedKey.toString('hex')}`;
}
async function verifyPassword(plaintext, storedHash) {
const [saltHex, hashHex] = storedHash.split(':');
const salt = Buffer.from(saltHex, 'hex');
const storedKey = Buffer.from(hashHex, 'hex');
const derivedKey = await scryptAsync(plaintext, salt, SCRYPT_CONFIG.keyLength, {
N: SCRYPT_CONFIG.N,
r: SCRYPT_CONFIG.r,
p: SCRYPT_CONFIG.p,
});
// Constant-time comparison
return crypto.timingSafeEqual(derivedKey, storedKey);
}
Algorithm Comparison
+------------------+----------+----------+----------+----------+
| Feature | bcrypt | scrypt | argon2id | PBKDF2 |
+------------------+----------+----------+----------+----------+
| Memory-hard | No | Yes | Yes | No |
| GPU resistant | Moderate | High | Highest | Low |
| Side-channel | Moderate | Low | High | Moderate |
| resistant | | | | |
| Max input length | 72 bytes | Unlimited| Unlimited| Unlimited|
| Built-in salt | Yes | No | Yes | No |
| Maturity | 1999 | 2009 | 2015 | 2000 |
| Recommendation | Good | Good | Best | Avoid |
+------------------+----------+----------+----------+----------+
Recommendation priority:
- Argon2id — Best overall, use if available
- bcrypt — Battle-tested, excellent fallback
- scrypt — Good if Argon2 unavailable
- PBKDF2 — Avoid for new projects (not memory-hard)
Salting: Preventing Rainbow Tables
A salt is a random value added to each password before hashing. Without salts, identical passwords produce identical hashes — attackers can use precomputed lookup tables (rainbow tables) to find passwords instantly.
Without salt:
hash("password123") = "abc123..." (same for ALL users with this password)
Attacker precomputes hashes for common passwords --> instant lookup
With salt:
user1: salt="randomA" --> hash("randomA" + "password123") = "xyz789..."
user2: salt="randomB" --> hash("randomB" + "password123") = "def456..."
Same password, different hashes. Rainbow table useless.
// Manual salting (for educational purposes — bcrypt/argon2 handle this automatically)
const crypto = require('crypto');
function hashWithSalt(password) {
const salt = crypto.randomBytes(32).toString('hex'); // 32 bytes = 256 bits
const hash = crypto
.createHash('sha256')
.update(salt + password)
.digest('hex');
return { salt, hash };
}
function verifyWithSalt(password, storedSalt, storedHash) {
const hash = crypto
.createHash('sha256')
.update(storedSalt + password)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(hash, 'hex'),
Buffer.from(storedHash, 'hex')
);
}
// NOTE: This is for illustration only.
// In production, use bcrypt or argon2 which handle salting internally.
Salt requirements:
- Unique per password — Never reuse salts
- Random — Use a cryptographic random number generator
- Sufficient length — At least 16 bytes (128 bits), 32 bytes preferred
- Stored with hash — The salt is not secret; it is stored alongside the hash
Pepper: Server-Side Secret
A pepper is a secret value added to the password before hashing, but unlike a salt, the pepper is NOT stored in the database. It is stored in environment variables or a key management service.
Salt: stored in database alongside hash (unique per password)
Pepper: stored in environment variable (same for all passwords)
hash(pepper + salt + password) = stored_hash
If database is breached:
Attacker has: stored_hash, salt
Attacker missing: pepper
Brute force is insufficient — they don't know the pepper
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const PEPPER = process.env.PASSWORD_PEPPER; // 32+ byte hex string
async function hashWithPepper(password) {
// HMAC the password with the pepper first
const peppered = crypto
.createHmac('sha256', PEPPER)
.update(password)
.digest('hex');
// Then bcrypt the peppered value
return bcrypt.hash(peppered, 12);
}
async function verifyWithPepper(password, storedHash) {
const peppered = crypto
.createHmac('sha256', PEPPER)
.update(password)
.digest('hex');
return bcrypt.compare(peppered, storedHash);
}
Pepper rotation: If your pepper is compromised, you need to re-hash all passwords. Plan for this by storing a pepper version alongside each hash, so you can gradually migrate users during login.
async function loginWithPepperRotation(email, password) {
const user = await db.users.findByEmail(email);
if (!user) throw new Error('Invalid credentials');
const currentPepper = process.env.PASSWORD_PEPPER_V2;
const oldPepper = process.env.PASSWORD_PEPPER_V1;
// Try current pepper first
let valid = await verifyWithPepper(password, user.passwordHash, currentPepper);
// Fall back to old pepper
if (!valid && user.pepperVersion === 1) {
valid = await verifyWithPepper(password, user.passwordHash, oldPepper);
if (valid) {
// Rehash with new pepper
const newHash = await hashWithPepper(password, currentPepper);
await db.users.update(user.id, {
passwordHash: newHash,
pepperVersion: 2,
});
}
}
if (!valid) throw new Error('Invalid credentials');
return user;
}
Password Reset Flows
Password reset is a critical flow. A poorly implemented reset can be a bigger vulnerability than the passwords themselves.
Secure Reset Flow
User Server Email Service
---- ------ -------------
| | |
|-- POST /forgot-password ->| |
| { email: "[email protected]" } | |
| |-- Generate token |
| |-- Store hash of token |
| | with expiry (1 hour) |
| | |
| |-- Send reset email ------->|
| | with token in URL |
| | |
|<-- "Check your email" ----| |
| (same response whether |
| email exists or not) |
| |
|<-- Email with reset link ------------------------------|
| https://app.com/reset?token=abc123 |
| |
|-- POST /reset-password -->| |
| { token, newPassword } | |
| |-- Verify token (hash match)|
| |-- Check not expired |
| |-- Hash new password |
| |-- Update DB |
| |-- Invalidate token |
| |-- Invalidate all sessions |
| | |
|<-- "Password updated" ----| |
Implementation
const crypto = require('crypto');
const bcrypt = require('bcrypt');
// Step 1: Request password reset
app.post('/api/forgot-password', async (req, res) => {
const { email } = req.body;
// Always return success to prevent user enumeration
res.json({ message: 'If the email exists, a reset link was sent.' });
const user = await db.users.findByEmail(email);
if (!user) return; // Silently return
// Generate cryptographic token
const resetToken = crypto.randomBytes(32).toString('hex');
// Store HASH of token (not the token itself)
const tokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
await db.resetTokens.create({
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
used: false,
});
// Invalidate any previous reset tokens for this user
await db.resetTokens.invalidateOld(user.id, tokenHash);
// Send email with the plain token (NOT the hash)
await sendEmail({
to: email,
subject: 'Password Reset',
text: `Reset your password: https://app.com/reset?token=${resetToken}`,
});
});
// Step 2: Reset password with token
app.post('/api/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
// Validate new password strength
const errors = validatePasswordStrength(newPassword);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join(', ') });
}
// Hash the submitted token to find the stored record
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const resetRecord = await db.resetTokens.findByHash(tokenHash);
if (!resetRecord || resetRecord.used || resetRecord.expiresAt < new Date()) {
return res.status(400).json({ error: 'Invalid or expired reset token' });
}
// Hash new password and update
const passwordHash = await bcrypt.hash(newPassword, 12);
await db.users.update(resetRecord.userId, { passwordHash });
// Mark token as used
await db.resetTokens.markUsed(tokenHash);
// Invalidate all existing sessions for this user
await db.sessions.deleteByUserId(resetRecord.userId);
res.json({ message: 'Password updated successfully' });
});
Key security requirements for password resets:
- Token is random — Use
crypto.randomBytes(32), not UUIDs or timestamps - Store hash of token — If the database is breached, attacker cannot use stored tokens
- Expire quickly — 1 hour maximum
- Single use — Token is invalidated after use
- Invalidate sessions — After password change, all existing sessions are destroyed
- Same response — Do not reveal whether the email exists
Frontend Password Validation
Client-side validation provides instant user feedback but is not a security measure. The server must re-validate all constraints because the client can be bypassed.
// Password strength validator
function validatePasswordStrength(password) {
const errors = [];
if (password.length < 12) {
errors.push('Must be at least 12 characters');
}
if (password.length > 128) {
errors.push('Must be 128 characters or fewer');
}
if (!/[a-z]/.test(password)) {
errors.push('Must include a lowercase letter');
}
if (!/[A-Z]/.test(password)) {
errors.push('Must include an uppercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Must include a number');
}
if (!/[^a-zA-Z0-9]/.test(password)) {
errors.push('Must include a special character');
}
// Check against common passwords
const commonPasswords = [
'password123', 'qwerty12345', 'letmein1234',
'admin12345', 'welcome1234', 'monkey12345',
];
if (commonPasswords.some(common => password.toLowerCase().includes(common))) {
errors.push('Password is too common');
}
return errors;
}
Password Strength Meter (React)
import { useState, useMemo } from 'react';
type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong';
function calculateStrength(password: string): { level: StrengthLevel; score: number } {
let score = 0;
if (password.length >= 8) score += 1;
if (password.length >= 12) score += 1;
if (password.length >= 16) score += 1;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1;
if (/[0-9]/.test(password)) score += 1;
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
if (password.length >= 20) score += 1;
// Unique character ratio
const uniqueRatio = new Set(password).size / password.length;
if (uniqueRatio > 0.7) score += 1;
const level: StrengthLevel =
score <= 2 ? 'weak' :
score <= 4 ? 'fair' :
score <= 6 ? 'good' :
'strong';
return { level, score: Math.min(score, 8) };
}
const STRENGTH_COLORS: Record<StrengthLevel, string> = {
weak: '#ef4444',
fair: '#f59e0b',
good: '#3b82f6',
strong: '#22c55e',
};
export function PasswordInput() {
const [password, setPassword] = useState('');
const strength = useMemo(() => calculateStrength(password), [password]);
return (
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
{password.length > 0 && (
<div>
<div
style={{
height: '4px',
width: `${(strength.score / 8) * 100}%`,
backgroundColor: STRENGTH_COLORS[strength.level],
transition: 'width 0.3s, background-color 0.3s',
borderRadius: '2px',
}}
/>
<span style={{ color: STRENGTH_COLORS[strength.level] }}>
{strength.level.charAt(0).toUpperCase() + strength.level.slice(1)}
</span>
</div>
)}
</div>
);
}
Password Security Checklist
+------+----------------------------------------------+----------+
| # | Check | Status |
+------+----------------------------------------------+----------+
| 1 | Passwords hashed with argon2id or bcrypt | [ ] |
| 2 | Unique salt per password (auto with bcrypt) | [ ] |
| 3 | Pepper applied (stored outside database) | [ ] |
| 4 | Minimum length 12 characters | [ ] |
| 5 | Maximum length 128+ characters | [ ] |
| 6 | No password truncation (or document if bcrypt) | [ ] |
| 7 | Common password check | [ ] |
| 8 | Reset tokens are random, hashed, expiring | [ ] |
| 9 | Same error for bad email and bad password | [ ] |
| 10 | Timing attack prevention on login | [ ] |
| 11 | Sessions invalidated after password change | [ ] |
| 12 | Password strength meter on frontend | [ ] |
| 13 | Server validates password strength too | [ ] |
| 14 | Rate limiting on login and reset endpoints | [ ] |
| 15 | Rehashing strategy for algorithm upgrades | [ ] |
+------+----------------------------------------------+----------+
Summary
Password security is a multi-layered discipline. The hashing algorithm is the core, but the complete system includes salting, peppering, strength validation, secure reset flows, and operational practices.
Key takeaways:
- Use Argon2id as first choice, bcrypt as fallback. Never use MD5, SHA-256, or PBKDF2 for passwords.
- Salts prevent rainbow table attacks. bcrypt and Argon2 handle salting automatically.
- Pepper adds a layer that survives database breaches. Store it in environment variables.
- Password reset tokens must be cryptographically random, hashed before storage, single-use, and short-lived.
- Prevent user enumeration — identical responses for "user not found" and "wrong password."
- Frontend validation provides UX feedback. Server validation provides security.
- Rehash on login when upgrading algorithms or increasing cost factors.
- Constant-time comparison prevents timing attacks when verifying hashes manually.