"How exactly should I store user passwords in the database?" — every builder hits this question once. The answer is clear. Here's the safe way to store them, in order, with no attack steps.
Why plaintext, encryption, and raw hashes all fail
Assume the database leaks someday. When it does, the damage differs wildly by storage method.
- Plaintext: every password is exposed the instant it leaks. Worse, password reuse chains the breach into the user's other services. The worst case.
- Encryption (reversible): the key turns it back — so if the key leaks alongside it, you're back to plaintext. A password never needs reading back, so being reversible buys you nothing.
- Raw hash (MD5/SHA-256): a fast hash lets an attacker test guesses rapidly. Common passwords fall to rainbow tables and brute force.
Four stages to safe storage
It's quickest to understand as adding one fix at a time to a weak method.
The key insight: a salt and a slow hash do different jobs. A salt defeats precomputation (rainbow tables) and bulk reuse cracking. A slow hash drops brute force to an impractical rate. You need both before it's safe (→ what hashing is).
In practice: what to do now
Use Argon2id (or bcrypt)
For new systems, make Argon2id the first choice — it can demand memory too, which resists GPU brute force. If you favor a well-tested implementation, bcrypt is plenty in practice. Use the standard library implementation as-is for either.
Let the library generate the salt
bcrypt/Argon2 generate a per-user salt and embed it inside the hash string. You don't keep a separate salt column. Hand-building MD5(salt + password) is the classic thing not to do.
Tune the cost for your environment
Set bcrypt's cost factor, or Argon2's memory/iterations/parallelism, as high as you can while a legitimate login still feels instant. As servers get faster, so do attackers — revisit and raise it yearly.
Verify with the standard compare function
At login, match the input against the stored hash with the library's verify function (most use a timing-safe constant-time compare). Don't write your own string equality.
Migrate weak hashes by re-hashing on login
If you already store MD5/SHA-256, the moment a user logs in successfully, re-hash with the new scheme and save it. For users who haven't logged in, you can strengthen them in the meantime by hashing the existing hash again with bcrypt (a layered hash).
Common mistakes vs the right implementation
Common mistake
- Encrypt passwords for storage (key leaks → over)
- Store
MD5(password)orSHA-256(password)directly - Use a single shared salt for all users
- Hand-roll
hash(salt + password)
Right implementation
- One-way hash with Argon2id / bcrypt
- A per-user salt (the library adds it automatically)
- Cost as high as feels OK, raised periodically
- Use the standard hash/verify functions as-is
Hashing vs encryption vs encoding
These three get confused, but they differ completely in whether you can reverse them. Only hashing is acceptable for storing passwords.
| Hashing | Encryption | Encoding (Base64 etc.) | |
|---|---|---|---|
| Reversible? | No (one-way) | Yes, with the key | Yes, by anyone |
| Key | None | Required | None |
| Typical use | Password storage, tamper detection | Keeping data or traffic confidential | Changing data format |
| For password storage | Use it (salt + slow hash) | Don't | Don't (same as plaintext) |
Recommended settings (OWASP)
The minimum settings from the OWASP Password Storage Cheat Sheet. Treat these as a floor and raise them to suit your server.
| Algorithm | Minimum setting | When to use it |
|---|---|---|
| Argon2id | 19 MiB memory, 2 iterations, parallelism 1 | First choice for new systems |
| scrypt | N=2^17 (128 MiB), r=8, p=1 | When Argon2id isn't available |
| bcrypt | Cost 10 or more. Input capped at 72 bytes | Existing systems, proven track record |
| PBKDF2-HMAC-SHA256 | 600,000 iterations or more | When FIPS 140 compliance is required |
Also check your platform's built-ins. PHP's password_hash() uses bcrypt by default (PASSWORD_DEFAULT), and PHP 8.4 raised the default cost from 10 to 12. Django uses PBKDF2 by default; install django[argon2] and put Argon2 first in PASSWORD_HASHERS to switch. Django automatically re-hashes passwords stored with an older algorithm when the user logs in.
How to check your own service
You can usually tell the scheme from the start of the stored hash string. Look at a few rows in an environment you control, without copying production data elsewhere.
| What the hash looks like | Scheme | Action |
|---|---|---|
$argon2id$v=19$m=…,t=…,p=…$ | Argon2id | Check m, t and p meet the OWASP minimum |
$2y$12$… / $2b$10$… | bcrypt (the number is the cost) | Raise it if below 10 |
pbkdf2_sha256$1000000$… | PBKDF2 (the number is the iteration count) | Check it's at least 600,000 |
| 32 hex characters | Most likely MD5 | Migrate by re-hashing on login |
| 40 / 64 hex characters | Most likely SHA-1 / SHA-256 | Same as above |
| The login string as-is | Plaintext | Migrate first. Assess the impact if it leaked |
Also check the following:
- Entered passwords are not recorded in app or web server logs, error reports, or analytics tools
- If you use bcrypt, you've decided how to handle input longer than 72 bytes
- Backups and database copies made for testing contain only the same kind of hashes
Real example: plaintext storage widened the damage
In the Takufile-bin incident (2019), unauthorized access to a server leaked about 4.8 million records, and the login passwords among them were stored in plaintext. Had they been stored with a salted slow hash, the risk of the leaked passwords being used directly on other services would have been far lower. Even if you can't prevent every intrusion, how you store passwords changes how much damage a leak does.
This site's view: for password storage, use the proven standard implementation
Password storage is not a place to be creative. Using the widely tested standard implementations of Argon2/bcrypt is the safest and the easiest to operate. Our stance: don't innovate here. Spend your effort instead on reducing dependence on passwords at all — thorough multi-factor authentication (MFA), and an eventual move to passkeys (passwordless). Even the strongest hash can't save a weak, reused password.
Read next
- Glossary: what password hashing is / what a salt is
- Basics: storing passwords the right way (user side) / choosing a password manager
- Defense: choosing MFA the right way (reduce password dependence)
- Glossary: what a passkey is (moving toward passwordless)
- Case: the Takufile-bin data leak (2019) (plaintext password storage that widened the damage)
Sources
- OWASP, "Password Storage Cheat Sheet": cheatsheetseries.owasp.org
- NIST, "SP 800-63B-4" (August 2025), password storage (salt and hashing): pages.nist.gov
- RFC 9106, "Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications": rfc-editor.org
- PHP Manual, "password_hash": php.net
- Django documentation, "Password management in Django" (5.2): docs.djangoproject.com