Authentication Patterns in Distributed Systems
Password-Based Authentication
Read aloud is unavailable in this browser.
The Ubiquitous Secret
Despite decades of security research, countless breaches, and the emergence of more sophisticated alternatives, passwords remain the dominant authentication mechanism on the internet. Over 80% of web applications still rely on passwords as their primary or sole authentication factor. This prevalence persists not because passwords are secure—they demonstrably aren't—but because they're understood by users, cheap to implement, and require no special hardware.
This creates a paradox for system designers: you must implement password authentication correctly, even while recognizing its fundamental limitations. The difference between a well-implemented and poorly-implemented password system is often the difference between a minor inconvenience and a catastrophic breach affecting millions of users.
The history of authentication breaches is largely a history of password failures—plaintext storage, weak hashing, insufficient salting, and predictable reset mechanisms. Understanding these failures is essential for avoiding them.
This page will teach you how to implement password authentication securely, covering cryptographic hashing algorithms, salting strategies, password policies based on NIST guidelines, breach detection integration, secure reset flows, and architectural patterns for password systems. You'll also understand why passwords persist despite their weaknesses and when to supplement or replace them.
The Anatomy of Password Authentication
Password-based authentication is deceptively simple on the surface: a user provides a secret, the system verifies it matches a stored reference, and authentication succeeds or fails. But every step of this process carries security implications that determine whether the system resists or falls to attack.
The Password Flow:
- •Registration — User chooses a password; system validates against policies; password is hashed with a unique salt; hash+salt are stored in the database.
- •Transmission — User submits password over HTTPS; password arrives at server in plaintext (encrypted in transit, decrypted on arrival).
- •Verification — Server retrieves the stored salt for this user; hashes the submitted password with the same salt; compares result to stored hash.
- •Decision — If hashes match, return success and issue session token. If not, return generic failure (don't reveal whether username or password was wrong).
- •Logging — Record the authentication attempt (success or failure) for security monitoring, being careful not to log the password itself.
This should never need to be stated, yet breaches continue to reveal plaintext passwords. In 2019, Facebook disclosed that hundreds of millions of passwords had been stored in plaintext in internal logs. If your system stores passwords in any form other than properly hashed, you are creating a ticking time bomb.
Why Hashing Instead of Encryption?
A common question from developers new to security: why hash passwords instead of encrypting them? The answer lies in the fundamental difference between these operations:
-
Encryption is reversible—with the right key, you can recover the original data. This means if an attacker obtains the encryption key and the database, they can recover all passwords.
-
Hashing is a one-way function—you cannot recover the original password from the hash. Even if attackers steal your entire database, they cannot directly extract passwords; they must attempt to reverse the hashes through brute force or dictionary attacks.
The key insight: a legitimate authentication system never needs to recover the original password. It only needs to verify that a submitted password produces the same hash as the stored one. Hashing provides this capability without the risk exposure of reversible encryption.
Cryptographic Hashing for Passwords
Not all hash functions are suitable for passwords. General-purpose cryptographic hashes like SHA-256, while secure for data integrity, are intentionally fast—a property that works against you in password storage. An attacker with a stolen database can compute billions of SHA-256 hashes per second using commodity hardware or cloud GPUs.
Password hashes must be intentionally slow to make brute-force attacks computationally infeasible. This is achieved through Password Hashing Functions (PHFs) designed specifically for credential storage.
| Algorithm | Status | Key Properties | Recommended? |
|---|---|---|---|
| Argon2id | State of the art | Memory-hard, GPU-resistant, tunable, winner of Password Hashing Competition (2015) | ✅ Preferred choice for new systems |
| bcrypt | Mature | CPU-hard, salt integrated, 72-byte password limit, widely supported | ✅ Excellent when Argon2 unavailable |
| scrypt | Mature | Memory-hard, GPU-resistant, complex to tune correctly | ✅ Good alternative to Argon2 |
| PBKDF2 | Dated | CPU-hard, NIST-approved, GPU-parallelizable | ⚠️ Acceptable with high iteration count, prefer Argon2/bcrypt |
| SHA-256/512 | General purpose | Fast, not designed for passwords | ❌ Never use alone for passwords |
| MD5 | Broken | Collision vulnerabilities, extremely fast | ❌ Never use for any security purpose |
| SHA-1 | Deprecated | Collision attacks demonstrated, fast | ❌ Never use for new systems |
Argon2: The Modern Standard
Argon2 won the Password Hashing Competition in 2015 and represents the current state of the art. It comes in three variants:
- Argon2d — Data-dependent memory access for maximum GPU resistance; vulnerable to side-channel attacks
- Argon2i — Data-independent memory access for side-channel resistance; slightly less GPU resistance
- Argon2id — Hybrid approach combining benefits of both; recommended for password storage
Argon2id should be your default choice for new systems. It provides:
- Memory hardness — Requires significant RAM per hash, making parallel GPU attacks expensive
- CPU hardness — Configurable time cost for each hash
- Parallelism — Tunable parallel lanes for multi-core utilization
- Salt integration — Automatic salt handling
- Domain separation — Prevents precomputed attacks across applications
12345678910111213141516171819202122232425262728293031
import argon2 from 'argon2'; // Recommended parameters for 2024+const ARGON2_OPTIONS = { type: argon2.argon2id, // Use the hybrid variant memoryCost: 65536, // 64 MB RAM per hash timeCost: 3, // 3 iterations parallelism: 4, // 4 parallel threads hashLength: 32, // 256-bit output}; // Hash a password for storageasync function hashPassword(plaintext: string): Promise<string> { return argon2.hash(plaintext, ARGON2_OPTIONS); // Returns: $argon2id$v=19$m=65536,t=3,p=4$salt$hash} // Verify a password during loginasync function verifyPassword(plaintext: string, hash: string): Promise<boolean> { try { return await argon2.verify(hash, plaintext); } catch (error) { // Hash format invalid or other error return false; }} // Check if rehashing is needed (parameters have been upgraded)function needsRehash(hash: string): boolean { return argon2.needsRehash(hash, ARGON2_OPTIONS);}Argon2 parameters should be tuned for your hardware. The goal is hashing that takes 100-500ms on your server. Too fast makes brute force easier; too slow impacts user experience. Run benchmarks on production-equivalent hardware and adjust memoryCost and timeCost accordingly.
Salting: Why and How
A salt is a random value added to each password before hashing. Without salting, two users with the same password would have the same hash—a catastrophic vulnerability that enables several attack classes.
Why Salts Are Essential:
- •Rainbow Tables — Precomputed hash-to-password lookup tables. Without salts, attackers precompute hashes for millions of common passwords, then instantly look up any hash. Salts make rainbow tables useless because each salt requires a separate table.
- •Batch Attacks — Without salts, an attacker can test one password guess against ALL users simultaneously by hashing once and comparing to all stored hashes. With salts, each guess must be hashed separately for each user.
- •Password Reuse Detection — Without salts, identical hashes reveal users with the same password, providing valuable intelligence to attackers. Salts make every hash unique even for identical passwords.
- •Pattern Analysis — Without salts, attackers can identify common passwords by hash frequency. Popular passwords appear more often. Salts eliminate this statistical signal.
Proper Salt Implementation:
Correct salting follows these principles:
- Uniqueness — Every password gets its own unique salt, generated fresh at registration time
- Randomness — Salts must be cryptographically random, not derived from user data (no username-based salts)
- Length — At least 128 bits (16 bytes) of random data; 256 bits is better
- Storage — The salt is stored alongside the hash (not secret); Argon2/bcrypt embed it in the hash string
- Never Reuse — Never use the same salt for multiple passwords or regenerate on password change
What Salts Are NOT:
- Salts are not secret. They're stored with the hash and are visible to attackers with database access.
- Salts don't replace strong hashing. A weak hash with a salt is still weak.
- Global salts (sometimes called 'pepper') are different—a secret value stored separately that adds another layer.
A pepper is a secret value added to passwords before hashing, stored separately from the database (e.g., in an HSM or environment variable). If attackers steal only the database, they can't crack hashes without the pepper. However, pepper adds operational complexity—if lost, all passwords become permanently unverifiable. Consider pepper if you can manage it securely.
Modern Hashing Libraries Handle Salts Automatically:
With Argon2 and bcrypt, you don't manually manage salts. The hash output includes the salt:
$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$hashvalue
^^^^^^^^^^^^^^
Salt (base64)
The library extracts the salt during verification. This design eliminates a common implementation error: forgetting to store or retrieve the salt separately.
Password Policies and NIST Guidelines
Traditional password policies—requiring uppercase, lowercase, numbers, special characters, and frequent rotation—have been thoroughly debunked by security research. These requirements lead to predictable patterns ('Password1!'), written-down passwords, and user frustration without corresponding security benefits.
The NIST Special Publication 800-63B (Digital Identity Guidelines) fundamentally revised password recommendations in 2017, based on empirical research about user behavior and password security. These guidelines represent the current best practice.
- •Mandatory complexity requirements (uppercase, special chars)
- •Periodic password expiration (every 90 days)
- •Security questions ('mother's maiden name')
- •Password hints stored with the account
- •SMS-based password reset as sole recovery option
- •Allowing extremely short passwords (< 8 chars)
- •Truncating or modifying user-submitted passwords
- •Minimum 8 characters; encourage passphrases to 64+
- •Check against breached password databases
- •Check against commonly used password lists
- •No arbitrary composition rules
- •No expiration without evidence of compromise
- •Allow all printable ASCII and Unicode characters
- •Offer paste functionality (for password managers)
The Length > Complexity Shift:
NIST's core insight is that password length matters far more than complexity. A 16-character passphrase like 'correct horse battery staple' is both more secure and more memorable than 'P@ssw0rd!'.
The mathematics are straightforward:
- An 8-character password using 95 printable ASCII characters: 95^8 = ~6.6 × 10^15 possibilities
- A 16-character password using only lowercase letters: 26^16 = ~4.4 × 10^22 possibilities
The longer password, despite using a smaller character set, has over six million times more possibilities. Users remember it easier, don't write it down, and the system is more secure.
Use libraries like zxcvbn (by Dropbox) to estimate password strength realistically. Unlike naive complexity checks, zxcvbn understands patterns, dictionary words, and common substitutions. It can reject 'P@ssw0rd!' while accepting 'correct horse battery staple'—exactly the behavior you want.
Breached Password Detection:
One of NIST's most impactful recommendations is checking passwords against known breached password databases. If a password appears in a public breach, it's already in attacker dictionaries and should be rejected regardless of length or complexity.
Implementation options:
-
Have I Been Pwned API — Troy Hunt's free service offers a k-anonymity API where you send only the first 5 characters of the password's SHA-1 hash, preserving privacy while checking against 600+ million breached passwords.
-
Local Database — Download the HIBP password list and check locally for maximum privacy (but requires regular updates).
-
Commercial Services — Various identity providers offer breached credential checking as part of their authentication services.
Integrate breach checking at both registration (reject breached passwords) and login (prompt users with breached passwords to change them).
Secure Password Reset Flows
Password reset is often the weakest link in authentication systems. A flawed reset mechanism effectively bypasses all your password security. Attackers know this and frequently target password reset rather than password login.
The Secure Reset Flow:
- •Generic Response — Always respond 'If this email exists, we sent a reset link.' Never confirm whether an account exists—this prevents account enumeration attacks.
- •Cryptographically Random Token — Generate a 256-bit (32-byte) cryptographically random token. Do not use sequential IDs, UUIDs (insufficiently random), or time-based tokens.
- •Token Storage — Store a hash of the token (SHA-256 is fine here), not the token itself. If the database is breached, attackers can't use raw tokens.
- •Short Expiration — Tokens should expire in 1 hour maximum; 15-30 minutes is better. Shorter windows limit the attack surface.
- •Single Use — Invalidate the token immediately after successful use. Also invalidate all tokens for a user when they successfully reset (prevents token stockpiling).
- •Rate Limiting — Limit reset requests per email/IP to prevent abuse (e.g., 3 requests per hour per email). This prevents mailbox flooding attacks.
- •Secure Link Transmission — Send reset links over HTTPS only. Consider email security—remember that email is not encrypted in transit by default.
- •Require Old Password When Possible — If the user is authenticated when changing their password, require the current password to prevent session hijacking from leading to password change.
Security questions ('What was your first pet's name?') are fundamentally broken. Answers are often guessable, researchable on social media, or leaked in other breaches. They provide a false sense of security while creating a weak backdoor. NIST explicitly recommends against them. Use alternatives like authenticator-app-based recovery, backup codes, or verified secondary email.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
import crypto from 'crypto'; interface ResetToken { token: string; // Sent to user tokenHash: string; // Stored in database expiresAt: Date; // Expiration timestamp} function generateResetToken(): ResetToken { // Generate 256-bit cryptographically random token const token = crypto.randomBytes(32).toString('base64url'); // Store hash of token (not the token itself) const tokenHash = crypto .createHash('sha256') .update(token) .digest('hex'); // Short expiration window const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes return { token, tokenHash, expiresAt };} async function verifyResetToken(submittedToken: string): Promise<boolean> { // Hash the submitted token const tokenHash = crypto .createHash('sha256') .update(submittedToken) .digest('hex'); // Look up in database const resetRequest = await db.resetTokens.findOne({ tokenHash, expiresAt: { $gt: new Date() }, used: false }); if (!resetRequest) return false; // Mark as used immediately (single use) await db.resetTokens.updateOne( { tokenHash }, { $set: { used: true, usedAt: new Date() } } ); return true;}Implementation Patterns and Architecture
Beyond individual security measures, password authentication requires careful architectural design. The following patterns address common implementation challenges in production systems.
| Pattern | Description | When to Use |
|---|---|---|
| Hash Migration | Rehash passwords with stronger algorithm upon successful login | When upgrading from legacy hashing (MD5/SHA1 to Argon2) |
| Parameter Upgrade | Check if stored hash uses old parameters; rehash with new on login | When increasing Argon2 memory/time parameters |
| Centralized Auth Service | Dedicated microservice handles all password operations | Microservices architecture; prevents password logic duplication |
| Password Vault | Store hashes in a separate, more secure database | High-security environments; limits breach exposure |
| HSM Integration | Hardware Security Module performs hashing operations | Financial/healthcare requiring hardware-backed security |
| Timing-Safe Comparison | Constant-time hash comparison to prevent timing attacks | All password systems |
Hash Migration Strategy:
When you've inherited a system with weak password hashing or need to upgrade parameters, you cannot simply rehash existing passwords—you don't have the plaintexts. The solution is opportunistic migration:
- Store a version/algorithm indicator with each hash
- On successful login (when you have the plaintext), check if the hash uses the old algorithm
- If so, rehash with the new algorithm and update storage
- Over time, most active users migrate automatically
- For inactive accounts, consider forcing password reset at next login
This pattern allows gradual migration without a disruptive forced-reset for all users.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
async function authenticateUser( email: string, plaintext: string): Promise<AuthResult> { const user = await db.users.findByEmail(email); if (!user) { return { success: false, reason: 'invalid_credentials' }; } let isValid = false; // Check which algorithm was used if (user.passwordHash.startsWith('$argon2id$')) { // Modern Argon2 hash isValid = await argon2.verify(user.passwordHash, plaintext); // Check if parameters need upgrade if (isValid && argon2.needsRehash(user.passwordHash, ARGON2_OPTIONS)) { const newHash = await argon2.hash(plaintext, ARGON2_OPTIONS); await db.users.updatePasswordHash(user.id, newHash); } } else if (user.passwordHash.startsWith('$2b$') || user.passwordHash.startsWith('$2a$')) { // Legacy bcrypt hash - migrate to Argon2 isValid = await bcrypt.compare(plaintext, user.passwordHash); if (isValid) { const newHash = await argon2.hash(plaintext, ARGON2_OPTIONS); await db.users.updatePasswordHash(user.id, newHash); console.log(`Migrated user ${user.id} from bcrypt to argon2id`); } } else { // Unknown hash format - force reset return { success: false, reason: 'password_reset_required', message: 'Please reset your password' }; } if (!isValid) { return { success: false, reason: 'invalid_credentials' }; } return { success: true, user };}Naive string comparison returns false at the first mismatched character. An attacker can measure response times to guess passwords character by character. Use constant-time comparison functions (crypto.timingSafeEqual in Node.js) for hash comparison. Most password hashing libraries handle this internally, but verify your implementation.
Defending Against Password Attacks
Password systems face continuous attack. Beyond proper hashing and storage, you need active defenses against online attacks where attackers attempt to guess passwords through your login endpoint.
- •Rate Limiting — Limit login attempts per account (e.g., 5 failures, then 15-minute lockout) and per IP (e.g., 20 attempts per minute). Use exponential backoff for repeated failures.
- •Account Lockout — After N consecutive failures, temporarily lock the account. Use temporary lockouts (15-60 minutes) rather than permanent to prevent denial-of-service.
- •CAPTCHA on Failure — After 3+ failed attempts, require CAPTCHA to continue. Modern CAPTCHAs (reCAPTCHA v3) can be invisible to legitimate users while blocking bots.
- •Device Fingerprinting — Track device characteristics (browser, OS, screen resolution). Unknown devices can require additional verification even with correct password.
- •Anomaly Detection — Flag unusual login patterns: impossible travel (login from different countries minutes apart), unusual hours, burst of failures across accounts.
- •IP Reputation — Integrate IP reputation services to identify known malicious IPs, Tor exit nodes, or VPN providers commonly used in attacks.
- •Credential Stuffing Detection — Look for patterns indicating automation: rapid sequential attempts, attempts across many accounts from one IP, consistent timing between requests.
Distributed Brute Force Defense:
Sophisticated attackers distribute attempts across many IPs to evade per-IP rate limiting. Defense strategies include:
- Global rate limiting per account — Even if attacks come from 1000 IPs, the target account still hits its failure limit
- Behavioral analysis — Automated attempts have detectable patterns (timing, User-Agent consistency, failure rate)
- Progressive CAPTCHA — Increase CAPTCHA difficulty with each failure, regardless of IP
- Device trust scoring — New devices require additional verification
- MFA enforcement — Multi-factor authentication renders most password attacks ineffective
Never reveal whether a username exists through different error messages ('Invalid username' vs 'Invalid password') or response times. Use identical generic responses ('Invalid credentials') and ensure response time is constant whether the account exists or not. Attackers use enumeration to build targeted attack lists.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.