Loading learning content...
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.
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:
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.
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:
Argon2id should be your default choice for new systems. It provides:
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.
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:
Proper Salt Implementation:
Correct salting follows these principles:
What Salts Are NOT:
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.
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.
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:
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).
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:
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;}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:
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.
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.
Distributed Brute Force Defense:
Sophisticated attackers distribute attempts across many IPs to evade per-IP rate limiting. Defense strategies include:
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.
Password authentication, despite its limitations, remains the reality for most systems. Implementing it correctly significantly reduces risk. Let's consolidate the essential practices:
What's Next:
Even perfectly implemented password authentication has fundamental limitations—passwords can be phished, reused, and socially engineered. The next page explores Multi-Factor Authentication (MFA), which combines passwords with additional factors to dramatically increase security. We'll cover TOTP, push notifications, hardware keys, and how to implement MFA in ways that users will actually adopt.
You now understand how to implement password authentication securely—from cryptographic hashing with Argon2 to NIST-compliant policies to defending against online attacks. This knowledge enables you to avoid the common mistakes that lead to password breaches while preparing for the transition to stronger authentication methods.