Password policy design: NIST 800-63B guidance and why complexity rules are wrong

The conventional password policy — at least 8 characters, must include uppercase, lowercase, a number, and a special character, change it every 90 days — was largely invented without empirical backing and has been thoroughly discredited by research. NIST SP 800-63B, the federal digital identity guideline, reversed most of these requirements in its 2017 update. Implementing legacy complexity rules today is not just a poor user experience, it actively makes passwords weaker.

Why complexity rules backfire

Complexity requirements do not produce random passwords — they produce predictable patterns. When required to include a number and special character, most users add "1!" at the end of a word. When required to capitalize, they capitalize the first letter. Password crackers are built to exploit exactly these patterns. A 12-character password that is a random sequence of lowercase letters has more entropy than an 8-character password following the "uppercase + number + special character" pattern because the pattern dramatically reduces the search space.

Forced rotation compounds the problem. Users who must change passwords every 90 days cycle through minor variations: Password1!, Password2!, Password3!. An attacker who cracked last quarter's password can guess the current one trivially. NIST 800-63B explicitly recommends against mandatory rotation except when there is evidence of compromise.

NIST 800-63B recommendations

The current NIST guidance on memorized secrets (passwords):

  • Minimum 8 characters required; allow up to at least 64 characters.
  • Accept all printable ASCII characters, spaces, and Unicode characters.
  • Do not impose composition rules (no "must include uppercase/number/special").
  • Do not require periodic rotation.
  • Do require rotation when there is evidence of compromise.
  • Check passwords against a list of known compromised passwords.
  • Check against context-specific words (the service name, the username).
  • Offer strength meters as guidance, but do not block on complexity.

Breach database checking with HIBP

The most impactful addition to any password policy is checking new and changed passwords against the HaveIBeenPwned (HIBP) database of breached passwords. HIBP's Pwned Passwords dataset contains over 600 million real-world passwords that have appeared in data breaches. Blocking these passwords prevents credential stuffing attacks where attackers test known-breached credentials.

The k-anonymity API lets you check passwords without sending the password to a third party. You hash the password with SHA-1, send the first 5 characters of the hex hash, and receive all hash suffixes that match. If your full hash is in the results, the password is breached.

// HIBP k-anonymity check — privacy-preserving breach lookup
async function isPasswordBreached(password: string): Promise<boolean> {
  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' } }  // padding prevents traffic analysis
  );

  if (!response.ok) {
    // If HIBP is unavailable, fail open — don't block the user
    return false;
  }

  const text = await response.text();
  const hashes = text.split('\r\n').map(line => {
    const [hashSuffix, count] = line.split(':');
    return { hashSuffix, count: parseInt(count) };
  });

  return hashes.some(h => h.hashSuffix === suffix);
}

// Using it in password validation
async function validateNewPassword(
  password: string,
  userId: string,
  email: string
): Promise<ValidationResult> {
  const errors: string[] = [];

  // Minimum length
  if (password.length < 8) {
    errors.push('Password must be at least 8 characters');
  }

  // Context-specific word check
  const username = email.split('@')[0].toLowerCase();
  if (password.toLowerCase().includes(username)) {
    errors.push('Password should not contain your username');
  }

  if (password.toLowerCase().includes('bastionary')) {
    errors.push('Password should not contain the service name');
  }

  // Breach check (non-blocking if unavailable)
  const breached = await isPasswordBreached(password).catch(() => false);
  if (breached) {
    errors.push(
      'This password has appeared in a data breach. Please choose a different password.'
    );
  }

  return { valid: errors.length === 0, errors };
}

Custom blocklists

Beyond HIBP, maintain a custom blocklist of passwords that are context-specific. This includes the names of your service and common variations, keyboard patterns, and words specific to your industry. The blocklist should be checked before the HIBP API call to avoid unnecessary network requests.

// Custom blocklist — loaded at startup
const BLOCKLISTED_PASSWORDS = new Set([
  'password', 'password1', 'password123',
  'qwerty', 'qwerty123', '12345678', '123456789',
  'letmein', 'welcome', 'admin', 'administrator',
  // Service-specific
  'bastionary', 'bastionary1', 'bastionary123',
  // Common patterns
  'iloveyou', 'monkey', 'dragon', 'master'
]);

function isPasswordBlocklisted(password: string): boolean {
  const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
  return BLOCKLISTED_PASSWORDS.has(normalized);
}

Password hashing

NIST 800-63B requires using a memory-hard hashing function. Use Argon2id — it is the winner of the Password Hashing Competition and the current recommended choice. bcrypt is acceptable but limited to 72-character inputs and not memory-hard. PBKDF2 is compliant for FIPS environments but weaker than Argon2id. Never use SHA-256, SHA-512, or MD5 for password hashing, regardless of salt length — they are too fast.

// Argon2id for password hashing (Node.js)
import argon2 from 'argon2';

// OWASP-recommended parameters for Argon2id
const ARGON2_OPTIONS = {
  type: argon2.argon2id,
  memoryCost: 19456,   // 19 MiB
  timeCost: 2,         // 2 iterations
  parallelism: 1
};

async function hashPassword(password: string): Promise<string> {
  return argon2.hash(password, ARGON2_OPTIONS);
}

async function verifyPassword(hash: string, password: string): Promise<boolean> {
  return argon2.verify(hash, password);
}

// Check if hash needs rehashing (algorithm or parameters changed)
function needsRehash(hash: string): boolean {
  return argon2.needsRehash(hash, ARGON2_OPTIONS);
}
Rehash passwords on successful login if your hash parameters have been upgraded. When the user authenticates successfully, check whether the stored hash was created with your current parameters using needsRehash. If it was not, re-hash the plaintext password (which you have at this moment during authentication) with the current parameters and update the stored hash. This migrates your hash library gradually without forcing password resets.
← Back to blog Try Bastionary free →