Skip to content

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.


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 hour
Terminal window
# Dictionary words are cracked in seconds
hashcat -a 0 -m 3200 hash.txt rockyou.txt # bcrypt crack attempt
# Common patterns cracked with rules
hashcat -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 crack

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 access

How 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 key
Bitwarden 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)

KDFs deliberately slow down password hashing to make brute-force attacks expensive.

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 Python
import hashlib, os, base64
password = b"MyMasterPassword"
salt = os.urandom(32) # 256-bit random salt
iterations = 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 (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 Python
pip install argon2-cffi
from argon2 import PasswordHasher
from 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 password
hash = ph.hash("MyMasterPassword")
print(hash) # $argon2id$v=19$m=19456,t=2,p=1$...
# Verify a password
try:
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 params
Terminal window
# bcrypt - designed in 1999; still widely used; built-in memory hardness
# Cost factor: each increment doubles the work
# Python: hash and verify with bcrypt
pip 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}")
# Verify
if 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)

Terminal window
# Check if an email was in a breach
curl -s "https://haveibeenpwned.com/api/v3/breachedaccount/[email protected]" \
-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 password
HASH=$(echo -n "password123" | sha1sum | tr '[:lower:]' '[:upper:]' | awk '{print $1}')
PREFIX=${HASH:0:5} # first 5 chars
SUFFIX=${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 it
import 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")

ManagerTypeArchitectureNotable
BitwardenOpen source + cloudZero-knowledge; self-hostable; auditedBest in class for most; free tier generous
1PasswordCommercialZero-knowledge + Secret Key; no self-hostStrong teams/enterprise features
KeePassXCOpen source, localLocal encrypted file; no cloud syncMaximum control; manual sync required
DashlaneCommercialZero-knowledge; built-in dark web monitoringGood UX; expensive at enterprise scale
Apple KeychainPlatform-nativeiCloud sync; end-to-end encryptedGood for Mac/iOS ecosystem
VaultwardenSelf-hostedBitwarden-compatible APISelf-host for teams; full Bitwarden client compatible

✅ 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 Relay
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 files

NIST 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 adviceNIST 800-63B guidance
Mandatory rotation every 90 daysOnly rotate on evidence of compromise
Complexity rules (upper/lower/number/symbol)Length is more important than complexity
Security questionsDo NOT use knowledge-based authentication
SMS OTPAcceptable but discouraged (SIM-swap risk); prefer TOTP or hardware key
Block common passwordsCheck against known-bad lists (HIBP, common word lists)
Terminal window
# 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 list
echo -n "password" | sha1sum | awk '{print toupper($1)}' | \
grep -c -f - pwned-passwords-sorted.txt
# Returns: 1 if found (compromised); 0 if not found

How applications should store user passwords in their database:

# ✅ Correct: Argon2id with appropriate parameters
from 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 # plaintext
password_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 encryption
Cracking 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