TL;DR
The #1 password security best practice is using bcrypt or argon2id for password hashing instead of SHA-256 or MD5. Require minimum 8 characters without complexity rules. Check against breached password databases. Implement rate limiting and account lockout. Offer passwordless options and 2FA.
"The strength of your password storage determines whether a data breach becomes a minor incident or a catastrophic compromise of every user account."
Best Practice 1: Use the Right Hashing Algorithm 3 min
Only use password-specific hashing algorithms:
| Algorithm | Status | Use Case |
|---|---|---|
| argon2id | Best choice | New applications |
| bcrypt | Excellent | Widely supported |
| scrypt | Good | Alternative to bcrypt |
| PBKDF2 | Acceptable | Compliance requirements |
| SHA-256 | NEVER | Not for passwords |
| MD5 | NEVER | Not for passwords |
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // Adjust based on server capacity
async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
// Usage
const hash = await hashPassword('userPassword123');
// Stored: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.G...
import argon2 from 'argon2';
async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id, // Recommended variant
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
}
async function verifyPassword(password, hash) {
return argon2.verify(hash, password);
}
Best Practice 2: Modern Password Policies 2 min
NIST guidelines recommend simpler, more effective policies:
- Minimum 8 characters (12+ recommended)
- Maximum 64+ characters (do not limit)
- Allow all characters including spaces
- No complexity requirements (uppercase, symbols)
- No periodic password rotation
- Check against breached password lists
- Show password strength meter
import { z } from 'zod';
const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password too long')
.refine(
(password) => !isBreachedPassword(password),
'This password has appeared in a data breach'
);
// Check against Have I Been Pwned
async function isBreachedPassword(password) {
const hash = crypto
.createHash('sha1')
.update(password)
.digest('hex')
.toUpperCase();
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const response = await fetch(
`https://api.pwnedpasswords.com/range/${prefix}`
);
const text = await response.text();
return text.includes(suffix);
}
Best Practice 3: Secure Password Reset 5 min
Password reset is a common attack vector:
import crypto from 'crypto';
async function requestPasswordReset(email) {
const user = await findUserByEmail(email);
// Always return success (prevent user enumeration)
if (!user) {
return { message: 'If the email exists, a reset link was sent' };
}
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto
.createHash('sha256')
.update(token)
.digest('hex');
// Store hashed token with expiry
await db.passwordReset.create({
userId: user.id,
tokenHash: hashedToken,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
});
// Send email with plain token
await sendEmail(email, {
subject: 'Password Reset',
link: `https://app.example.com/reset?token=${token}`,
});
return { message: 'If the email exists, a reset link was sent' };
}
async function resetPassword(token, newPassword) {
const hashedToken = crypto
.createHash('sha256')
.update(token)
.digest('hex');
const reset = await db.passwordReset.findFirst({
where: {
tokenHash: hashedToken,
expiresAt: { gt: new Date() },
used: false,
},
});
if (!reset) {
throw new Error('Invalid or expired reset link');
}
// Hash and save new password
const hash = await hashPassword(newPassword);
await db.user.update({
where: { id: reset.userId },
data: { passwordHash: hash },
});
// Mark token as used and invalidate sessions
await db.passwordReset.update({
where: { id: reset.id },
data: { used: true },
});
await invalidateAllSessions(reset.userId);
}
Best Practice 4: Rate Limiting Login Attempts 3 min
Protect against brute force attacks:
import rateLimit from 'express-rate-limit';
// Global rate limit by IP
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
// Per-account lockout
const LOCKOUT_THRESHOLD = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
async function attemptLogin(email, password, ip) {
const user = await findUserByEmail(email);
if (!user) {
// Prevent timing attacks
await bcrypt.hash(password, 12);
throw new AuthError('Invalid credentials');
}
// Check if account is locked
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new AuthError('Account temporarily locked');
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
// Increment failed attempts
const attempts = user.failedAttempts + 1;
await db.user.update({
where: { id: user.id },
data: {
failedAttempts: attempts,
lockedUntil: attempts >= LOCKOUT_THRESHOLD
? new Date(Date.now() + LOCKOUT_DURATION)
: null,
},
});
throw new AuthError('Invalid credentials');
}
// Reset failed attempts on success
await db.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockedUntil: null },
});
return createSession(user);
}
Best Practice 5: Offer Stronger Authentication 10 min
Passwords alone are not enough:
- Offer two-factor authentication (TOTP, WebAuthn)
- Support passwordless login (magic links, passkeys)
- Encourage password managers
- Implement step-up authentication for sensitive actions
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
// Generate 2FA secret
function generate2FASecret(email) {
const secret = speakeasy.generateSecret({
name: `YourApp (${email})`,
issuer: 'YourApp',
});
return {
secret: secret.base32,
qrCodeUrl: secret.otpauth_url,
};
}
// Verify 2FA token
function verify2FAToken(secret, token) {
return speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: token,
window: 1, // Allow 1 step before/after
});
}
// Login with 2FA
async function loginWith2FA(email, password, totpToken) {
const user = await authenticatePassword(email, password);
if (user.twoFactorEnabled) {
if (!totpToken) {
return { requiresTwoFactor: true };
}
if (!verify2FAToken(user.twoFactorSecret, totpToken)) {
throw new AuthError('Invalid 2FA code');
}
}
return createSession(user);
}
Official Resources: For comprehensive password security guidance, see OWASP Password Storage Cheat Sheet and OWASP Authentication Cheat Sheet.
Should I require password changes every 90 days?
No. NIST no longer recommends periodic password rotation. It leads to weaker passwords (Password1, Password2...). Only require changes when there is evidence of compromise.
What about complexity requirements?
Studies show complexity requirements (uppercase, number, symbol) do not improve security and frustrate users. Focus on length and breach checking instead.
How many bcrypt rounds should I use?
Use the highest value that keeps login under 250ms on your server. Start with 10-12 and benchmark. Increase over time as hardware improves.