Password Security Best Practices Cheat Sheet
Summarizes modern password hygiene, hashing standards, and multi-factor authentication practices for individuals and engineering teams.
Best Practices for Users
Habits that meaningfully reduce account compromise risk.
- Use a password manager- Generate and store unique, random passwords per site (e.g. Bitwarden, 1Password)
- Length over complexity- Prefer long passphrases (14+ characters) over short complex strings that are hard to remember
- Unique per site- Never reuse a password across multiple accounts to limit credential-stuffing blast radius
- Enable MFA everywhere- Add a second factor (authenticator app, hardware key) on top of the password
- Avoid personal info- Don't use names, birthdays, or dictionary words that appear in guessing wordlists
- Rotate only on compromise- NIST no longer recommends periodic forced rotation; rotate when a breach is suspected
Password Hashing (Node.js)
Correctly hash and verify passwords server-side using bcrypt.
const bcrypt = require('bcrypt');// Hashing: cost factor (salt rounds) controls work factor, 12 is a common defaultasync function hashPassword(plain) { const saltRounds = 12; return await bcrypt.hash(plain, saltRounds); // salt is generated and embedded automatically}// Verifyingasync function verifyPassword(plain, hash) { return await bcrypt.compare(plain, hash); // true/false, timing-safe comparison}// Never store plaintext or use fast hashes like MD5/SHA1 for passwords
NIST-Aligned Password Policy
Configuration reference implementing NIST SP 800-63B guidance.
password_policy: min_length: 12 # NIST recommends 8 minimum, 12+ for stronger accounts max_length: 64 # allow long passphrases, don't artificially cap require_uppercase: false # composition rules add little value per NIST require_special_char: false check_against_breach_list: true # reject passwords found in known breach corpora (e.g. HaveIBeenPwned) block_common_passwords: true # reject top-10k common password list allow_paste: true # supports password managers lockout_threshold: 10 # account lockout after N failed attempts mfa_required: true
Checking Breach Exposure (k-Anonymity API)
Use the Have I Been Pwned Pwned Passwords API without exposing the full password.
# SHA-1 hash the password, send only the first 5 hex chars of the hashpassword="CorrectHorseBatteryStaple"hash=$(echo -n "$password" | sha1sum | tr 'a-z' 'A-Z' | cut -c1-40)prefix=${hash:0:5}suffix=${hash:5}curl -s "https://api.pwnedpasswords.com/range/$prefix" | grep -i "$suffix"# If a match with a count > 0 is returned, the password has appeared in a known breach
MFA Methods Ranked by Strength
Relative phishing resistance of common second-factor options.
- Hardware security key (FIDO2/WebAuthn)- Strongest; cryptographically bound to the origin, resists phishing
- Authenticator app (TOTP)- Good; time-based codes, but can be phished via real-time relay attacks
- Push notification- Convenient but vulnerable to MFA fatigue/prompt-bombing attacks
- SMS/voice OTP- Weakest; vulnerable to SIM-swapping and SS7 interception, avoid for sensitive accounts
Argon2id Hashing (Modern Recommended KDF)
Argon2id is the OWASP-recommended default over bcrypt for new systems due to tunable memory-hardness against GPU/ASIC cracking.
const argon2 = require('argon2');async function hashPassword(plain) { return await argon2.hash(plain, { type: argon2.argon2id, // hybrid resists both side-channel and GPU attacks memoryCost: 19456, // ~19 MiB, OWASP 2023 minimum recommendation timeCost: 2, // iterations parallelism: 1 });}async function verifyPassword(plain, hash) { try { return await argon2.verify(hash, plain); // handles embedded params/salt automatically } catch { return false; // malformed hash, never throw to caller }}// argon2.needsRehash-style check: if stored params are weaker than current// policy, rehash on next successful login instead of forcing a reset
Passwordless Registration with WebAuthn/Passkeys
Server-side ceremony for registering a FIDO2 credential, eliminating the shared secret entirely.
const { generateRegistrationOptions, verifyRegistrationResponse } = require('@simplewebauthn/server');// Step 1: server issues a challenge bound to the user and originasync function startRegistration(user) { const options = await generateRegistrationOptions({ rpName: 'Example App', rpID: 'example.com', userID: user.id, userName: user.email, attestationType: 'none', authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' } }); await saveChallenge(user.id, options.challenge); // store server-side, single use return options;}// Step 2: verify the browser's response and persist the public key credentialasync function finishRegistration(user, credentialResponse) { const expectedChallenge = await getChallenge(user.id); const verification = await verifyRegistrationResponse({ response: credentialResponse, expectedChallenge, expectedOrigin: 'https://example.com', expectedRPID: 'example.com' }); if (verification.verified) { await saveCredential(user.id, verification.registrationInfo); // store publicKey + counter, never a secret } return verification.verified;}
Attack Techniques Beyond Simple Guessing
Patterns to recognize and mitigate at the authentication layer, not just at password creation time.
- Credential stuffing- Attackers replay username/password pairs leaked from other breaches; mitigate with breach-list checks and device/IP risk scoring
- Password spraying- A small set of common passwords tried against many accounts to stay under per-account lockout thresholds; detect via cross-account failure correlation
- MFA fatigue / prompt bombing- Repeated push notifications sent hoping for an accidental approval; mitigate with number-matching push challenges
- Session fixation after login- Attacker pre-sets a session ID before authentication; always regenerate the session ID on privilege change
- Credential harvesting via lookalike domains- Phishing pages that proxy real login flows in real time; FIDO2 origin-binding is the only fully effective defense
- Rainbow table attacks- Precomputed hash lookup tables; defeated by per-password random salts, which Argon2/bcrypt embed automatically
Adaptive Rate Limiting with Exponential Backoff
Throttle authentication attempts progressively instead of a hard lockout, which itself enables denial-of-service against victims.
import timeimport redisr = redis.Redis()def check_and_record_attempt(username, ip): key_user = f"failcount:user:{username}" key_ip = f"failcount:ip:{ip}" fails_user = int(r.get(key_user) or 0) fails_ip = int(r.get(key_ip) or 0) # Exponential backoff delay, capped, applied server-side before responding delay = min(2 ** fails_user, 30) time.sleep(delay) if fails_user >= 10 or fails_ip >= 50: # Soft-lock: require MFA/step-up or CAPTCHA rather than blocking outright return "CHALLENGE_REQUIRED" return "OK"def record_failure(username, ip): pipe = r.pipeline() pipe.incr(f"failcount:user:{username}") pipe.expire(f"failcount:user:{username}", 900) # 15 min sliding window pipe.incr(f"failcount:ip:{ip}") pipe.expire(f"failcount:ip:{ip}", 900) pipe.execute()
Constant-Time Comparison & Secure Token Generation
Avoid timing side-channels when comparing secrets like API keys or reset tokens outside of a hashing library.
import hmacimport secretsdef generate_reset_token(): # Cryptographically secure, URL-safe, 32 bytes of entropy return secrets.token_urlsafe(32)def tokens_match(provided, expected): # hmac.compare_digest runs in constant time regardless of where # the strings first differ, preventing character-by-character # timing attacks that == would be vulnerable to return hmac.compare_digest(provided, expected)# Never compare secrets with `==`, `in`, or string startswith() checks
When migrating from an older hash format (MD5/SHA1), rehash transparently on the user's next successful login rather than forcing a mass password reset — verify against the old hash, then immediately store a new bcrypt/argon2 hash.