Password Security Best Practices 2026: Hashing, Policies & Breach Detection
Password security best practices have evolved significantly — and the gap between what developers implement and what attackers expect is wider than ever. Credential stuffing, rainbow table attacks, and brute force automation make poor password handling a critical vulnerability. This guide covers hashing algorithms, policy design, and breach detection with production-ready code.
Check your site's security configuration: Free ZeriFlow scan in 60 seconds →
Why Password Storage Still Goes Wrong
Despite years of guidance, password storage failures remain in the OWASP Top 10. The most common mistakes are using MD5 or SHA-1 (fast hashes designed for integrity, not passwords), storing passwords in plain text, using a single global salt instead of per-user salts, and choosing bcrypt cost factors so low they offer no real protection.
Modern GPU-based cracking rigs can compute 10 billion MD5 hashes per second. Even a "complex" 8-character password falls in under a minute. The solution is adaptive hashing: algorithms designed to be tunable so that hardware improvements do not erode protection.
bcrypt vs Argon2: Choosing the Right Algorithm
bcrypt has been the standard for 25 years. It is battle-tested, widely supported, and uses a cost factor that doubles computation time for each increment.
// Node.js — bcrypt
const bcrypt = require('bcrypt');
const COST_FACTOR = 12; // 2^12 rounds — ~300ms on modern hardware
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, COST_FACTOR);
}
async function verifyPassword(plaintext, hash) {
return bcrypt.compare(plaintext, hash);
}# Python — bcrypt
import bcrypt
COST_FACTOR = 12
def hash_password(plaintext: str) -> bytes:
return bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt(rounds=COST_FACTOR))
def verify_password(plaintext: str, hashed: bytes) -> bool:
return bcrypt.checkpw(plaintext.encode(), hashed)Argon2 won the Password Hashing Competition in 2015 and is the recommended algorithm for new systems. It has three variants: Argon2i (side-channel resistant), Argon2d (GPU resistant), and Argon2id (hybrid, recommended). Unlike bcrypt, Argon2 has memory and parallelism parameters in addition to time cost, making GPU attacks dramatically more expensive.
# Python — argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=65536, # 64 MB
parallelism=4,
hash_len=32,
salt_len=16
)
def hash_password(plaintext: str) -> str:
return ph.hash(plaintext)
def verify_password(plaintext: str, hashed: str) -> bool:
try:
return ph.verify(hashed, plaintext)
except VerifyMismatchError:
return False// Node.js — argon2
const argon2 = require('argon2');
async function hashPassword(plaintext) {
return argon2.hash(plaintext, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4
});
}
async function verifyPassword(plaintext, hash) {
return argon2.verify(hash, plaintext);
}Rule of thumb: target 300-500ms per hash on your production server. This is imperceptible to users logging in but makes brute-force attacks thousands of times slower. Periodically benchmark and increase cost factors as your hardware improves.
Password Policy: What the Evidence Says
NIST SP 800-63B (2024 revision) fundamentally changed what good password policy looks like. The old rules — mandatory complexity, regular rotation, security questions — are gone. The evidence showed they trained users to make predictable patterns (P@ssword1!, Summer2024!) while creating support overhead.
Current NIST guidance:
- Minimum length: 8 characters. Recommended: 12-15+.
- Maximum length: at least 64 characters (to support passphrases and password managers).
- Allow all printable ASCII and Unicode characters.
- Do not enforce complexity rules (uppercase, numbers, symbols).
- Check against a list of known compromised passwords (see next section).
- Do not expire passwords unless there is evidence of compromise.
- Do not use security questions.
// Password policy validation
const commonPasswords = require('./common-passwords.json'); // top 100k list
function validatePassword(password) {
const errors = [];
if (password.length < 12) {
errors.push('Password must be at least 12 characters');
}
if (password.length > 128) {
errors.push('Password cannot exceed 128 characters');
}
if (commonPasswords.includes(password.toLowerCase())) {
errors.push('This password is too common. Please choose something more unique.');
}
return { valid: errors.length === 0, errors };
}HaveIBeenPwned Integration: Blocking Breached Passwords
Troy Hunt's Pwned Passwords API contains over 800 million passwords from real data breaches. Checking user passwords against this list at registration and password change time is now a best practice (and a NIST recommendation).
The API uses a k-anonymity model so your actual password never leaves your server:
const crypto = require('crypto');
const https = require('https');
async function isPasswordPwned(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}`,
{ headers: { 'Add-Padding': 'true' } }
);
const text = await response.text();
return text.split('\n').some(line => {
const [hashSuffix] = line.split(':');
return hashSuffix === suffix;
});
}
// Usage at registration
async function registerUser(email, password) {
if (await isPasswordPwned(password)) {
return { error: 'This password has appeared in a data breach. Please choose a different password.' };
}
const hash = await hashPassword(password);
// ... store user
}import hashlib
import requests
def is_password_pwned(password: str) -> bool:
sha1 = hashlib.sha1(password.encode('utf-8')).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
response = requests.get(
f"https://api.pwnedpasswords.com/range/{prefix}",
headers={"Add-Padding": "true"}
)
return any(
line.split(':')[0] == suffix
for line in response.text.splitlines()
)Anti-Brute Force: Rate Limiting and Account Lockout
Blocking brute force attacks requires layered controls. No single mechanism is sufficient.
Progressive delays — add exponential backoff after each failed attempt:
const attempts = new Map(); // use Redis in production
async function loginWithThrottle(email, password, ip) {
const key = `login:${email}`;
const ipKey = `login_ip:${ip}`;
const userAttempts = attempts.get(key) || 0;
const ipAttempts = attempts.get(ipKey) || 0;
if (userAttempts >= 5 || ipAttempts >= 20) {
const delay = Math.min(Math.pow(2, userAttempts) * 1000, 30000);
await new Promise(r => setTimeout(r, delay));
}
const user = await db.users.findByEmail(email);
const valid = user && await verifyPassword(password, user.password_hash);
if (!valid) {
attempts.set(key, userAttempts + 1);
attempts.set(ipKey, ipAttempts + 1);
return { error: 'Invalid credentials' };
}
attempts.delete(key);
return { user };
}Account lockout: lock the account after 10 consecutive failures, require email-based unlock. Avoid locking after 3-5 attempts — this enables denial of service against your users.
IP-based rate limiting: use a token bucket or sliding window at the reverse proxy level (nginx, Cloudflare) as a first line of defense before requests reach your application.
Transmission Security
Passwords must only travel over TLS 1.2+. ZeriFlow scans your site's TLS configuration, checking for weak cipher suites, expired certificates, and missing HSTS headers. A site that allows HTTP fallback can have credentials intercepted in transit regardless of how well they are hashed at rest.
Additional transport-layer controls:
- Set Strict-Transport-Security: max-age=31536000; includeSubDomains to force HTTPS.
- Never log passwords — audit your logging middleware for accidental password capture.
- Never return the password hash in API responses.
- Use autocomplete="new-password" on password fields to prevent browser autofill on sensitive forms.
FAQ
### Q: Should I still use bcrypt or switch to Argon2 for new projects? A: Use Argon2id for new projects. It is the current recommendation from NIST and the Password Hashing Competition. For existing bcrypt deployments, migration is straightforward: re-hash passwords with Argon2 the next time each user logs in, and fall back to bcrypt for users who have not yet logged in.
### Q: What cost factor should I use for bcrypt? A: Start at 12 and benchmark. Target 300-500ms per hash on your production hardware. In 2026, cost 12-14 is typical. Revisit annually as server hardware improves.
### Q: Should I salt passwords manually before hashing? A: No. Both bcrypt and Argon2 generate and store a cryptographically random salt internally. Adding a manual pre-salt (pepper) before hashing can provide defense-in-depth if your database is compromised but your application secret is not, but it also adds complexity. If you use a pepper, store it in an environment variable or secrets manager, not in the database.
### Q: How often should users be required to change passwords? A: NIST no longer recommends forced periodic rotation. Require a password change only when there is evidence of compromise (e.g., a breach affecting your service, or a user-reported suspicious login). Forced rotation trains users to make weak incremental changes.
### Q: Is it safe to use the HaveIBeenPwned API in production? A: Yes. The k-anonymity model means only the first 5 characters of the SHA-1 hash are sent to the API. The actual password never leaves your server. Troy Hunt publishes the full dataset for download if you want to run the check entirely on-premise.
Conclusion
Robust password security is a combination of choosing the right hashing algorithm (Argon2id), applying evidence-based policies (NIST SP 800-63B), and layering anti-brute-force controls. Add HaveIBeenPwned checks at registration to prevent credentials from breached datasets from entering your system. Protect transmission with HSTS and strong TLS.
Run a free ZeriFlow scan → — no credit card required.