Skip to content

Hashing passwords (not encrypting them)

Why passwords are hashed, never encrypted; why fast hashes like SHA-256 are wrong for this job; and how Argon2id / bcrypt / scrypt buy you decades against an attacker with the database.

Updated May 25, 2026

Hash, not encrypt

Encryption is reversible by design — there is a key that turns ciphertext back into plaintext. Storing passwords encrypted means: somewhere on the server, the key exists. An attacker who reaches the database eventually also reaches the key (config file, environment variable, KMS handle), and your passwords decrypt in bulk.

Hashing is one-way by design. There is no key, and no algorithm to invert it. The server stores h = H(password). To verify a login, it computes H(submitted) and compares. To attack the database, the adversary must guess passwords and re-hash each guess — the work scales with the number of guesses, not the size of the dump.

Concept

Password hashing

A one-way function that turns a password into a fixed-length value the server can verify against, but cannot reverse — and that is deliberately slow, so guessing at scale is expensive.

The “slow” part is what separates a password hash from a general-purpose cryptographic hash like SHA-256. SHA-256 is built to be fast; that’s exactly the property an attacker with a GPU needs. A password hash is built to take measurable wall-clock time and memory per call, so an attacker’s guess rate stays low even with a warehouse of hardware.

Why SHA-256 is wrong for passwords

SHA-256 (and the rest of the SHA-2 family, MD5, SHA-1) are general-purpose cryptographic hashes. They are designed to be fast — gigabytes per second on a modern CPU, far more on a GPU. That speed is a feature for file integrity, signatures, and content addressing. It is a catastrophe for password storage.

Concrete numbers: a single high-end GPU can compute on the order of 10 billion SHA-256 hashes per second. A 9-character lowercase-alphanumeric password has 36⁹ ≈ 10¹⁴ possibilities — about 3 hours on one GPU. Add salt and the attacker still finishes the same password in 3 hours; salt doesn’t slow guessing, it just prevents pre-computation (rainbow tables) and forces guessing to happen per-user.

A purpose-built password hash is tuned so a single guess takes 100ms and tens of megabytes of memory. That same 9-character password becomes years per GPU, and the memory requirement means you can’t pack thousands of attempts in parallel on one chip.

The three things a password hash must have

  1. One-way

    Computing H(password) is easy; recovering password from H(password) is computationally infeasible. Every cryptographic hash satisfies this — it’s table stakes.

  2. Salted, per-user

    A random per-user salt is concatenated with the password before hashing. The salt is stored alongside the hash, in clear. Salts prevent rainbow tables (precomputed dictionaries) and force the attacker to re-run the entire guessing campaign for every user, instead of once for the whole database.

  3. Slow, and tunable

    The hash takes measurable wall-clock time per call — typically tuned so a login costs 100–500ms on the server. The cost parameters (iterations, memory, parallelism) are stored inside the hash string, so the server can increase them later as hardware gets faster, without breaking existing hashes.

The third property is what makes Argon2id, bcrypt, and scrypt different from SHA-256-with-salt. They are KDFs (key derivation functions) deliberately constructed to be slow and, in the modern designs, memory-hard — meaning each guess requires holding tens of megabytes in RAM, which is what defeats GPUs and ASICs (those have abundant compute but thin per-core memory bandwidth).

The choice: which password hash

Decision
Chose: Argon2id for new code; bcrypt is acceptable for existing systems
2026-05-25

Argon2id has the strongest defence against GPU/ASIC attacks of any widely-available KDF, and its tunable parameters survive hardware progress better than bcrypt's single cost factor. Bcrypt remains a defensible choice — the difference is meaningful but not enormous, and an existing bcrypt deployment is not a security incident.

Argon2id
Pros
  • Winner of the 2015 Password Hashing Competition
  • Memory-hard — defeats GPU and ASIC attacks
  • Tunable memory, time, parallelism independently
  • Recommended by OWASP for new systems
Cons
  • Newer — library support is occasionally patchy in older ecosystems
bcrypt
Pros
  • Battle-tested since 1999
  • Available in every language
  • Tunable cost factor
Cons
  • Not memory-hard — modern GPUs attack it more efficiently than Argon2id
  • Max password length 72 bytes (subtle truncation bug if not pre-hashed)
  • Cost factor knob is logarithmic and coarse
scrypt
Pros
  • Memory-hard like Argon2
  • Standardised in RFC 7914
Cons
  • Harder to tune correctly — single combined cost parameter mixes memory and time
  • Argon2id supersedes it in most new designs
PBKDF2
Pros
  • FIPS-approved — required in some regulated environments
Cons
  • Not memory-hard — falls to GPUs
  • Should only be picked when compliance mandates it

Suggested starting parameters

Per-call wall-clock time on a modern server-class CPU should land in the 100–500ms band — below that and you’re under-paying for security; above that and the auth path drags. Tune by measuring on your hardware.

AlgorithmParametersNotes
Argon2idm=64 MiB, t=3, p=4RFC 9106’s “second recommended” config. OWASP also accepts lighter sets like m=19 MiB, t=2, p=1 if memory is constrained. m is memory per call, t iterations, p parallelism.
bcryptcost=12Each +1 doubles the time; 12 is ~200–300ms on a 2026 server-class CPU.
scryptN=2^17, r=8, p=1N is the work factor; doubles ≈ doubles cost and memory.

What a stored hash looks like

Modern password hash libraries produce a self-describing string — the algorithm, parameters, salt, and hash all packed together. Argon2id:

$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
│         │    │              │           │
│         │    │              │           └── base64 hash output
│         │    │              └── base64 salt
│         │    └── parameters: m=64 MiB, t=3, p=4
│         └── algorithm version
└── algorithm identifier

bcrypt:

$2b$12$4tj7BYY72GuvfnY1rz0wPOrYZiCqNVAaEPsJjOEyB4zuKV9jcqyHm
│ │  │  │                          │
│ │  │  │                          └── 23-byte base64 hash
│ │  │  └── 22-byte base64 salt
│ │  └── cost factor (12 = 2^12 iterations)
│ └── algorithm variant (2b is current)
└── identifier

Because parameters live in the string, the server can read them when verifying, and notice when the stored cost is below the current target — at which point it re-hashes the password with the new parameters (using the plaintext from the in-flight login) and updates the row. This is how a system gracefully upgrades its cost over years without forcing password resets.

In code: verify and lazy-upgrade

PasswordService.java
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;

class PasswordService {
  // Explicit params — not the library default, which is lighter (m=16 MiB, t=2, p=1).
  // Argon2PasswordEncoder(saltLength, hashLength, parallelism, memory, iterations)
  // Memory is in KiB, so 65536 = 64 MiB.
  private final Argon2PasswordEncoder encoder =
      new Argon2PasswordEncoder(16, 32, 4, 65536, 3);

  String hash(String password) {
    return encoder.encode(password);
  }

  boolean verify(String submitted, String stored) {
    return encoder.matches(submitted, stored);
  }

  boolean needsRehash(String stored) {
    return encoder.upgradeEncoding(stored);
  }
}

The login path:

AuthController.java
boolean login(String email, String submitted) {
  var user = users.findByEmail(email);
  if (user == null) return false;
  if (!passwords.verify(submitted, user.passwordHash())) return false;

  if (passwords.needsRehash(user.passwordHash())) {
    var fresh = passwords.hash(submitted);
    users.updatePasswordHash(user.id(), fresh);
  }
  return true;
}

The highlighted block is the upgrade path. It costs one extra hash on the login where the upgrade happens, then the new parameters stick.

Step through the full flow to see when each call lands and which path the upgrade adds on top of a plain verify:

Login with lazy cost-factor upgrade

The verify path is steps 1–5. Steps 6–9 only happen when the stored hash was produced with parameters below the current target — they cost one extra hash on that single login, then the new parameters stick for future logins.

0 / 10
ClientAuthCtrlPwdServiceUserRepo

The footguns

Anti-pattern
Rolling your own with SHA-256 plus salt plus a few iterations
Smell

“We hash with SHA-256, add a salt, loop 10,000 times.” Or worse: “We use SHA-256 with a pepper from config.”

Why it happens

SHA-256 is the hash a developer knows. Salt and iteration sound like the right ideas. The construction is a homemade PBKDF2 — and PBKDF2 itself is what OWASP now recommends only under compliance constraints.

Fix instead

Use an off-the-shelf password hashing library. Every mainstream language has Argon2id and bcrypt bindings maintained by people who understand the side-channel surface. Re-using SHA-256 means you’ll get the comparison wrong (timing attacks), the salt wrong (too short, predictable), or the cost wrong (10,000 iterations is ~1ms on a GPU). Don’t.

Other ways this goes wrong:

  • Truncating bcrypt at 72 bytes. Bcrypt only considers the first 72 bytes. If you allow long passphrases, pre-hash with SHA-256 and base64 the result before passing to bcrypt — or use Argon2id, which has no such limit.
  • Comparing with ==. A string == string comparison short-circuits on the first mismatch, leaking byte-by-byte timing. Use a constant-time comparator. Library matches() calls do this already; hand-rolled checks usually don’t.
  • Storing a pepper alongside the hash. A “pepper” is a secret salt shared across users, stored in config rather than the database. It’s a defensible defence-in-depth layer only if the secret really lives somewhere the attacker doesn’t get with the database dump. If the pepper is in the same repo or the same VM, it’s not a layer.
  • Logging the plaintext. Audit logs, request loggers, error tracers — any of them will happily capture password=hunter2 if you let them. Mask at the framework level, not at every log call.

What to remember

Encryption belongs in 0015 PGP encryption: keys, signatures, and what they actually protect · System Design — for the things you do need to read back later. Passwords are not on that list.

Related