Every data breach disclosure follows the same arc: “We takes security seriously.” Then you read the details and find out passwords were stored in plaintext, or hashed with MD5, or encrypted with a key that was in the same database. The password storage problem is solved — the algorithms exist, the libraries exist, the documentation exists — and yet developers still get it wrong.
This post covers what to store, what not to store, the difference between hashing and encryption, and why SHA-256(password) is not a password hash.
The rule: never store plaintext
If your database is compromised and you have plaintext passwords, every user who reuses that password on other sites is now compromised. Not just your site — their email, their bank, their social media. Plaintext password storage is not a security issue. It’s a moral issue.
Store a hash. Always.
Hashing vs encryption
Hashing is a one-way transformation. You can’t reverse it. Given hash(password), you can’t recover password. You can only verify: hash the input and compare.
Encryption is a two-way transformation. Given encrypt(password, key), you can recover password with the key. If the key is compromised, every password is compromised.
Hash passwords. Never encrypt them. If you need to recover the plaintext (you don’t), you’re solving the wrong problem.
Why SHA-256 is not enough
A common mistake: hash = SHA-256(password). It’s fast, it’s well-known, and it’s wrong for passwords.
The problem is speed. SHA-256 on a modern GPU processes billions of hashes per second. An attacker with a database of SHA-256 password hashes can try every common password, every dictionary word, every permutation, in minutes.
Password hashing algorithms are deliberately slow. They use key stretching — thousands of iterations of a hash function — to make brute-force attacks impractical:
- bcrypt — the standard since 1999. Adjustable cost factor. Password + salt → hash. Every major language has a library.
- scrypt — memory-hard. Requires large amounts of RAM to compute, making GPU attacks harder.
- Argon2 — winner of the Password Hashing Competition (2015). Memory-hard, configurable time and memory costs. The current best practice.
- PBKDF2 — NIST-recommended, widely available, uses HMAC iterations. Less memory-hard than Argon2 but well-tested.
Use bcrypt, scrypt, or Argon2. Don’t invent your own scheme. Don’t hash once with SHA-256 and call it done.
Salt: why every password needs a unique one
A salt is random data added to the password before hashing. Without a salt, two users with the same password produce the same hash. An attacker can precompute hashes for common passwords (a rainbow table) and match them against your database.
With a unique salt per user, every hash is different even if the passwords are identical. Rainbow tables don’t work. The attacker has to brute-force each password individually.
Modern password hashing functions (bcrypt, Argon2) generate a random salt automatically. You don’t need to manage salts separately. Pass the password to the hash function, and it handles the rest.
What to store
For each user, store:
- The password hash (bcrypt/Argon2 output, which includes the salt and cost parameters)
- The hash algorithm (bcrypt, argon2, etc.)
- The cost parameters (bcrypt rounds, Argon2 memory/time)
Do NOT store:
- The plaintext password
- The salt separately (the hash function output includes it)
- An “encryption key” that could decrypt the password
- Password hints (they leak information)
The hash output from bcrypt looks like: $2b$12$LJ3m4ys3GzF4VQ.YOUR.HASH.HERE. The $2b$ identifies the algorithm, 12 is the cost factor, and the rest is salt + hash. To verify a login attempt, hash the submitted password with the same parameters and compare.
Cost factors
bcrypt’s cost factor determines how slow the hash is. Each increment doubles the computation time:
- Cost 10: ~10ms per hash (fast for users, fast for attackers)
- Cost 12: ~40ms per hash (reasonable)
- Cost 14: ~160ms per hash (slow for attackers, noticeable for users)
- Cost 16: ~640ms per hash (too slow for most login flows)
The sweet spot in 2026: bcrypt cost 12-14, Argon2 with 64MB+ memory and 3+ iterations. The goal is to make each hash attempt slow enough that brute-forcing billions of passwords takes years, but fast enough that a user doesn’t notice a delay on login.
Rate limiting
Hashing alone isn’t enough. If an attacker can try millions of passwords per second against your login endpoint, even bcrypt won’t save you.
Rate limit login attempts. After 5-10 failed attempts from the same IP or account, lock the account temporarily or require a CAPTCHA. This limits brute-force attacks to a trickle.
Don’t reveal whether the username or password was wrong. “Invalid username or password” is safer than “No account found with that email.” The latter tells an attacker which usernames exist.
Add account lockout after sustained failures. After 10-20 failed attempts, lock the account for 15 minutes or require email verification to unlock.
Password generation
When generating passwords (for users, for API keys, for tokens):
- Minimum 12 characters. 8 is no longer enough against modern hardware.
- Include uppercase, lowercase, digits, and symbols. But length matters more than complexity.
- Use a CSPRNG (cryptographically secure pseudo-random number generator).
Math.random()is not secure. - Never generate predictable patterns.
Password1!looks complex but is trivially guessable.
The best password is a randomly generated string from a CSPRNG. Use a password manager to store it. Don’t ask users to remember complex passwords — give them a generated one and let the manager handle it.
Common mistakes
- Hashing once with SHA-256 or MD5 — too fast, no salt, trivially brute-forceable.
- Using a static salt — if every user shares the same salt, rainbow tables work.
- Storing the salt separately — if the salt is in a different table, an attacker who compromises the database gets both.
- Encrypting instead of hashing — if you can decrypt the password, so can an attacker with the key.
- No rate limiting — even bcrypt is vulnerable to unlimited login attempts.
- Revealing whether a username exists — helps attackers enumerate valid accounts.
- Using
Math.random()for password generation — predictable, not cryptographically secure.
Quick reference
| Do | Don’t |
|---|---|
| Use bcrypt, Argon2, or scrypt | Use SHA-256 or MD5 |
| Let the library handle salting | Manage salts yourself |
| Rate limit login attempts | Allow unlimited password tries |
| Generate passwords with CSPRNG | Use Math.random() |
| Store only the hash | Store plaintext or encrypted passwords |
| Say “invalid credentials” | Say “wrong password” |
Try it
If you need to generate a secure random password or hash a string for testing, use a browser-based tool so the operation stays local. Your password should never touch a server for a simple generation task.