Password Hashing Best Practices: bcrypt, Argon2id, and scrypt
Quick answer
💡OWASP recommends Argon2id with m=19456 KiB, t=2 iterations, p=1 parallelism as the default choice for new applications. If Argon2id is unavailable, use bcrypt with a minimum cost factor of 10 (cost 12 recommended). Never use MD5, SHA-1, SHA-256, or any unsalted hash for passwords — these algorithms are too fast for password storage and are trivially cracked with GPU-based dictionary attacks. Each password must have a unique random salt, which bcrypt and Argon2 generate automatically.
Error symptoms
- ✕
Password database breach results in rapid mass account compromise within hours - ✕
bcrypt.compare() always returns false even for the correct password - ✕
argon2.verify() throws Error: password verification failed - ✕
Password reset works but login fails — hash stored without the generated salt - ✕
Identical passwords from different users produce identical hash values in the database - ✕
Authentication slows to unusable speed after raising bcrypt rounds to 16 or higher
Common causes
- •Using MD5, SHA-1, or plain SHA-256 for password hashing — these are fast hashing algorithms not designed for password storage
- •Storing passwords without a salt, allowing rainbow table and dictionary attacks
- •Reusing the same static salt across all users, defeating the purpose of salting
- •Truncating the password before hashing, causing collision where different passwords verify as equal
- •Storing the plain text password or a reversible encrypted form rather than a one-way hash
- •Setting bcrypt cost factor too low (4–6) for current hardware, making brute force feasible
When it happens
- •After a database breach is disclosed and attacker is seen cracking hashes within hours of exfiltration
- •During security audit when legacy code using md5($password) or sha1($password) is discovered
- •After upgrading from one framework version to another that changed default hashing settings
- •When migrating from a legacy system that stored passwords with an obsolete scheme like DES crypt
- •After a developer mistakenly commits a password hash algorithm change that breaks existing logins
Examples and fixes
MD5 and SHA-1 are cryptographic hash functions designed for speed, not for password storage. An attacker with a GPU can test billions of MD5 hashes per second. Replace them with Argon2id.
Replacing MD5 with Argon2id for password storage
❌ Wrong
// CRITICAL SECURITY VULNERABILITY — never do this
const crypto = require('crypto');
async function hashPassword(password) {
// MD5 is broken for passwords — 10 billion guesses/second on GPU
return crypto.createHash('md5').update(password).digest('hex');
}
async function verifyPassword(password, storedHash) {
const hash = crypto.createHash('md5').update(password).digest('hex');
return hash === storedHash; // timing-safe comparison missing too
}✅ Fixed
const argon2 = require('argon2');
// OWASP recommended Argon2id parameters
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB in KiB
timeCost: 2, // 2 iterations
parallelism: 1,
};
async function hashPassword(password) {
// argon2 generates a unique salt automatically
return argon2.hash(password, ARGON2_OPTIONS);
}
async function verifyPassword(password, storedHash) {
return argon2.verify(storedHash, password); // timing-safe internally
}MD5 processes data at over 10 billion operations per second on consumer GPU hardware. A typical 8-character password with letters and numbers has about 218 trillion combinations — crackable in under 6 hours with a single GPU. Argon2id uses memory-hard computation: it requires m=19456 KiB (about 19 MB) of RAM per hash attempt, which makes GPU-based cracking infeasible since GPUs have limited per-core memory. The argon2 library generates a unique random salt for each password automatically and stores it in the hash string, so no separate salt management is required.
When upgrading from a weaker algorithm (bcrypt cost 8) to a stronger one (cost 12), you cannot rehash all passwords at once because you do not have the plaintext. Rehash on successful login.
Online rehashing: upgrading legacy bcrypt hashes on login
❌ Wrong
// Trying to rehash all at once — impossible without plaintext
async function migrateAllPasswords() {
const users = await db.query('SELECT id, password_hash FROM users');
for (const user of users) {
// Cannot call bcrypt.hash here — we don't have the plaintext!
const newHash = await bcrypt.hash(user.password_hash, 12); // WRONG
await db.query('UPDATE users SET password_hash=? WHERE id=?',
[newHash, user.id]);
}
}✅ Fixed
const bcrypt = require('bcrypt');
const CURRENT_ROUNDS = 12;
async function login(email, password) {
const user = await db.query(
'SELECT id, password_hash FROM users WHERE email = ?', [email]);
if (!user) return null;
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return null;
// Rehash if stored hash uses outdated cost factor
const cost = parseInt(user.password_hash.split('$')[2]);
if (cost < CURRENT_ROUNDS) {
const newHash = await bcrypt.hash(password, CURRENT_ROUNDS);
await db.query('UPDATE users SET password_hash=? WHERE id=?',
[newHash, user.id]);
}
return user;
}Password hashes are one-way: you cannot recover the original password from the hash. The only opportunity to upgrade a user's hash is when they provide their password — typically at login. The fixed version checks the cost factor encoded in the bcrypt hash string (bcrypt's $2b$ROUNDS$ prefix) and rehashes with the current target if the stored hash uses a lower cost factor. This upgrades the entire user base gradually over time as users log in, without requiring a forced password reset or a database migration that processes plaintext passwords.
Why Fast Hash Functions Fail for Passwords
The fundamental problem with using general-purpose cryptographic hash functions (MD5, SHA-1, SHA-256) for passwords is that they are designed to be fast. SHA-256 can process approximately 3.5 billion hashes per second on a single modern GPU. When an attacker obtains a database of password hashes, they can test a dictionary of millions of common passwords against every hash in seconds. Combinations like adding numbers, capitalizing letters, or substituting characters are handled by rule-based cracking tools like Hashcat.
Password hashing algorithms — bcrypt, scrypt, and Argon2 — are deliberately slow and resource-intensive. They are designed with an adjustable cost factor (bcrypt), memory requirements (scrypt and Argon2), and parallelism limits (Argon2). This makes each individual hash comparison expensive in terms of CPU time and RAM, which means an attacker testing billions of guesses faces a prohibitive computational cost.
Salts prevent a separate class of attack: precomputation attacks. A rainbow table is a precomputed lookup table mapping hash values back to passwords. Without a salt, if two users choose the same password they produce the same hash, and an attacker can crack both simultaneously from a single table entry. A unique random salt per user means each password's hash is different even if the plaintext is identical. bcrypt, scrypt, and Argon2 all generate and store a unique salt automatically as part of the hash output string.
The OWASP Password Storage Cheat Sheet (published and regularly updated by the Open Web Application Security Project) provides the canonical guidance for password algorithm selection. As of 2024, OWASP's recommendation hierarchy is: Argon2id first, scrypt second (if Argon2id is unavailable), bcrypt third (for legacy compatibility), and PBKDF2 only when FIPS 140 compliance is required. MD5 and SHA-1 are explicitly prohibited. OWASP also notes that all new systems should use Argon2id unless there is a specific reason not to.
A common misconception is that adding pepper (a static secret known to the application but not stored in the database) to a password before hashing provides significant additional security. Pepper does help if the database is leaked but the application server is not compromised. However, it complicates key rotation (if the pepper changes, all passwords need rehashing) and provides false confidence. OWASP recommends pepper as an additional layer after choosing a strong algorithm, not as a substitute for a strong algorithm.
Identifying Vulnerable Password Hashing in Your Codebase
Start by searching your codebase for calls to fast cryptographic hash functions used on passwords. In Node.js: grep -r 'createHash\|md5\|sha1\|sha256' src/ and inspect which calls process user passwords. In Python: grep -r 'hashlib.md5\|hashlib.sha1\|hashlib.sha256' and verify if any are used for password storage. In PHP: grep -r 'md5(\|sha1(' and check the context. Any use of these functions on password values is a critical vulnerability.
For bcrypt hashes in your database, check the cost factor embedded in the hash string. A bcrypt hash looks like $2b$10$SALT22CHARS.HASHCHARS. The 10 between the second and third dollar signs is the cost factor (also called rounds). For 2026 hardware, OWASP recommends a minimum of 10 rounds with 12 as a good default for most applications. Cost factor 6–8 (set in older defaults) is now insufficient — a modern GPU can test approximately 100,000 bcrypt-10 hashes per second and over 1 million bcrypt-8 hashes per second.
For Argon2 hashes, the hash string encodes the parameters: $argon2id$v=19$m=19456,t=2,p=1$SALT$HASH. The m value is memory in KiB. If you find m values below 64000 (64 MB) in production hashes, consider upgrading. The OWASP minimum for Argon2id is m=19456 (19 MiB), with 64 MiB recommended if your hardware allows it.
Check for the same-salt anti-pattern: if all hashes in your database have the same salt prefix, or if you find code like bcrypt.hash(password + STATIC_SALT, rounds), you have a shared-salt vulnerability. The purpose of salting is that each password gets a unique salt. Look for any shared constant being concatenated with passwords before hashing.
To test bcrypt timing, run a benchmark on your production-equivalent hardware: time node -e "require('bcrypt').hashSync('test', 12)". OWASP recommends tuning the cost factor so that hash generation takes approximately 1 second on your production server. This balances security (high cost makes attacks expensive) with usability (1 second is acceptable for login) and DOS resistance (avoid factors so high that an attacker can exhaust server resources with many login attempts).
Implementing bcrypt, Argon2id, and scrypt Correctly
For Argon2id in Node.js, use the argon2 npm package (built on the reference implementation): const argon2 = require('argon2'). Call argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 }) for hashing and argon2.verify(hash, password) for verification. The library handles salt generation and embedding. For Python, use argon2-cffi: from argon2 import PasswordHasher; ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1); hash = ph.hash(password); ph.verify(hash, password).
For bcrypt in Node.js, the bcrypt package is most widely used: const bcrypt = require('bcrypt'). Call bcrypt.hash(password, 12) for hashing (second argument is cost factor / rounds). Call bcrypt.compare(password, hash) for verification — this returns a boolean and is timing-safe internally. Never use bcrypt.hashSync() in an async Node.js server — it blocks the event loop during the expensive computation, making every request wait. Always use the async versions.
For PBKDF2 in environments where FIPS 140 compliance is required (government systems, some financial institutions), use Node.js built-in crypto.pbkdf2() with SHA-512, at least 600,000 iterations (OWASP 2023 recommendation), and a 16-byte random salt: crypto.pbkdf2(password, salt, 600000, 64, 'sha512', callback). For Java, use javax.crypto.SecretKeyFactory with PBKDF2WithHmacSHA512 and the same iteration count.
For scrypt, the OWASP minimum parameters are N=65536, r=8, p=1 (approximately 64 MB of memory). Node.js has built-in scrypt support: crypto.scrypt(password, salt, 64, { N: 65536, r: 8, p: 1 }, callback). Note that scrypt does not embed the salt in its output like bcrypt and Argon2 do — you must store the salt separately alongside the hash.
Security warning: never implement these algorithms yourself. Always use a well-maintained library. Subtle implementation errors in memory management, constant-time comparison, or salt generation can completely undermine security. The libraries listed above (argon2, bcrypt) are maintained by security professionals, have been audited, and have years of production use behind them. Never use a custom hash function or an obscure library with few users and no security audit history.
Edge Cases: bcrypt Truncation, Pepper, and FIPS
bcrypt has a well-known 72-byte input truncation limit. The original bcrypt specification processes only the first 72 bytes of the password. A 73-character password and a 74-character password that differ only after character 72 will produce the same bcrypt hash, meaning both passwords will verify as correct. This is a collision vulnerability that affects bcrypt exclusively — Argon2id and scrypt do not have this limitation. Mitigate by pre-hashing the password with SHA-256 before passing it to bcrypt: bcrypt.hash(Buffer.from(sha256(password), 'hex'), rounds). The SHA-256 output is 64 bytes — within the 72-byte limit — and contains full entropy from the original password regardless of length.
Null bytes in passwords cause silent truncation in some implementations. The original crypt() function treated the password as a C string, stopping at the first null byte. Some bcrypt implementations inherited this behavior. A password containing a null byte may verify differently across implementations. Reject null bytes in passwords at the input validation layer, or hash the password's hex representation rather than the raw binary.
Pepper (a server-side secret added to passwords before hashing) is an additional layer that OWASP mentions as beneficial. The pepper is stored in application configuration, not in the database. If only the database is compromised, the attacker cannot crack passwords without the pepper. However, if both the database and application configuration are compromised, pepper provides no protection. Implementing pepper with key rotation support requires storing the pepper version identifier alongside each hash. This complexity is usually not worth the marginal security benefit over simply choosing a strong Argon2id configuration.
FIPS 140 compliance eliminates bcrypt and Argon2 as options because neither is approved by NIST's FIPS 140-2/3 standards. Environments requiring FIPS compliance must use PBKDF2 with an approved PRF (SHA-256 or SHA-512) and a compliant cryptographic module. Node.js OpenSSL can be configured in FIPS mode, which will throw errors if non-FIPS algorithms are used. Verify your FIPS requirements with your security compliance officer before committing to a password hashing algorithm.
Password migration from legacy hashing must not require forced password resets for all users simultaneously. The gradual rehashing on login approach (implemented in the examples section) is the standard solution. Maintain a hash_algorithm or hash_version column in the users table if your application supports multiple algorithms simultaneously during migration. Query this column at login to choose the correct verification method, then rehash with the current algorithm after successful verification.
Password Storage Mistakes That Cause Breaches
Storing passwords in plaintext is the most extreme and unfortunately still occurring mistake. It requires no attack skill to exploit — a database dump immediately exposes all user credentials. If you find plaintext passwords in a database, treat it as an active security incident: assume all passwords are compromised, force password resets for all users, and check whether the same server stores other sensitive data.
Encrypting passwords (as opposed to hashing them) is a common misunderstanding. Encryption is reversible — anyone with the decryption key can recover the original password. Password storage must be one-way: the original password cannot be recovered from the stored value. If someone argues for password encryption to allow password recovery emails (showing users their existing password), the correct response is to explain that a password reset (not recovery) is the secure design. Password recovery is a user expectation, not a security requirement.
Not using constant-time comparison when verifying passwords opens a timing side-channel. A naive string comparison in many languages exits early as soon as a mismatch is found, leaking information about how many characters match. Use the comparison function provided by your hashing library (bcrypt.compare() and argon2.verify() are both timing-safe) or crypto.timingSafeEqual() in Node.js for custom comparisons.
Setting the bcrypt cost factor at application startup and never adjusting it is a long-term vulnerability. Hardware gets faster every year. A cost factor that required 0.5 seconds per hash in 2018 may require only 0.05 seconds per hash on 2026 hardware, making brute force 10x cheaper. Review and adjust cost factors annually. Add an endpoint to your admin interface that displays the current average hash time and warns when it drops below 500 ms.
Pre-hashing passwords with unsalted MD5 before bcrypt is a real pattern found in some PHP codebases (md5($password) passed to password_hash()). This is catastrophic: MD5 reduces 8+ character passwords to a 32-hex-character space with fewer unique values, and the MD5 hashes are recoverable from public rainbow tables. The bcrypt wrapper becomes useless because the input has been pre-weakened. Always pass the original plaintext password to bcrypt or Argon2.
OWASP-Aligned Password Hashing Recommendations
Follow the OWASP Password Storage Cheat Sheet as your authoritative reference, reviewing it annually for updates. The current recommendation hierarchy is: Argon2id (primary choice), scrypt (when Argon2id is unavailable), bcrypt (for broad library compatibility), and PBKDF2 (for FIPS-required environments only). Document the algorithm and parameters used in your system design documentation and in code comments, so future maintainers understand the security rationale.
Tune cost factors for your specific hardware and traffic patterns. For Argon2id, start with the OWASP minimums (m=19456, t=2, p=1) and benchmark on production-equivalent hardware. If your servers can handle higher memory settings without exceeding 1-second hash time, increase m first (memory hardness is more effective against specialized hardware than time cost alone). For bcrypt, adjust rounds to achieve 200–500 ms per hash on your hardware — faster is acceptable for high-traffic login endpoints, slower is preferable for lower-traffic administrative accounts.
Implement account lockout to defend against online brute force, but design it carefully to avoid creating a denial-of-service vector. NIST SP 800-63B recommends allowing at least 100 failed attempts before lockout when using modern hashing algorithms, because the slow hash provides significant protection. Rate limiting by IP and by account, with exponential backoff and CAPTCHA challenges, provides a better trade-off than hard account lockout that can lock out legitimate users.
For high-security accounts (administrators, security personnel), consider requiring longer passwords or passphrases (16+ characters) rather than complexity rules. NIST SP 800-63B also recommends against mandatory periodic password changes (which lead to predictable increment patterns) in favor of changing passwords only upon evidence of compromise. This is a significant shift from older guidance and reflects modern understanding of how users actually handle forced resets.
Audit your password hashing regularly. Include a query like SELECT COUNT(*), SUBSTRING(password_hash, 1, 7) as algorithm FROM users GROUP BY algorithm in your quarterly security reviews to detect if any hashes use unexpected algorithms (indicating a configuration bug or migration error). Maintain a hash_version column to track migration progress. Set a target date for completing the migration of all legacy hashes and monitor progress against it.
Quick fix checklist
- ✓Audit the database for MD5, SHA-1, or plaintext passwords using SELECT DISTINCT SUBSTRING(password_hash,1,7) FROM users
- ✓Replace MD5/SHA-1 hashing code with Argon2id (preferred) or bcrypt (cost factor 12 minimum)
- ✓Verify each password gets a unique random salt — Argon2id and bcrypt handle this automatically
- ✓Implement online rehashing on login for any users whose hashes use outdated parameters
- ✓Benchmark hash time on production hardware — target 200ms-1s per hash for login endpoints
- ✓Replace string equality comparison with timing-safe comparison (bcrypt.compare or argon2.verify)
- ✓Check the bcrypt 72-byte limit if allowing long passwords — pre-hash with SHA-256 as mitigation
- ✓Review and document cost factor upgrade plan to run annually as hardware improves
Related guides
Frequently asked questions
What is the OWASP recommendation for password hashing in 2026?
OWASP recommends Argon2id as the primary choice with parameters m=19456 KiB, t=2 iterations, p=1 parallelism. If Argon2id is unavailable, use bcrypt with a minimum cost factor of 10 (12 recommended). For FIPS 140 compliant environments, use PBKDF2-HMAC-SHA512 with at least 600,000 iterations. Never use MD5, SHA-1, SHA-256, or any unsalted hash for password storage.
What is the difference between bcrypt and Argon2id?
bcrypt is a CPU-hard algorithm that has been in production since 1999 with excellent library support across all languages. Argon2id won the 2015 Password Hashing Competition and is memory-hard in addition to CPU-hard, making it significantly more resistant to GPU-based attacks where GPU memory is the limiting resource. Argon2id is the better algorithm by design, but bcrypt remains secure and is appropriate when Argon2id library support is a concern.
Why should I never use SHA-256 for passwords?
SHA-256 is designed for speed — it processes about 3.5 billion hashes per second on a single GPU. With a dictionary of 1 billion common passwords and variations, an attacker can crack all SHA-256 password hashes in under 1 second. Password hashing algorithms like Argon2id and bcrypt are deliberately slow and memory-intensive, reducing attack speed by a factor of millions compared to SHA-256.
What is a password salt and does bcrypt/Argon2 handle it automatically?
A salt is a unique random value added to each password before hashing to prevent rainbow table attacks and ensure identical passwords produce different hashes. Both bcrypt and Argon2id generate a unique random salt automatically and embed it in the output hash string. You do not need to generate or store salts separately. The verify function extracts the salt from the stored hash and uses it for comparison.
What bcrypt cost factor should I use in 2026?
OWASP recommends a minimum cost factor of 10, with 12 as a good default for 2026 hardware. Benchmark on your production hardware — bcrypt with cost 12 should take approximately 200–500 ms per hash. If your login endpoint can tolerate higher latency, cost factor 13 or 14 provides stronger security. Adjust annually as hardware improves to maintain at least 200 ms per hash.
How do I upgrade users from an old hashing algorithm without forcing password resets?
Implement online rehashing: when a user logs in successfully (which means you have their plaintext password), check whether their stored hash uses the old algorithm or outdated parameters. If so, immediately rehash with the current algorithm and update the database. This upgrades the entire user base gradually as users log in, without requiring password resets. Track migration progress with a hash_version column.
Is bcrypt's 72-byte password limit a real security problem?
The 72-byte limit means bcrypt ignores everything after the 72nd byte. Two passwords identical in the first 72 characters but different afterward will produce the same hash, creating a collision. For passwords under 72 characters (the vast majority), there is no issue. For applications allowing longer passwords, pre-hash with SHA-256 before bcrypt: hash = bcrypt.hash(sha256hex(password), rounds). This safely compresses any length to within bcrypt's limit.
Should I add a pepper to my password hashing?
Pepper (a secret value stored in application config, not in the database) provides marginal additional protection when only the database is compromised. If both the database and application server are compromised, pepper provides no benefit. OWASP recommends pepper as an optional enhancement, not a substitute for a strong algorithm. The complexity of pepper key rotation is usually not worth it unless you have a specific threat model requiring it.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.