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.
"A stolen password table hashed with argon2id is an inconvenience. The same table hashed with SHA-256 is every account your users reused that password on."
Best Practice 1: Use the Right Hashing Algorithm 3 min
SHA-256 is built to be fast, and that's what disqualifies it. A stolen table can be attacked at billions of guesses per second on a rented GPU. Password hashes are deliberately slow, which makes that uneconomic.
Use one of these and nothing else:
| 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
Most of what people know about password rules comes from advice NIST has retired. The current guidance mostly means deleting rules you already built:
- 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
Reset is where hand-rolled auth leaks. The flow below stores only a hash of the token, so a leaked row can't be replayed, and it revokes every session on password change.
That second part matters. If an attacker already holds a session and the user resets to lock them out, leaving that session alive means the reset did nothing.
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
You need both limits. An IP limit stops one machine hammering many accounts; a per-account lockout stops a botnet hammering one. Ship only the first and credential stuffing walks through.
Note the throwaway hash on the not-found branch. Without it a missing account answers faster than a wrong password, which leaks who is registered.
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
A perfect password policy still loses to reuse. Your user picked something strong, then used it on a forum breached in 2019. Your hashing can't touch that:
- 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 dropped it, because forced rotation just produces Password1, then Password2. Require a change on evidence of compromise, not on a calendar.
What about complexity requirements?
NIST SP 800-63B advises against them. Composition rules push people toward predictable substitutions like P@ssw0rd1, which cracking dictionaries already carry. Length and a breach check do more.
How many bcrypt rounds should I use?
Pick the highest cost factor that keeps a login under 250ms on your own hardware. Start at 10 to 12, benchmark, and raise it as servers get faster.
Further Reading
Put these practices into action with our step-by-step guides.
Check Your Password Security
Scan for weak password hashing and policy issues.