How to Store Passwords Correctly
06 May 2026If you’re storing passwords with bcrypt, you’re fine. If you’re storing them with anything else — MD5, SHA-256, your own scheme — stop and fix it today, not after this post. Here’s what “correct” means and the exact parameters to use.
The one rule
Never store a password in plaintext, and never hash it with a general-purpose hash function (MD5, SHA-1, SHA-256) alone. Those are fast by design, which is exactly wrong for passwords — fast means an attacker with a stolen hash dump can try billions of guesses a second on commodity GPUs. Password hashing needs to be deliberately slow and memory-hungry.
Argon2id is the current recommendation
Argon2 won the Password Hashing Competition and the id variant balances resistance to both GPU-cracking and side-channel attacks. If your language has a maintained binding, use it:
const argon2 = require('argon2')
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // ~19 MB
timeCost: 2,
parallelism: 1,
})
const valid = await argon2.verify(hash, password)
Those parameters are OWASP’s current baseline for argon2id. Memory cost is the important knob — it’s what makes GPU cracking expensive, since GPUs have lots of compute but comparatively little fast memory per core.
bcrypt is still fine if that’s what you have
If your stack already uses bcrypt and switching is a real migration project, don’t rush it. bcrypt with a cost factor of 12+ is still considered acceptable in 2026:
const bcrypt = require('bcrypt')
const hash = await bcrypt.hash(password, 12)
const valid = await bcrypt.compare(password, hash)
bcrypt’s weakness relative to Argon2 is that it doesn’t have a memory-cost parameter, making it comparatively cheaper to attack on GPU/ASIC hardware — but a cost factor of 12+ still puts a meaningful floor on brute-force speed. Migrate to Argon2 on your own timeline, not in a panic.
Never hand-roll this part
No custom “hash it twice,” no “add a secret prefix and SHA-256 it,” no HMAC-based homemade scheme. These all feel more secure and are not — they lack the deliberate slowness and memory cost that makes Argon2/bcrypt resistant to brute force, and they’re untested against attacks the real algorithms were specifically designed around.
Salting is automatic — don’t add your own on top
Both argon2 and bcrypt libraries generate and store a unique random salt inside the hash output automatically. You don’t manage salts yourself, and you don’t need a separate salt column:
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$hash...
That whole string is what you store in one column. The salt is embedded in it.
Rehashing on login: how you migrate without a forced reset
You can’t decrypt an old hash to get the plaintext, so you upgrade algorithms lazily, on the next successful login:
async function verifyAndUpgrade(user, password) {
const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) return false
// migrate to argon2 now that we have the plaintext, just this once
const newHash = await argon2.hash(password, { type: argon2.argon2id })
await db.updateUserPasswordHash(user.id, newHash)
return true
}
Users who don’t log in for a year stay on the old hash until they do. That’s fine — it’s still a legitimate hashing scheme, just not the current best one.
What this doesn’t fix
Hashing correctly stops an attacker who steals your database from trivially recovering passwords. It does nothing against a phished password or a reused one from a breach elsewhere — that’s what rate limiting login attempts and, ideally, offering 2FA are for.
The concrete next action
grep -r "md5\|sha256(password\|sha1(password" src/
If that returns anything touching a password field, you have a real problem today, not a someday one.