Skip to main content
Back to blog
April 28, 2026·Updated April 28, 2026|8 min read|Antoine Duno

Password Security Best Practices 2026: Hashing, Policies & Breach Detection

Weak password storage and poor policies remain the root cause of the majority of data breaches. This guide covers everything from hashing algorithms to breach detection in 2026.

ZeriFlow Team

1,540 words

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.

javascript
// 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
# 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
# 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
javascript
// 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.
javascript
// 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:

javascript
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
}
python
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:

javascript
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.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading