Passwords
Password security sits at the intersection of two competing forces: security requirements that make passwords difficult to attack, and usability constraints that determine whether people follow the policy at all. A policy that is too burdensome gets worked around -- passwords written on sticky notes, reused across systems, incremented predictably (Password1! to Password2!). The goal is a policy grounded in how attacks actually work, not one based on theatre that makes auditors comfortable while providing little real protection.
This article follows NIST SP 800-63B (Digital Identity Guidelines) as the primary reference, which reversed several long-standing password requirements that research showed to be counterproductive.
How Password Attacks Work
Policy requirements should be designed around the attacks they defend against. The primary attack classes are:
Brute Force
The attacker systematically tries every possible combination of characters. Effectiveness depends entirely on password length and character space, and the cost of computing each guess (determined by the hashing algorithm protecting the stored password).
Password space = charset_size ^ length
8-char alphanumeric (62 chars): 62^8 = ~218 trillion combinations
12-char alphanumeric: 62^12 = ~3.2 quadrillion combinations
16-char alphanumeric: 62^16 = ~47 quintillion combinations
At 100 billion guesses per second (modern GPU against MD5): an 8-character password falls in ~25 minutes. A 12-character password takes ~370 days. A 16-character password takes millions of years. Length is the single most effective defence against brute force.
Dictionary and Rule-Based Attacks
Rather than exhaustive enumeration, attackers apply transformation rules to wordlists derived from breach data, common phrases, keyboard patterns, and cultural references.
Hashcat rules like best64.rule apply substitutions (a->@, e->3, o->0), capitalisations, and suffix appending to millions of base words. correct horse battery staple as a passphrase is far stronger than P@ssw0rd! despite meeting complexity requirements.
Implications for policy:
- Requiring !@#$ substitutions provides weak protection -- attackers know the rules too
- Banning common passwords and known breached passwords is more effective than mandating complexity patterns
Credential Stuffing
Attackers take email:password pairs from one breach and replay them against other services. Success relies entirely on password reuse across accounts.
Implication: Users who reuse passwords are vulnerable regardless of the password's inherent strength. The defence is detection (see Advanced WAF) and checking passwords against breach databases at authentication time.
Password Spraying
Rather than brute-forcing a single account (which triggers lockout), attackers try a small set of common passwords (Password1, Welcome1, Summer2024) across a large number of accounts. Each account receives only one or two attempts -- staying under lockout thresholds.
Implication: Banning the most common passwords and enforcing MFA are the primary defences. Lockout policies alone are insufficient.
Phishing and Social Engineering
The technically strongest password provides zero protection if the user is deceived into entering it on a fake login page. Phishing-resistant MFA (FIDO2/passkeys) is the only complete defence.
Password Requirements
Length
Length is the most important password characteristic. Minimum 12 characters for user accounts; 16+ for privileged accounts.
| Account Type | Minimum Length | Recommended |
|---|---|---|
| Standard user | 12 characters | 16+ |
| Privileged / admin account | 16 characters | 20+ |
| Service account / API key | 32 characters | Use a secrets manager |
| System-to-system secrets | 32+ characters | Randomly generated; never human-typed |
NIST SP 800-63B requires support for passwords up to at least 64 characters. Many organisations still cap passwords at 16 or 20 characters -- this is a legacy constraint with no security justification. Remove length maximums or raise them to 256+.
Complexity
NIST SP 800-63B explicitly recommends against mandatory complexity rules (requiring uppercase, numbers, and special characters). Research showed these requirements cause predictable behaviour:
- Users satisfy rules minimally:
Password1! - Complexity is concentrated at predictable positions: capital at start, number and symbol at end
- Users rotate predictably:
Password1!toPassword2!
What to do instead:
- Enforce minimum length
- Check against a banned password list
- Check against known breached passwords
- Allow any printable Unicode character including spaces
If your organisation or a compliance requirement mandates complexity rules, configure them as:
- At least one character from two or more character classes (not all four)
- Never mandate specific positions (first character uppercase, last character a digit)
Banned Password List
Block the most commonly chosen passwords regardless of whether they meet length and complexity requirements. At minimum, ban:
- The top 10,000-1,000,000 most common passwords (e.g. from the SecLists project)
- Common patterns:
password,letmein,welcome,qwerty,abc123,111111 - The organisation's own name, domain, product names, and obvious variations
- Keyboard walks:
qwerty,asdfgh,zxcvbn,123456,qwerty123 - Seasonal/year patterns:
Summer2024,Winter2024,Jan2024 - Dictionary words with simple substitutions:
P@ssw0rd,S3cur1ty
# Check password against banned list at registration/change
def is_banned_password(password: str, banned_list: set) -> bool:
normalised = password.lower().strip()
return normalised in banned_list
# At authentication endpoint:
if is_banned_password(new_password, BANNED_PASSWORDS):
return error("This password is too common. Please choose a less predictable password.")
Breached Password Detection
Check passwords against known breach databases at account creation and password change. If a user's chosen password appears in a breach, reject it -- even if it meets all other requirements.
HaveIBeenPwned Pwned Passwords (k-anonymity API): The full database of 900 million+ breached passwords, queryable without sending the plain-text password:
import hashlib
import requests
def is_breached_password(password: str) -> tuple[bool, int]:
# Uses k-anonymity -- password is never sent to the API
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}", timeout=5)
response.raise_for_status()
for line in response.text.splitlines():
hash_suffix, count = line.split(":")
if hash_suffix == suffix:
return True, int(count)
return False, 0
# Usage at password change:
breached, count = is_breached_password(new_password)
if breached:
return error(f"This password has appeared in data breaches {count:,} times.")
Self-hosted alternative: Download the full HIBP SHA-1 hash list (~40 GB) and run checks locally -- no third-party dependency and no network call during authentication.
Passphrases
Encourage passphrases -- sequences of random words that are long, memorable, and resistant to dictionary attacks:
correct-horse-battery-staple (4 words, ~44 bits of entropy)
purple-monkey-dishwasher-friday (4 words, ~44 bits of entropy)
coffee-lamp-river-seven-moon (5 words, ~55 bits of entropy)
A 4-word passphrase from a 7,776-word wordlist (Diceware) has ~51 bits of entropy -- stronger than P@ssw0rd1! while being far more memorable. Train users that length beats complexity.
Password Rotation
The Old Guidance (Now Superseded)
Mandatory periodic rotation -- changing passwords every 30, 60, or 90 days -- was standard practice for decades. It was based on intuition: if a password is compromised, rotating it limits exposure.
Research and real-world experience showed the opposite: mandatory rotation makes passwords weaker, not stronger. When forced to change passwords regularly, users:
- Make minimal changes: Password1! to Password2! to Password3!
- Choose weaker base passwords knowing they will change soon
- Write passwords down to remember the current iteration
- Reuse passwords across systems to reduce cognitive load
NIST SP 800-63B Current Guidance
NIST explicitly recommends against mandatory periodic rotation for passwords that show no evidence of compromise. Instead:
Rotate immediately and only when:
- A credential is known or suspected to be compromised
- A user leaves the organisation (for shared credentials)
- An account shows signs of unauthorised access
- A breach of the authentication system or password store is detected
- A staff member with knowledge of the credential departs
Do not rotate on a fixed schedule if there is no evidence of compromise.
Exception: Service Accounts and Shared Credentials
For system-to-system credentials, API keys, and shared service accounts, rotation should be:
- Automated -- using a secrets manager (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault)
- Frequent -- 30-90 day rotation is reasonable when fully automated
- Never manual -- manually rotated shared secrets introduce outage risk and are frequently delayed or skipped
# AWS Secrets Manager -- automatic rotation every 30 days
aws secretsmanager rotate-secret \
--secret-id prod/database/password \
--rotation-lambda-arn arn:aws:lambda:eu-west-1:123456789:function:RotateDBPassword \
--rotation-rules AutomaticallyAfterDays=30
Privileged Account Passwords
For privileged accounts (local admin, domain admin, service accounts), use LAPS (Local Administrator Password Solution) for local accounts and a Privileged Access Management (PAM) solution for shared privileged accounts:
- Passwords are checked out on demand, used once, and rotated on check-in
- No standing privileged credentials that can be harvested
- Full audit trail of who accessed what privileged account and when
Account Lockout and Throttling
Lockout policies defend against brute force and password spraying while creating a denial-of-service risk if misconfigured.
Recommended Settings
| Setting | Recommended Value | Rationale |
|---|---|---|
| Lockout threshold | 5-10 failed attempts | Balance security vs self-DoS risk |
| Lockout duration | 15-30 minutes (soft lockout) | Auto-unlock reduces helpdesk load |
| Admin unlock | Required after X minutes | For higher-security accounts |
| Observation window | Equal to lockout duration | Reset counter after window expires |
| Failed attempt logging | All failures | Feed to SIEM for spray detection |
Soft lockout vs hard lockout:
- Soft lockout: Account unlocks automatically after the lockout duration. Preferred for most environments -- balances protection with usability.
- Hard lockout: Account requires manual admin unlock. Use for highly privileged accounts or environments with high phishing risk.
Detecting password spraying: A single account hitting the lockout threshold is a user forgetting their password. Hundreds of accounts each receiving 2-3 failed attempts simultaneously is a spray attack. Alert on this pattern in your SIEM:
Alert: Password spray detected
Condition: > 20 unique accounts with failed login in a 5-minute window
Severity: High
Response: Investigate source IPs; consider temporary geo-block; notify security team
Progressive Delays (API and Web)
For web and API login endpoints, implement progressive rate limiting rather than or in addition to hard lockout:
# Progressive delay on failed authentication
DELAY_SECONDS = {1: 0, 2: 1, 3: 5, 4: 15, 5: 30}
def get_login_delay(failed_attempts: int) -> int:
return DELAY_SECONDS.get(failed_attempts, 60) # 60s for 5+ failures
This adds cost to automated attacks while being imperceptible to legitimate users who rarely fail more than once.
Password Storage
How passwords are stored is as important as how they are chosen. A breach of weakly hashed passwords renders even strong passwords crackable offline.
Approved Hashing Algorithms
Never store passwords as plaintext, Base64, or with reversible encryption. Use a purpose-built password hashing function (not a general cryptographic hash like SHA-256):
| Algorithm | Recommended | Notes |
|---|---|---|
| Argon2id | First choice | Winner of the Password Hashing Competition; memory-hard; resistant to GPU and ASIC attacks |
| bcrypt | Acceptable | Widely supported; work factor adjustable; limited to 72-byte input (use prehashing for longer passwords) |
| scrypt | Acceptable | Memory-hard; more complex to configure correctly |
| PBKDF2-HMAC-SHA256 | Acceptable (FIPS environments) | Required by FIPS 140; less resistant to GPU than Argon2/bcrypt |
| MD5, SHA-1, SHA-256 (unsalted) | Never | Crackable offline in hours with modern hardware; no work factor |
# Argon2id -- recommended implementation (Python)
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=2, # Number of iterations
memory_cost=65536, # 64 MB memory usage
parallelism=2, # Parallelism factor
hash_len=32, # Output hash length
salt_len=16 # Random salt length
)
# Hash a password
hashed = ph.hash(password)
# Verify a password
try:
ph.verify(hashed, input_password)
if ph.check_needs_rehash(hashed):
hashed = ph.hash(input_password) # Rehash if parameters changed
return True
except Exception:
return False
# bcrypt -- acceptable alternative (Python)
import bcrypt
# Hash
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# Verify
bcrypt.checkpw(password.encode(), hashed)
Salting
All approved password hashing functions generate a unique random salt per password automatically. Salting ensures:
- Two users with the same password produce different hashes
- Pre-computed rainbow table attacks are infeasible
- An attacker who breaches the hash database must crack each hash individually
Never implement salting manually -- use a library that handles it correctly.
Work Factor Tuning
The work factor (iterations, memory cost) should be tuned so that hashing takes approximately 250-500ms on your authentication servers. As hardware improves, increase the work factor to maintain this target:
# Benchmark Argon2id work factors for your hardware
import time
from argon2 import PasswordHasher
for time_cost in [1, 2, 3, 4]:
for memory_cost in [32768, 65536, 131072]:
ph = PasswordHasher(time_cost=time_cost, memory_cost=memory_cost)
start = time.time()
ph.hash("benchmark_password")
elapsed = time.time() - start
print(f"time={time_cost}, memory={memory_cost//1024}MB: {elapsed*1000:.0f}ms")
Multi-Factor Authentication
A strong password combined with MFA provides substantially better protection than a strong password alone. If a password is phished, stuffed from a breach, or guessed, MFA prevents account takeover.
MFA factors by strength:
| Factor | Examples | Phishing Resistant |
|---|---|---|
| FIDO2 / Passkey | Hardware security key (YubiKey), platform authenticator (Face ID, Touch ID) | Yes |
| TOTP | Google Authenticator, Authy, Microsoft Authenticator | No -- OTP can be phished in real time |
| Push notification | Duo, Microsoft Authenticator push | No -- MFA fatigue attacks work against this |
| SMS OTP | Text message codes | No -- SIM swap, SS7 attacks |
| Email OTP | Email magic links | No -- depends on email account security |
For high-security accounts and privileged access, require FIDO2/passkeys -- the only MFA factor that is phishing-resistant by design. The private key never leaves the device and the credential is bound to the origin domain.
Password Managers
Encourage or mandate the use of a password manager. A password manager enables users to:
- Use a unique, randomly generated password for every account
- Never reuse passwords
- Store passwords of arbitrary length and complexity without cognitive burden
- Share credentials securely within teams (enterprise password managers)
Enterprise deployment:
- Deploy a business password manager (1Password Business, Bitwarden Teams, Keeper, Dashlane Business) centrally managed
- Enforce vault access via SSO and MFA
- Review shared credential vault access as part of access reviews
- Never store privileged account passwords in a personal vault -- use a PAM solution
For developers: Enforce the use of a secrets manager (HashiCorp Vault, AWS Secrets Manager, 1Password Secrets Automation) for application credentials -- never in .env files committed to version control.
Session Management
A strong password is undermined by weak session management. After successful authentication:
| Control | Recommendation |
|---|---|
| Session token length | >= 128 bits of entropy (use a CSPRNG) |
| Session fixation | Regenerate session ID after successful login |
| Session timeout (idle) | 15-30 minutes for sensitive applications |
| Session timeout (absolute) | 8-12 hours; require re-authentication |
| Concurrent sessions | Limit and notify user of new sessions from unexpected locations |
| Cookie flags | HttpOnly, Secure, SameSite=Strict |
| Session invalidation on logout | Server-side invalidation; do not rely only on cookie deletion |
Hardening Checklist
| Control | Priority | Notes |
|---|---|---|
| Minimum password length >= 12 characters | Critical | 16+ for privileged accounts |
| Maximum password length >= 64 characters | High | Remove arbitrary low maximums |
| All Unicode printable characters accepted | Medium | Including spaces |
| Banned password list enforced at creation/change | High | Top 10,000+ common passwords |
| Breached password check (HIBP) at creation/change | High | k-anonymity API or local dataset |
| No mandatory periodic rotation (unless compromised) | High | NIST SP 800-63B |
| Immediate rotation on suspected compromise | Critical | Define and test the process |
| Service account credentials rotated automatically | High | Secrets manager with auto-rotation |
| Account lockout after 5-10 failed attempts | High | Soft lockout preferred |
| Password spray detection alert in SIEM | High | > 20 accounts with failures in 5 min |
| Passwords hashed with Argon2id or bcrypt | Critical | Never MD5, SHA-1, or plaintext |
| Unique salt per password | Critical | Automatic with approved algorithms |
| Work factor tuned to 250-500ms on auth server | Medium | Increases offline attack cost |
| MFA required for all user accounts | Critical | FIDO2 for privileged access |
| Password manager deployed and enforced | High | Unique password per account |
| Session tokens >= 128 bits entropy | High | Use CSPRNG |
| Session invalidated server-side on logout | High | Not just cookie deletion |
| Idle session timeout <= 30 minutes (sensitive systems) | Medium | Context-dependent |
References
- NIST SP 800-63B -- Digital Identity Guidelines: Authentication and Lifecycle Management
- OWASP Authentication Cheat Sheet
- OWASP Password Storage Cheat Sheet
- HaveIBeenPwned Pwned Passwords
- Argon2 -- Password Hashing Competition Winner
- Diceware Passphrase Method
- SecLists -- Common Passwords
- Troy Hunt -- Passwords Evolved