Password Hashing Explained: Why 'We Encrypt' Is a Red Flag

How password hashing works, why MD5 and SHA-256 are the wrong tools, and how salts, Argon2id, peppers and rate limiting actually protect your users' passwords.

Shariar Kabir 7 min read

Years ago I clicked "forgot password" on a shopping site and, within a minute, received an email containing my password. Not a reset link. The password. In plain text, in an email, sitting in an inbox the site had no business being able to see into.

I emailed support to ask how they stored passwords. The reply was cheerful: "Don't worry, all passwords are encrypted." That sentence was meant to reassure me. It had the opposite effect, because if they could send me my password, they could read it, and so could anyone who got into their database.

This post is about password hashing: what it is, why "we encrypt passwords" is the wrong answer, and how to store passwords so that even you cannot read them. If you build anything with a login form, this is the one piece of cryptography you cannot outsource to hope.

Hashing vs encryption: one of them has a key

Encryption is reversible by design. You take a message, a key and an algorithm, and you get ciphertext. Anyone with the key gets the message back. That is the whole point of encryption: the data is meant to be read again by someone.

Hashing is deliberately one-way. A hash function takes any input and produces a fixed-size fingerprint. The same input always gives the same output, a tiny change to the input gives a completely different output, and there is no key that turns the fingerprint back into the input. You cannot "decrypt" a hash because nothing was encrypted.

For passwords, this is exactly what you want. You never need to read the user's password again. You only need to answer one question at login time: does what they just typed produce the same fingerprint as what we stored? So you store the hash, hash the login attempt, and compare.

If a site encrypts passwords instead, the key lives somewhere on the same infrastructure as the database. When the database leaks, the key tends to leak with it, and now every password is readable. When a site can email you your password, that is what has happened, minus the leak. So far.

Why MD5, SHA-1 and SHA-256 are the wrong tools for passwords

The obvious follow-up is "fine, I'll use SHA-256". That is better than encryption and still wrong, for a reason that surprised me when I first learned it: general-purpose hash functions are designed to be fast.

Fast is wonderful when you are checksumming a file download. It is a disaster for passwords, because the attacker does not need to reverse the hash. They just guess. A modern GPU can compute billions of SHA-256 hashes per second. Human passwords are not random; they are drawn from a fairly small space of words, dates, names and keyboard patterns. An attacker with a leaked table of SHA-256 hashes runs a wordlist through the same function and matches the outputs. For most real passwords, this takes minutes.

MD5 and SHA-1 have the extra problem of being cryptographically broken for collision resistance, but honestly that is a footnote here. Even a perfectly sound fast hash is the wrong shape for the job. The job needs something slow.

Salts: making every password its own problem

Before slowing things down, there is a cheaper trick. If two users pick the same password and you hash it plainly, they get the same hash. An attacker can precompute hashes for millions of common passwords once, then look up every leaked hash in that table. These are the famous rainbow tables.

A salt is a random value, unique per user, that you combine with the password before hashing and store alongside the hash. It is not secret. Its only job is to make every stored hash unique, so that precomputed tables are useless and each password must be attacked separately.

Two rules I have seen broken more often than I would like:

  • The salt must be random, generated by a proper cryptographic random number generator, not derived from the username or the user ID.
  • The salt must be per user. One global salt is just a slightly obscured unsalted hash.

Modern password hashing libraries generate and store the salt for you, embedded in the output string. If you find yourself writing salt-handling code, you are probably using the wrong library.

Slow and memory-hard: bcrypt, scrypt and Argon2id

The real fix is a password hashing function, sometimes called a key derivation function or KDF, that is deliberately expensive to compute. The idea is simple: if one hash takes, say, a quarter of a second on your server, a legitimate user never notices, but an attacker trying a billion guesses now needs a very long time and a very large electricity bill.

Three names matter:

  • bcrypt (1999): slow by design, with an adjustable cost. Still fine. Its main quirk is a 72-byte input limit, which some libraries silently truncate.
  • scrypt (2009): slow and memory-hungry, so that specialised hardware with lots of cheap compute but little memory per core loses its advantage.
  • Argon2 (2015): winner of the Password Hashing Competition. The Argon2id variant is what the OWASP cheat sheet recommends by default. It is memory-hard, tunable in time, memory and parallelism, and resists both GPU brute force and side-channel tricks.

Here is the entire amount of code you need with the argon2-cffi package:

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()  # Argon2id with the library's current defaults

# At registration: store this string, nothing else
stored = ph.hash("correct horse battery staple")
# looks like: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>

# At login
try:
    ph.verify(stored, submitted_password)
    if ph.check_needs_rehash(stored):         # parameters got stronger since?
        stored = ph.hash(submitted_password)  # upgrade transparently
except VerifyMismatchError:
    deny_login()

Notice what the stored string contains: the algorithm, the version, the parameters, the salt and the hash. That self-describing format is what makes the last part possible. You can raise the cost later and upgrade each user's hash the next time they log in successfully, with no mass reset and no second column in the database.

Work factors, peppers and rate limiting

The work factor is the knob that makes the hash slow. The guidance is to tune it so a single hash takes as long as your login latency budget allows on your actual hardware, then revisit it every year or two as hardware gets faster. The parameters in that Argon2 string (m for memory in kibibytes, t for iterations, p for parallelism) are the knobs.

A pepper is a secret value, the same for all users, mixed into the hashing but stored somewhere other than the database, typically a secrets manager or a hardware security module. If the database leaks on its own, the hashes are useless without the pepper. It is an extra layer, not a replacement for a slow hash, and it makes rotation a chore, so use it deliberately.

Rate limiting is the part that has nothing to do with cryptography and stops most real attacks anyway. Offline cracking needs a leaked database. Online guessing needs your login endpoint to accept unlimited attempts. Throttle by account and by source, add progressive delays, and log the failures somewhere your monitoring can see them. If you have moved to single sign-on with OAuth2 and OpenID Connect, your identity provider should be doing this for you, but check.

When the database leaks anyway

Assume it will. The Zero Trust habit of assuming breach applies to your own users table. Good hashing means a leak is an incident rather than a catastrophe, but you still have work to do:

  1. Rotate any pepper and any credentials that lived near the database.
  2. Force a password reset for affected accounts, with a clear explanation of what leaked and what did not.
  3. Assume the weakest and most reused passwords fall first, and prioritise those users.
  4. Write down what happened, including the hashing parameters in use, so the next person does not have to guess.

The last defence is refusing bad passwords in the first place. NIST's guidance has for years been to check new passwords against lists of known-breached ones rather than enforcing baroque complexity rules. The k-anonymity API behind Have I Been Pwned lets you do this without sending the password anywhere: you send the first five characters of its SHA-1 hash, receive a list of matching suffixes, and compare locally. Users who pick a password that has already appeared in a breach get a polite no.

What to remember

  • Hashing is one-way; encryption is reversible. Passwords should be hashed, never encrypted.
  • MD5, SHA-1 and SHA-256 are fast, which is precisely why they are wrong for passwords.
  • Use a per-user random salt, which any decent library generates for you.
  • Use a slow, memory-hard function: Argon2id first, scrypt or bcrypt if you must.
  • Tune the work factor to your hardware and raise it over time using transparent rehashing.
  • Rate limit logins, consider a pepper, and reject passwords already in known breaches.

Further reading

Authentication is a recurring theme in security research generally, where the working assumption is that every credential will eventually leak and the architecture has to survive it. If your site can email me my password, it has not survived anything yet.

Shariar Kabir
Shariar Kabir

Researcher in AI and cybersecurity, School of Computing, Mathematics and Physics, University of Portsmouth. UK Global Talent Visa, endorsed by UKRI. About · Google Scholar · LinkedIn