What you'll learn
Quick Answer
Never store passwords in plain text, and do not encrypt them either, because encryption is reversible and the key usually sits on the same server. Hash them with a function built for passwords: argon2id, or bcrypt if argon2 is not available. Those are deliberately slow and tunable, which makes guessing expensive. MD5 and SHA-256 are wrong here because they are fast and parallelise well on GPUs, which is exactly what an attacker wants.
Plaintext and encryption both fail
Start with the assumption that makes the rest of the topic make sense: one day someone will have a copy of your users table. Password storage is designed for the day after that happens, not for the day before.
Plain text loses immediately, and it loses beyond your own site. People reuse passwords, so your leaked table becomes a working key to their email, and from email to everything else. It is also visible to everyone with database access, and it shows up in support screenshots and CSV exports.
Encryption feels like the sophisticated answer and is still wrong. Encryption is reversible by design: it exists so that someone holding the key can get the original back. That key has to live somewhere your login endpoint can reach it, which usually means the same server, the same environment file or the same repository as the code. An attacker who got the database usually gets the key in the next step.
Hashing is different in kind, not in strength. A hash is one-way. There is no key and no decrypt function, because the operation throws information away. At login you hash what the user typed and compare it with what you stored. You never learn the password, which is exactly why "email me my password" is impossible on a correctly built site, and why a site that can do it has told you something important about itself.
That distinction is a favourite interview question. The short version: encryption is reversible with a key, hashing is not reversible at all, and passwords must be hashed.
Why MD5 and SHA-256 are the wrong tool
So use a hash. But not MD5, SHA-1, SHA-256 or SHA-512. This is where the reasoning gets counterintuitive, because those are the hashes everyone learns first.
MD5 and SHA-2 were designed for checksums and signatures, where speed is the whole point. You want to verify a large file quickly. That design goal is precisely wrong for passwords. An attacker holding your table does not try to reverse the hash; they guess. They take a list of leaked passwords and dictionary words, hash each candidate, and compare. Every improvement in hashing speed is a direct improvement in their guessing rate.
It gets worse, because that workload parallelises beautifully. Each guess is independent, so it maps onto GPUs and purpose-built hardware, where enormous numbers of candidate hashes run at once. Fast general-purpose hashes are exactly the kind of computation such hardware is built to accelerate.
A password hashing function is deliberately built the other way. It is slow by design, with a cost parameter you tune, so that a single verification takes a noticeable fraction of a second on your server. For one legitimate login per user that cost is invisible. For an attacker running through a wordlist it multiplies against every candidate.
Modern choices go further and are memory-hard: argon2 and scrypt require a configurable amount of memory per hash. GPUs have many cores but limited memory per core, so a function that demands real memory for every guess removes much of the hardware advantage. That is the mechanism, and it is why argon2 is preferred over bcrypt for new systems.
MD5 and SHA-1 are separately broken for collision resistance. But even if they were not, they would still be the wrong tool here, and so would SHA-256. Speed is the disqualifier.
Salts, and what they actually prevent
A salt is a random value, unique per password, generated at registration and stored alongside the hash. It is not secret. Its job is narrower than most people assume, and being precise about that job is the difference between a good interview answer and a vague one.
Without salts, identical passwords produce identical hashes. Two things follow. First, an attacker looking at your table instantly sees which accounts share a password, and the most repeated hash is almost certainly something obvious. Second, precomputation becomes possible: an attacker can build a table of hashes for common passwords once and reuse it against every leaked database in the world. Rainbow tables are a space-optimised version of that idea.
A unique salt destroys both. The same password produces a different hash for every user, so precomputed tables are useless and the attacker must attack each account separately.
What a salt does not do is protect a weak password. If someone chose pune@123, the attacker just includes the stored salt in each guess and cracks that one account at the speed of the hash function. This is the entire reason the cost parameter matters: the salt forces per-account work, and the slow function makes per-account work expensive.
With bcrypt and argon2 you do not manage salts yourself. The library generates a random salt, and the output string carries the algorithm, the parameters and the salt together, which is why the same password hashed twice gives different strings. Verification reads the parameters back out of the stored string. A bcrypt hash is one 60-character value: three $-separated fields, then the salt and the hash itself run together with no separator.
$2b$12$eImiTXuWVxfM37uY4JANjQuKRlIzCUXFtEQnrmI6a7O5xnPNlWEqu
2b algorithm identifier (bcrypt)
12 cost factor
eImiTXuWVxfM37uY4JANjQ salt, 22 characters
uKRlIzCUXFtEQnrmI6a7O5xnPNlWEqu hash, 31 charactersA pepper is different: a single secret value combined with every password, kept outside the database in an environment variable or key service. If an attacker gets only the database dump, it stops offline cracking. It is optional, and worthless if the same breach exposes your configuration.
bcrypt and argon2 in practice
Use argon2id for new systems, or bcrypt if argon2 is not practical in your stack. Both are well tested and both are available everywhere. Do not implement either yourself.
// Node, bcrypt
import bcrypt from 'bcrypt';
const hash = await bcrypt.hash(password, 12); // 12 is the cost factor
const ok = await bcrypt.compare(password, hash); // plain first, hash second// Node, argon2
import argon2 from 'argon2';
const hash = await argon2.hash(password, { type: argon2.argon2id });
const ok = await argon2.verify(hash, password); // hash first, plain secondRead those two argument orders again: they are reversed between the libraries, and swapping them gives a function that always returns false. This is a genuinely common bug.
# Python, argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
stored = ph.hash(password)
try:
ph.verify(stored, password)
logged_in = True
except VerifyMismatchError:
logged_in = FalseNote that argon2-cffi raises an exception on mismatch instead of returning False. Code written as if ph.verify(...) will not do what you expect, since a successful verify returns a truthy value and a failure never reaches the condition at all.
<?php
$hash = password_hash($password, PASSWORD_DEFAULT);
$ok = password_verify($password, $hash);PHP handles salting and format for you through password_hash, and PASSWORD_DEFAULT is documented as changing over time as stronger algorithms become the default, so store the result in a column wide enough for longer hashes. PASSWORD_ARGON2ID is available when the build supports it.
Two parameter notes. The bcrypt cost factor is an exponent rather than an iteration count: the work is proportional to two raised to the cost, so each increment doubles it. Pick a value where a login takes a fraction of a second on your own hardware, and re-measure when you change servers. And bcrypt ignores input beyond 72 bytes, so a long passphrase is silently truncated. If you want passphrases, use argon2, which has no such limit.
The rest of the login path
A correct hash sitting inside a careless login flow still leaks accounts. The rest of the path matters.
Always compare with the library verify function. It handles constant-time comparison, so an attacker cannot learn how much of a value was correct by measuring response time. Never compare hashes with == in a hand-rolled scheme.
Do not leak which accounts exist. "No such user" and "wrong password" as separate messages hand an attacker a list of valid emails. Return one message for both. There is a subtler version of the same leak: if a missing user returns instantly while a real user takes as long as the hash function, the timing difference says the same thing. Hash a dummy value on the missing-user path so both branches cost the same.
Rehash on login when parameters change. When you raise the cost factor, existing hashes keep their old parameters. The only moment you hold the plain password again is a successful login, so upgrade there.
<?php
if (password_verify($input, $user['password_hash'])) {
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$new = password_hash($input, PASSWORD_DEFAULT);
// save $new against the user row
}
}Rate limit and back off. The slow hash raises the cost of offline cracking; it does nothing about someone trying a leaked password list against your live login endpoint. Limit attempts per account and per source, add increasing delays, and log the failures so somebody could notice a pattern.
Do not cap length aggressively and do not block paste. Short maximums and paste-blocking push users towards weaker passwords and break password managers, which are the single most effective habit an ordinary user can adopt. Allow long inputs and every character.
Reset tokens are credentials too. Generate them from a cryptographic source, expire them quickly, make them single use, and store a hash of the token rather than the token itself.
Last one, and it is the mistake that gets found in log aggregators over and over: never log the request body on the login route. A password that was hashed correctly in the database and printed in plain text into a log file that half the team can read has not been protected at all.
