Password storage is one of the few areas in software with a genuine right answer, published, maintained and free. It is also one of the most consistently got wrong, because the wrong answers were correct at some point and nobody revisited them.
The failure is rarely exotic. It is a system built in 2016 against advice that was current in 2012, still running, still accepting logins, and nobody has looked at that function since the person who wrote it left.
If you take one thing: use Argon2id, and if you cannot, use bcrypt. Everything else in this article is detail. A general-purpose hash such as SHA-256 is not a password hash regardless of how many times you apply it, because its speed is a design goal, and speed is precisely what you are trying to deny an attacker who has your database.
Password Storage Has Exactly One Correct Shape
Store a hash produced by a deliberately slow, memory-hard function, with a unique random salt per password, using parameters tuned to your hardware.
The OWASP password storage guidance states the current recommendations precisely, and they are worth quoting as numbers rather than as principles.
| Algorithm | Recommended parameters | When to use |
|---|---|---|
| Argon2id | 19 MiB memory, 2 iterations, 1 degree of parallelism | First choice |
| scrypt | N=2^17 (128 MiB), r=8, p=1 | If Argon2id is unavailable |
| bcrypt | Work factor 10 or more, 72 byte password limit | Legacy systems |
| PBKDF2-HMAC-SHA256 | 600,000 iterations | Where FIPS-140 compliance is required |
Two notes on that table. The Argon2id figure is a minimum, and higher memory with fewer iterations is an equivalent trade, so a configuration of 47 MiB with 1 iteration provides comparable security to the baseline. And bcrypt’s 72 byte limit is a real truncation, not a validation rule: anything past 72 bytes is silently ignored, which matters if you accept long passphrases or pre-hash the input.
These parameters are revised as hardware improves. Read the source rather than this table when you implement.
A Salt Is Not Optional and Not Sufficient
A salt is a unique random value stored alongside each hash. Its purpose is narrow and important: it ensures two users with the same password produce different hashes, which defeats precomputed rainbow tables and stops an attacker cracking one password and getting every account that shared it.
What a salt does not do is slow anybody down. An attacker with your database has the salts too, because they must be readable to verify a login. Salting alone against a fast hash means an attacker attacks each password individually at enormous speed, which is exactly the situation you were trying to avoid.
The slowness has to come from the algorithm. That is the whole point of a memory-hard function: it deliberately consumes resources so that an attacker with specialised hardware gains far less advantage than they would against a fast hash.
A pepper, a secret value applied to every password and stored outside the database, adds a real layer, because a database dump alone becomes insufficient. It also adds an operational burden, since rotating it requires rehashing on next login, and losing it locks out every account. Worth it for high-value systems and skippable for most.
The Mistakes That Survive for Years
A fast hash with a salt. SHA-256 with a salt looks responsible and is not. Speed is the vulnerability.
Encryption instead of hashing. Encryption is reversible by design, which means a key exists, and anybody who obtains it obtains every password in plaintext. Passwords should never be recoverable, only verifiable.
Work factors set once and never raised. A bcrypt cost chosen in 2015 is substantially weaker against 2026 hardware. Raise it on next successful login, when you have the plaintext momentarily and can rehash.
Truncation or character restrictions. A maximum length below about 64 characters, or a ban on particular characters, usually signals that the password is being stored in a way that cares what it contains. It should not; you are hashing it.
Comparison that leaks timing. Verification must use a constant-time comparison. Standard library functions do this and hand-rolled equality checks do not.
Logging the password. It reaches an error log, a request trace or a monitoring system, and then it is in three places with weaker access controls than the database.
Migrating an Existing System
You cannot rehash what you do not have, because you only hold hashes. The workable approach is to upgrade at login.
When a user authenticates successfully, you have their plaintext password in memory for a moment. Verify against the old hash, then immediately compute a new one with the current algorithm and replace the stored value. Over a few months most active accounts migrate with no user-visible change.
Track the algorithm per record so both can coexist, and set a date after which remaining old hashes are invalidated and those users must reset. Dormant accounts with weak hashes are exactly the ones an attacker wants.
Do not wrap the old hash in the new algorithm as a shortcut. It works, and it leaves the weak hash as the effective security boundary while looking modern.
What Matters More Than the Algorithm
Correct hashing protects you when the database leaks. It does nothing about the more common attack, which is somebody logging in with a real password obtained elsewhere.
Rate limiting on authentication, multi-factor availability, checking new passwords against known-breached lists, and alerting on credential stuffing patterns all address that, and all matter more day to day than the choice between Argon2id and bcrypt. The API security controls are the same family of problem.
Both are worth doing, and the order is worth being honest about: get the hashing right once because it is cheap and permanent, then spend the ongoing effort on the login path, which is where the attacks actually arrive. Mecanik reviews both as part of our application security work.
Frequently Asked Questions
What is the best password hashing algorithm in 2026? Argon2id, at a minimum of 19 MiB of memory, 2 iterations and 1 degree of parallelism. If it is unavailable, scrypt at N=2^17, r=8, p=1 is an alternative, bcrypt with a work factor of 10 or more suits legacy systems, and PBKDF2-HMAC-SHA256 at 600,000 iterations covers FIPS-140 requirements.
Is salting passwords enough? No. A salt ensures identical passwords produce different hashes, defeating rainbow tables and stopping one cracked password unlocking every account that shared it. It does not slow an attacker down, because salts must be readable to verify logins. The slowness has to come from a deliberately memory-hard algorithm.
Why not use SHA-256 for passwords? Because its speed is a design goal, and speed is exactly what you are trying to deny an attacker holding your database. A general-purpose hash is not a password hash regardless of how many times it is applied or whether it is salted.
Does bcrypt have a password length limit? Yes, 72 bytes, and anything beyond that is silently truncated rather than rejected. This matters if you accept long passphrases or pre-hash input before passing it to bcrypt, since the truncation happens without any error to indicate it.
How do I upgrade password hashes without user passwords? At login. When a user authenticates you hold their plaintext momentarily, so verify against the old hash and immediately rehash with the current algorithm. Track the algorithm per record so both coexist, and set a date after which remaining old hashes are invalidated and those users must reset.
Comments