Why Password Security Still Matters
In 2024, the RockYou2024 compilation exposed nearly 10 billion unique plaintext passwords from decades of data breaches. The Snowflake breach chain compromised over 165 organizations because stolen credentials lacked multi-factor authentication. Despite years of industry efforts toward passwordless login, passwords remain the primary authentication method for the vast majority of web applications.
As a developer, the passwords your users trust you with are a liability. Store them correctly, and a database breach is an inconvenience. Store them wrong, and it becomes front-page news. The difference is entirely in how you handle three things: hashing, validation policies, and layered authentication.
This guide covers each one with working code examples. You can test password strength and generate secure passwords as you read using QTool's Password Strength Checker and Password Generator.
Password Hashing: bcrypt, Argon2, scrypt
A hash function takes an input of any length and produces a fixed-length output. Cryptographic hash functions are one-way: given the output, you cannot compute the input. Password hashing adds a critical requirement: the function must be intentionally slow.
Fast hash functions like MD5 and SHA-256 compute billions of hashes per second on modern GPUs. An attacker with a stolen database can try every common password in minutes. Password-specific hash functions are designed to be slow (hundreds of milliseconds per hash), making brute-force attacks computationally infeasible.
Algorithm Comparison
| Algorithm | Year | Memory-Hard | GPU Resistant | Recommendation |
|---|---|---|---|---|
| Argon2id | 2015 | Yes | Yes | Best choice for new projects |
| bcrypt | 1999 | No (4 KB) | Moderate | Excellent, widely supported |
| scrypt | 2009 | Yes | Yes | Good alternative to Argon2 |
| PBKDF2 | 2000 | No | No | Acceptable if nothing else available |
| SHA-256 | 2001 | No | No | Never use for passwords |
| MD5 | 1992 | No | No | Never use for passwords |
MD5, SHA-1, and SHA-256 were designed to be fast. A single RTX 4090 GPU computes ~8 billion MD5 hashes per second. That means every 8-character password using lowercase letters and digits (2.8 trillion combinations) can be cracked in under 6 minutes. Use bcrypt, Argon2id, or scrypt.
Argon2id
Argon2 won the Password Hashing Competition (PHC) in 2015 and is the OWASP-recommended algorithm. The id variant combines Argon2i (resistant to side-channel attacks) and Argon2d (resistant to GPU attacks). You configure three parameters independently:
- Memory — How much RAM the hash requires (e.g., 64 MB). Higher memory makes GPU attacks expensive because GPUs have limited per-core memory.
- Iterations (time cost) — How many passes over the memory. More iterations = slower hash.
- Parallelism — How many threads to use. Set to the number of cores available on your server for hashing.
OWASP's recommended minimum for Argon2id: 19 MiB memory, 2 iterations, 1 parallelism. For higher security: 64 MiB memory, 3 iterations, 4 parallelism. Tune until hashing takes 200-500ms on your hardware.
bcrypt
bcrypt has been the industry standard since 1999. It uses a single parameter called cost factor (also called work factor or rounds), expressed as a power of 2. A cost of 12 means 2^12 = 4,096 iterations. Each increment doubles the computation time.
In 2026, use a minimum cost factor of 12 (roughly 250ms on modern hardware). If your server can handle it, use 13 or 14. bcrypt has a 72-byte input limit — passwords longer than 72 bytes are silently truncated. If you need to support longer passwords, pre-hash with SHA-256 before passing to bcrypt.
scrypt
scrypt was designed specifically to be memory-hard. It takes three parameters: N (CPU/memory cost, must be a power of 2), r (block size), and p (parallelism). Recommended values: N=2^15, r=8, p=1, which uses about 32 MB of memory per hash.
Salting: What It Is and How It Works
A salt is a random value unique to each user, combined with their password before hashing. Without salting, two users with the same password produce the same hash — meaning an attacker who cracks one has cracked both.
// Without salt: identical passwords produce identical hashes
hash("password123") = "ef92b778..."
hash("password123") = "ef92b778..." // Same! Attacker cracks one, gets both.
// With unique salts: identical passwords produce different hashes
hash("a1b2c3d4" + "password123") = "7f2e9c3a..."
hash("x9y8z7w6" + "password123") = "1d4e5f6a..." // Different!
Salting defeats two attack strategies:
- Rainbow tables — Precomputed lookup tables mapping common passwords to hashes. With unique salts, every possible password would need a separate table for every salt, making precomputation infeasible.
- Batch cracking — Without salts, an attacker can hash a candidate password once and compare it against every hash in the database. With salts, each hash requires a separate computation.
Both bcrypt and Argon2 generate a cryptographically random salt and embed it in the output hash string. You do not need to generate or store salts separately. Just pass the password in and store the full output string.
Implementation: Hashing in Code
Node.js: bcrypt
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // 2^12 iterations, ~250ms
// Hash a password (at registration or password change)
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext, SALT_ROUNDS);
}
// Verify a password (at login)
async function verifyPassword(plaintext, storedHash) {
return bcrypt.compare(plaintext, storedHash);
}
// Usage
const hash = await hashPassword('my-secure-password');
// "$2b$12$LJ3m4ys3Lk0TSwHjZx.G5e8K7Yf2cVDq9xlmPJ6xUq4aExZnSCXi"
const isValid = await verifyPassword('my-secure-password', hash);
// true
Node.js: Argon2
import argon2 from 'argon2';
async function hashPassword(plaintext) {
return argon2.hash(plaintext, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterations
parallelism: 4
});
}
async function verifyPassword(plaintext, storedHash) {
return argon2.verify(storedHash, plaintext);
}
const hash = await hashPassword('my-secure-password');
// "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$..."
const isValid = await verifyPassword('my-secure-password', hash);
// true
Python: bcrypt and Argon2
import bcrypt
def hash_password(plaintext: str) -> str:
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(plaintext.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(plaintext: str, stored_hash: str) -> bool:
return bcrypt.checkpw(
plaintext.encode('utf-8'),
stored_hash.encode('utf-8')
)
from argon2 import PasswordHasher
ph = PasswordHasher(
memory_cost=65536, # 64 MiB
time_cost=3,
parallelism=4
)
def hash_password(plaintext: str) -> str:
return ph.hash(plaintext)
def verify_password(plaintext: str, stored_hash: str) -> bool:
try:
return ph.verify(stored_hash, plaintext)
except Exception:
return False
Go: bcrypt
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword(
[]byte(password),
12, // cost factor
)
return string(bytes), err
}
func verifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(hash),
[]byte(password),
)
return err == nil
}
func main() {
hash, _ := hashPassword("my-secure-password")
fmt.Println("Hash:", hash)
fmt.Println("Valid:", verifyPassword("my-secure-password", hash))
}
To quickly generate hash values for testing, use QTool's Hash Generator which supports MD5, SHA-1, SHA-256, SHA-512, and more — all computed client-side without sending your data anywhere.
Password Policies That Actually Work
NIST Special Publication 800-63B (Digital Identity Guidelines, updated 2024) fundamentally changed password policy recommendations. Many of the rules developers have enforced for decades are now explicitly discouraged.
What NIST Recommends
- Minimum 8 characters, but 15+ is strongly encouraged. Length is the single most important factor in password strength.
- Maximum at least 64 characters. Never set a low maximum. Let users use passphrases.
- Allow all characters. All printable ASCII, spaces, and Unicode (including emoji). Do not restrict special characters.
- Check against breached password lists. Reject any password that appears in known breaches (e.g., the HIBP database).
- Check against a blocklist. Block common passwords ("password", "123456"), context-specific terms (your site's name), and repetitive patterns ("aaaaaa", "abcabc").
What NIST Explicitly Discourages
- Composition rules. Do not require "at least one uppercase, one lowercase, one digit, one symbol." These rules produce predictable patterns:
Password1!satisfies every rule and is in every breach database. - Periodic password rotation. Do not force users to change passwords every 30/60/90 days. This leads to incremental changes (
Password1,Password2,Password3) that attackers predict trivially. - Password hints. Do not store or display password hints. They leak information.
- Security questions. Do not use them for account recovery. The answers are often publicly available or guessable.
A random 8-character password using all 95 printable ASCII characters has ~52 bits of entropy. A random 4-word passphrase from a 7,776-word list (diceware) has ~51 bits. But the passphrase is far easier to remember. Check the entropy of any password instantly with QTool's Password Strength Checker.
Checking Against Known Breaches
The Have I Been Pwned (HIBP) Passwords API lets you check if a password has appeared in any known data breach without ever transmitting the password or its full hash.
async function isPasswordBreached(password) {
// 1. SHA-1 hash the password
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-1', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
// 2. Send only the first 5 characters
const prefix = hashHex.slice(0, 5);
const suffix = hashHex.slice(5);
// 3. Get all matching hashes from HIBP
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const text = await response.text();
// 4. Check if our full hash suffix appears
const lines = text.split('\n');
for (const line of lines) {
const [hashSuffix, count] = line.split(':');
if (hashSuffix.trim() === suffix) {
return { breached: true, count: parseInt(count) };
}
}
return { breached: false, count: 0 };
}
// Usage
const result = await isPasswordBreached('password123');
// { breached: true, count: 247543 }
Integrate this check at two points: registration (block the password) and password change (block or warn). Some teams also check at login and prompt users to change compromised passwords, though this requires careful UX to avoid locking users out.
Two-Factor Authentication (2FA)
A strong password hash protects against database breaches. Two-factor authentication protects against stolen passwords — phishing, keyloggers, credential stuffing, and social engineering. Every application that handles sensitive data should offer 2FA.
TOTP (Time-Based One-Time Password)
TOTP is the most widely supported 2FA method. The user scans a QR code with an authenticator app (Google Authenticator, Authy, 1Password). The app generates a 6-digit code that changes every 30 seconds.
import { TOTP } from 'otpauth';
import QRCode from 'qrcode';
// Generate a secret for the user (at 2FA enrollment)
function createTOTPSecret(userEmail) {
const totp = new TOTP({
issuer: 'YourApp',
label: userEmail,
algorithm: 'SHA1',
digits: 6,
period: 30
});
return {
secret: totp.secret.base32, // Store encrypted in DB
uri: totp.toString(), // For QR code
qrCode: QRCode.toDataURL(totp.toString()) // QR image
};
}
// Validate a code (at login)
function verifyTOTP(secret, userCode) {
const totp = new TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
algorithm: 'SHA1',
digits: 6,
period: 30
});
// Allow 1 step of drift (previous + current + next period)
const delta = totp.validate({ token: userCode, window: 1 });
return delta !== null; // null means invalid
}
Backup Codes
Always generate 8-10 single-use backup codes during 2FA enrollment. Store them hashed (bcrypt) in the database. When a user enters a backup code, verify it, then mark it as used. Display them once during setup and instruct the user to store them securely. This is the recovery path when a user loses their authenticator device.
WebAuthn / Security Keys
WebAuthn (FIDO2) provides the strongest second factor: hardware security keys (YubiKey, Google Titan) or platform authenticators (Touch ID, Windows Hello). WebAuthn is phishing-resistant by design because the browser verifies the origin domain before signing the challenge.
SIM swapping attacks are well-documented and have compromised high-value accounts. Offer TOTP or WebAuthn as the primary 2FA method. SMS is still better than no 2FA at all, but it should not be the only option for security-sensitive applications.
Passkeys: The Future of Authentication
Passkeys are a passwordless authentication standard built on WebAuthn/FIDO2. Instead of typing a password, users authenticate with their device's biometric sensor, a PIN, or a security key. The private key never leaves the device (or is synced via the platform's encrypted cloud keychain).
How Passkeys Work
- Registration: The server sends a challenge. The browser creates a public/private key pair. The private key is stored on the device. The public key is sent to the server.
- Authentication: The server sends a challenge. The browser signs it with the private key (after biometric/PIN verification). The server verifies the signature with the stored public key.
Platform Support (February 2026)
- Apple: iCloud Keychain syncs passkeys across all Apple devices. Safari, Chrome, Firefox on macOS/iOS.
- Google: Google Password Manager syncs passkeys across Android devices and Chrome on all platforms.
- Microsoft: Windows Hello stores passkeys locally. Cross-device sync via Microsoft Account.
- Third-party: 1Password, Dashlane, and Bitwarden support passkey storage and sync across all platforms.
import {
generateRegistrationOptions,
verifyRegistrationResponse
} from '@simplewebauthn/server';
// Server: Generate registration options
const options = await generateRegistrationOptions({
rpName: 'Your App',
rpID: 'yourapp.com',
userName: user.email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
}
});
// Client: navigator.credentials.create(options)
// Server: Verify the response
const verification = await verifyRegistrationResponse({
response: clientResponse,
expectedChallenge: options.challenge,
expectedOrigin: 'https://yourapp.com',
expectedRPID: 'yourapp.com'
});
if (verification.verified) {
// Store verification.registrationInfo in your database
}
Generate secure test passwords while implementing these features with QTool's Password Generator, which creates cryptographically random passwords of any length and character set.
Secure Storage and Transmission
In Transit
- Always use HTTPS. Passwords must never travel over unencrypted HTTP. Use HSTS headers to enforce HTTPS.
- Hash on the server, not the client. Client-side hashing does not replace server-side hashing. If you hash on the client, the hash becomes the password — an attacker who steals the hash can replay it.
- Rate-limit login attempts. After 5-10 failed attempts, enforce a delay (exponential backoff) or temporary lockout. This blocks brute-force attacks against live endpoints.
At Rest
- Store only the hash. Never store plaintext passwords, encrypted passwords (encryption is reversible), or unsalted hashes.
- Encrypt the hash column. Defense in depth: even the hashes should be encrypted at the database level (AES-256). If the DB file is stolen but the encryption key is not, the hashes are unusable.
- Isolate the auth database. Separate the authentication database from the application database. Apply stricter access controls, monitoring, and backups.
- Log authentication events, not credentials. Log login attempts (success/failure, IP, timestamp) but never log the password or hash.
Test Your Password Strength
QTool's Password Strength Checker estimates crack time, calculates entropy, and checks against breach databases. Entirely client-side — your password never leaves your browser.
Check Password Strength Generate PasswordsSecurity Tools
Frequently Asked Questions
Argon2id is the recommended choice for new projects in 2026. It won the Password Hashing Competition in 2015 and has been the OWASP recommendation since 2023. Argon2id is memory-hard, making it resistant to both GPU and ASIC attacks, and it lets you configure memory usage, parallelism, and iteration count independently. However, bcrypt remains a solid choice if your platform does not have a well-tested Argon2 library. bcrypt has been battle-tested for over 25 years and is supported everywhere. The most important thing is to use any of the recommended algorithms (Argon2id, bcrypt, or scrypt) rather than MD5, SHA-1, SHA-256, or any unsalted/fast hash.
A salt is a random string that is appended or prepended to a password before hashing. Each user gets a unique salt, which is stored alongside the hash in the database. Salting prevents two users with the same password from having the same hash (defeating rainbow table attacks), and it makes precomputed hash tables useless. Without salting, an attacker who obtains your database can look up common password hashes in a rainbow table and crack millions of passwords instantly. With unique salts, each password must be attacked individually. Modern hashing functions like bcrypt and Argon2 generate and embed the salt automatically, so you do not need to manage salts manually.
NIST SP 800-63B recommends: minimum 8 characters (15+ is better), maximum at least 64 characters, allow all printable ASCII characters plus Unicode, check against a list of known breached passwords (like the Have I Been Pwned database), do NOT require specific character classes (uppercase, lowercase, digit, symbol), and do NOT force periodic password changes. The reasoning is that complexity rules lead users to create predictable patterns like "Password1!" while a long passphrase like "correct horse battery staple" is both stronger and easier to remember.
Implement TOTP (Time-based One-Time Password) as the baseline 2FA method. Use a library that generates a shared secret, creates a QR code for authenticator apps, and validates 6-digit codes with a time window of plus or minus one step (30 seconds). Store the shared secret encrypted in your database. Always provide backup codes (8-10 single-use codes) during setup. For higher security, support WebAuthn/FIDO2 security keys. Avoid SMS-based 2FA as the primary option because SIM swapping attacks are well-documented, though SMS is still better than no 2FA at all.
Passkeys are a passwordless authentication standard built on WebAuthn/FIDO2. Instead of a password, users authenticate with their device's biometric sensor (fingerprint, face), a PIN, or a security key. The cryptographic key pair is stored on the user's device (or synced via their platform's cloud keychain), and only the public key is stored on the server. Passkeys are phishing-resistant by design because the browser verifies the domain. As of 2026, passkeys are supported by Apple, Google, Microsoft, and all major browsers. You should support passkeys alongside traditional passwords.
Use the Have I Been Pwned (HIBP) Passwords API with k-anonymity. Hash the password with SHA-1, send only the first 5 characters of the hash to the API, and receive a list of matching hash suffixes. Check if the full hash appears in the response. This approach never sends the actual password or the full hash to any external service. The API is free and returns the number of times each password has appeared in known breaches. Block any password that has appeared in more than 0 breaches, or at minimum warn the user and require confirmation.