Password Managers and Credential Hygiene
Weak, reused, and stolen passwords are the root cause of the majority of account compromises. This note covers how password managers work internally (vault encryption), key derivation functions (PBKDF2, Argon2), credential breach detection, why password reuse is catastrophic, and operational practices for individuals and organisations.
Why Passwords Fail
Section titled “Why Passwords Fail”The Reuse Problem
Section titled “The Reuse Problem”User creates password "Summer2023!" for: Gmail account Work VPN GitHub Banking portal 10 other sites
One site has a data breach → hash cracked → Attacker now has valid credentials for ALL of those accounts → This is credential stuffing
Scale: Have I Been Pwned database as of 2025: > 12 billion exposed accounts from known breaches Automated tools test millions of username/password pairs per hourHuman Password Patterns Are Predictable
Section titled “Human Password Patterns Are Predictable”# Dictionary words are cracked in secondshashcat -a 0 -m 3200 hash.txt rockyou.txt # bcrypt crack attempt
# Common patterns cracked with ruleshashcat -a 0 -m 1000 hash.txt rockyou.txt -r rules/best64.rule# Tries: password, Password, Password1, P@ssword, p@ssw0rd, etc.
# Password123!, Summer2024, CompanyName1 → cracked within minutes# 4-word random passphrase "correct horse battery staple" → centuries to crackHow Password Managers Work Internally
Section titled “How Password Managers Work Internally”Vault Encryption Model
Section titled “Vault Encryption Model”A password manager stores an encrypted database (vault). The vault is only decryptable with the master password, which never leaves your device in plaintext.
Master Password (human-entered) │ ▼Key Derivation Function (PBKDF2 / Argon2 / bcrypt) → Stretches password → derives a long symmetric key → Salt prevents rainbow tables → High cost prevents brute force (slow even on GPU) │ ▼Vault Encryption Key (AES-256) │ ▼Encrypted vault (stored locally or in cloud) [Site1: gmail.com → {login: alice, password: ...} encrypted] [Site2: github.com → {login: alice, password: ...} encrypted] │ ▼Cloud sync: only the encrypted blob is transmitted → Provider sees: opaque binary data → Provider CANNOT decrypt your vault → Even a breach of the provider's servers = no plaintext accessHow Login Without Transmitting the Master Password Works
Section titled “How Login Without Transmitting the Master Password Works”Most password managers use a Zero-Knowledge proof architecture:
1Password SRP (Secure Remote Password) flow: Client derives authentication key from master password + Secret Key Client proves knowledge of key to server using SRP (password never transmitted) Server validates proof WITHOUT ever seeing the password Server returns encrypted vault blob Client decrypts locally using the derived encryption keyBitwarden open-source architecture: Master password → PBKDF2-SHA256 (600,000 iterations) → "Master Key" Master Key → HKDF-SHA256 → "Stretched Master Key" (encryption key) Random "Protected Symmetric Key" encrypted with Stretched Master Key All vault items encrypted with the Protected Symmetric Key (AES-256 CBC) Protected Symmetric Key stored server-side (encrypted - server can't decrypt)Key Derivation Functions (KDFs)
Section titled “Key Derivation Functions (KDFs)”KDFs deliberately slow down password hashing to make brute-force attacks expensive.
PBKDF2
Section titled “PBKDF2”PBKDF2(PRF, password, salt, iterations, keyLength) PRF = HMAC-SHA256 (or SHA-512) salt = random 16+ bytes (defeats rainbow tables) iterations = cost factor (more iterations = slower brute force)
Recommended iterations (2024): HMAC-SHA256: 600,000+ (NIST SP 800-132) HMAC-SHA1: 600,000+ HMAC-SHA512: 210,000+# PBKDF2 in Pythonimport hashlib, os, base64
password = b"MyMasterPassword"salt = os.urandom(32) # 256-bit random saltiterations = 600_000 # NIST recommended
key = hashlib.pbkdf2_hmac( hash_name='sha256', password=password, salt=salt, iterations=iterations, dklen=32 # 256-bit output key)
print(f"Derived key (hex): {key.hex()}")Argon2 - Modern Standard
Section titled “Argon2 - Modern Standard”Argon2 (winner of Password Hashing Competition 2015) adds memory hardness - it requires large amounts of RAM, which limits parallelism on GPU clusters.
Variants: Argon2d → fastest; resistant to GPU attacks; vulnerable to side-channel Argon2i → resistant to side-channel; weaker vs GPU for offline cracking Argon2id → hybrid (recommended for password hashing)
Parameters: memory → RAM required (counteracts GPU cracking; GPUs have limited VRAM) time → number of passes over memory parallelism → threads to use
Recommended (2024 OWASP): Argon2id, memory=19456 (19 MB), time=2, parallelism=1# Argon2 in Pythonpip install argon2-cffi
from argon2 import PasswordHasherfrom argon2.exceptions import VerifyMismatchError
ph = PasswordHasher( time_cost=2, # number of iterations memory_cost=19456, # 19 MB of RAM parallelism=1, # single thread hash_len=32, salt_len=16)
# Hash a passwordhash = ph.hash("MyMasterPassword")print(hash) # $argon2id$v=19$m=19456,t=2,p=1$...
# Verify a passwordtry: ph.verify(hash, "MyMasterPassword") print("Password correct")except VerifyMismatchError: print("Wrong password")
# Check if hash needs rehashing (params changed)if ph.check_needs_rehash(hash): hash = ph.hash("MyMasterPassword") # rehash with current paramsbcrypt
Section titled “bcrypt”# bcrypt - designed in 1999; still widely used; built-in memory hardness# Cost factor: each increment doubles the work
# Python: hash and verify with bcryptpip install bcrypt
python3 << 'EOF'import bcrypt
password = b"MyPassword123!"# Hash (cost factor 12 = ~250ms per hash on modern hardware)hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))print(f"Hash: {hashed}")
# Verifyif bcrypt.checkpw(password, hashed): print("Correct password")else: print("Wrong password")EOF
# Linux login passwords (/etc/shadow) also use bcrypt or yescrypt# $6$ = SHA-512 crypt (legacy)# $y$ = yescrypt (modern Debian/Fedora default; memory-hard)Breach Detection
Section titled “Breach Detection”Have I Been Pwned (HIBP) API
Section titled “Have I Been Pwned (HIBP) API”# Check if an email was in a breach -H "hibp-api-key: $HIBP_API_KEY" \ -H "User-Agent: SecAwareness" \ | python3 -m json.tool | grep -E "Name|Domain|BreachDate|PwnCount"
# k-Anonymity model for password checking (privacy-preserving)# Step 1: hash the passwordHASH=$(echo -n "password123" | sha1sum | tr '[:lower:]' '[:upper:]' | awk '{print $1}')PREFIX=${HASH:0:5} # first 5 charsSUFFIX=${HASH:5} # rest
# Step 2: query with only the prefix (server doesn't see full hash)curl -s "https://api.pwnedpasswords.com/range/$PREFIX" | grep "$SUFFIX"# Returns the count: number of times this password appears in breach dumps# 0 = not found; N = found N times in breaches# Python: check password against HIBP without revealing itimport hashlib, requests
def is_password_breached(password: str) -> int: """Returns breach count (0 = not found).""" sha1 = hashlib.sha1(password.encode()).hexdigest().upper() prefix, suffix = sha1[:5], sha1[5:]
response = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}") for line in response.text.splitlines(): hash_suffix, count = line.split(':') if hash_suffix == suffix: return int(count) return 0
count = is_password_breached("password123")print(f"Found in {count} breaches" if count else "Not found in known breaches")Password Manager Comparison
Section titled “Password Manager Comparison”| Manager | Type | Architecture | Notable |
|---|---|---|---|
| Bitwarden | Open source + cloud | Zero-knowledge; self-hostable; audited | Best in class for most; free tier generous |
| 1Password | Commercial | Zero-knowledge + Secret Key; no self-host | Strong teams/enterprise features |
| KeePassXC | Open source, local | Local encrypted file; no cloud sync | Maximum control; manual sync required |
| Dashlane | Commercial | Zero-knowledge; built-in dark web monitoring | Good UX; expensive at enterprise scale |
| Apple Keychain | Platform-native | iCloud sync; end-to-end encrypted | Good for Mac/iOS ecosystem |
| Vaultwarden | Self-hosted | Bitwarden-compatible API | Self-host for teams; full Bitwarden client compatible |
Credential Hygiene Practices
Section titled “Credential Hygiene Practices”For Individuals
Section titled “For Individuals”✅ Password manager for everything - one unique, random password per site✅ Passphrase for master password: 4+ random words (e.g., diceware) "correct horse battery staple" = 44 bits of entropy "Summer2024!" = ~30 bits (predictable pattern)
✅ Enable HIBP or dark web monitoring (1Password, Bitwarden Premium)✅ Enable MFA everywhere (TOTP app; hardware key for critical accounts)✅ Review and revoke unused OAuth authorisations periodically → GitHub: Settings → Applications → Authorised OAuth Apps → Google: myaccount.google.com/permissions
✅ Check browser stored passwords and migrate to password manager → Chrome: chrome://settings/passwords → export CSV → import to Bitwarden
✅ Never share passwords (even with IT - never over email or chat)✅ Use email aliases where possible to limit breach exposure → SimpleLogin, Apple Hide My Email, Firefox RelayFor Organisations
Section titled “For Organisations”Policy requirements: [ ] Password manager deployed to all employees (not optional) [ ] Master password: minimum 16 chars or 4-word passphrase [ ] Breached password detection enabled (HIBP integration or equivalent) [ ] Periodic audit: revoke ex-employee vault access on offboarding [ ] No shared password items in password manager (use per-person entries) [ ] MFA required for password manager access [ ] Password manager vault backup policy (for self-hosted)
What NOT to do: ❌ "Strong password" requirements that produce P@ssw0rd behaviour ❌ Mandatory 90-day rotation (this encourages predictable patterns, per NIST) ❌ Prohibiting password managers (forces insecure alternatives) ❌ Emailing temporary passwords in plaintext ❌ Storing passwords in spreadsheets, wikis, or plaintext filesNIST SP 800-63B Password Guidelines (2017+)
Section titled “NIST SP 800-63B Password Guidelines (2017+)”NIST updated its guidance - some traditional requirements actively harm security:
| Old advice | NIST 800-63B guidance |
|---|---|
| Mandatory rotation every 90 days | Only rotate on evidence of compromise |
| Complexity rules (upper/lower/number/symbol) | Length is more important than complexity |
| Security questions | Do NOT use knowledge-based authentication |
| SMS OTP | Acceptable but discouraged (SIM-swap risk); prefer TOTP or hardware key |
| Block common passwords | Check against known-bad lists (HIBP, common word lists) |
# Enforce NIST-aligned password policy in applications:# 1. Minimum 15 characters# 2. Check against breached password list (HIBP)# 3. Allow all printable characters (including spaces)# 4. No forced rotation unless compromised# 5. No composition rules (no "must include uppercase")
# HIBP Pwned Passwords list (local check, no API):# Download the sorted SHA-1 hash list (~13GB):wget https://downloads.pwnedpasswords.com/passwords/pwned-passwords-sha1-ordered-by-hash-v8.7z
# Check a password hash against local listecho -n "password" | sha1sum | awk '{print toupper($1)}' | \ grep -c -f - pwned-passwords-sorted.txt# Returns: 1 if found (compromised); 0 if not foundPassword Storage for Applications
Section titled “Password Storage for Applications”How applications should store user passwords in their database:
# ✅ Correct: Argon2id with appropriate parametersfrom argon2 import PasswordHasher
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
def register_user(username: str, password: str): hashed = ph.hash(password) db.save_user(username, hashed) # store only the hash
def login_user(username: str, password: str) -> bool: user = db.get_user(username) try: ph.verify(user.password_hash, password) if ph.check_needs_rehash(user.password_hash): # Rehash with updated parameters on successful login db.update_hash(username, ph.hash(password)) return True except: return False # timing-safe: always runs same code path
# ❌ Wrong approaches (all broken)password_stored = password # plaintextpassword_stored = hashlib.md5(password) # unsalted MD5 (rainbow table attack)password_stored = hashlib.sha256(password) # unsalted SHA (rainbow table attack)password_stored = base64.encode(password) # encoding is not encryptionCracking speed comparison (RTX 4090): MD5: 68 billion hashes/second SHA-256: 23 billion hashes/second bcrypt,12: 184 thousand hashes/second (3,000x slower than SHA-256) Argon2id: ~500 hashes/second (40,000x slower than SHA-256)
A 8-char password: MD5 cracked in <1 second; Argon2id = years