noted.do
The Tech Guy

How to Store Passwords Without Regretting It Later

If your app has user accounts, at some point you're storing something derived from people's passwords. The question isn't whether your database will ever leak. Plan as if it will. The question is how much damage that leak does. Good password storage turns "attackers have everyone's password" into "attackers have a slow, expensive puzzle per user." That gap is huge, and it mostly comes down to picking the right function and not getting clever.

Hashing, not encryption

First thing to get straight. Encryption is reversible: anyone with the key can get the original back. That's exactly what you don't want for passwords. If the key sits on the same server as the database (and it usually does), a breach gets both.

Hashing is one way. You feed in a password and get out a fixed-length string, and there's no "unhash" button. To check a login, you hash what the user typed and compare it to what you stored. You never need to know the actual password, so you never keep it.

Why ordinary hashes are the wrong tool

So just use SHA-256, right? No. SHA-256 is a fine hash for checking file integrity, but it's designed to be fast. Fast is the opposite of what you want here. Attackers with a leaked database don't reverse your hashes, they guess. They take huge lists of common and previously leaked passwords, hash each one, and look for matches. Modern GPUs can compute fast hashes at an absurd rate, so weak passwords fall almost immediately.

MD5 and SHA-1 are worse for the same reason plus their own known weaknesses. If you inherit a codebase storing passwords with any of these, that's a priority fix.

Salts

A salt is a random value generated per user and mixed into the hash. Two people with the same password end up with completely different hashes. That kills precomputed lookup tables (rainbow tables) and forces attackers to crack each account separately instead of all at once.

Salts aren't secret. They're stored right next to the hash. Their job is uniqueness, not secrecy. The good news is that every modern password hashing function generates and stores the salt for you, so you basically never handle it by hand.

Slow on purpose: bcrypt, scrypt, Argon2

Password hashing functions are deliberately slow and come with a tunable cost setting. If checking one password takes your server a fraction of a second, a single login is fine. But an attacker trying billions of guesses is now stuck doing billions of slow operations.

bcrypt has been around a long time and is still a solid choice. It has a cost factor you can raise as hardware gets faster. One quirk to know about: it only looks at the first 72 bytes of input. For normal passwords that doesn't matter, but it's why you shouldn't, for example, concatenate other long values onto a password before bcrypting it.

scrypt added the idea of being memory-hard, meaning each guess needs a chunk of RAM, not just CPU time. GPUs and custom hardware are great at raw computation but much less great when each attempt needs lots of memory.

Argon2 won the Password Hashing Competition and builds on that idea. Use the Argon2id variant, which is the generally recommended default. It lets you tune time cost, memory cost and parallelism.

PBKDF2 also exists and is acceptable when compliance requirements demand it, but it isn't memory-hard, so it needs a lot of iterations to hold up.

My pick: Argon2id if your platform supports it, bcrypt otherwise. Both are fine. Choosing either over a plain hash matters about a hundred times more than choosing between them.

What this looks like in practice

The best part is you almost never implement any of this yourself. Most languages have a vetted function. In PHP:

// When the user registers or changes their password
$hash = password_hash($password, PASSWORD_DEFAULT);
// store $hash in the database (use a VARCHAR(255) column)

// When the user logs in
if (password_verify($password, $user['password_hash'])) {
    // correct password

    // upgrade old hashes quietly if your settings have changed
    if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
        $newHash = password_hash($password, PASSWORD_DEFAULT);
        // save $newHash for this user
    }
} else {
    // wrong password, show a generic error
}

PASSWORD_DEFAULT uses bcrypt and can change to something stronger over time, which is why the column should be generously sized. If your PHP build supports it, PASSWORD_ARGON2ID gives you Argon2id instead. Either way, the salt and settings are baked into the output string, so password_verify() knows how to check it later. It also does the comparison in a way that doesn't leak timing information.

The password_needs_rehash() bit is underrated. It lets you raise your cost settings or switch algorithms and have every account upgrade itself the next time that user logs in. No migration, no forced resets.

Other languages have the same thing. Python has libraries like argon2-cffi and bcrypt. Node has argon2 and bcrypt packages. Go has golang.org/x/crypto/bcrypt and argon2. Use those, not something you wrote.

Tuning the cost

Pick settings so that one hash takes a noticeable but tolerable amount of time on your production server. Benchmark it on the real hardware. Too low and you've thrown away the protection. Too high and your login endpoint becomes a nice way for someone to tie up your CPU, which is one more reason to rate limit it.

A few things not to do

Don't write your own scheme, like hashing a hash a few times with a secret mixed in. Don't truncate or lowercase passwords before hashing. Don't put a maximum length so short it blocks passphrases. Don't log the raw password anywhere, including in debug output and error reports, which is how a lot of "we stored passwords in plain text" stories actually happen.

Some teams also add a pepper, a secret value kept outside the database (in an environment variable or a secrets manager) and mixed in before hashing. It can help if only the database leaks. It's an optional extra on top of a proper hash, never a replacement for one.

Get the hashing right once, use the built-in function, and this becomes one of the few security problems you can genuinely call solved.

Comments

Be kind. See the rules.