Storing Passwords Properly

Argon2id, bcrypt, and why every fast hash — including SHA-256 — is the wrong tool here.

If you accept passwords, you will one day be judged on how you stored them. There is a correct answer and it is short.

Use a password hash, not a hash

SHA-256 is designed to be fast. That is exactly wrong for passwords: a GPU computes billions of SHA-256 hashes per second, so a leaked table of them is a leaked table of passwords. A password hashing function is deliberately slow and memory-hungry.

AlgorithmVerdict
Argon2id✅ current first choice
scrypt✅ good
bcrypt✅ fine; caps input at 72 bytes
PBKDF2⚠️ acceptable where required by policy
SHA-256 / SHA-1 / MD5❌ never, salted or not

Parameters

For Argon2id, a reasonable starting point is 19 MiB of memory, 2 iterations and parallelism 1 — then tune so verification takes roughly 250ms on your hardware. For bcrypt, a cost of 12 and rising over time. Salting is automatic in all of these; it is part of the output string.

Rehash on login

The stored hash records the parameters it was made with. When you raise them, verify with the old ones and immediately rehash with the new — you have the plaintext at that moment and never will again.

Policy that helps

  • Minimum length, not composition rules. 12+ characters beats "one uppercase, one symbol", which produces Password1! and nothing else.
  • Check against known-breached passwords (the Pwned Passwords k-anonymity API sends only a hash prefix).
  • Allow the full Unicode range and long passphrases, and let password managers paste.
  • No forced rotation without a reason to believe there was a compromise — NIST dropped that guidance because it drives users to predictable increments.

Never

Log the password. Return it. Email it. Store it recoverably. Truncate it silently. Send it to an analytics tool by including the whole form body in an error report.

Example

Example · javascript
import argon2 from 'argon2';

const OPTIONS = {
  type: argon2.argon2id,
  memoryCost: 19456,   // 19 MiB
  timeCost: 2,
  parallelism: 1,
};

const hash = await argon2.hash(password, OPTIONS);
// $argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
//  ^algorithm  ^parameters        ^salt        ^hash
// Everything needed to verify — and to notice when the parameters are stale.

const ok = await argon2.verify(hash, password);

When to use it

  • A database leak exposes Argon2id hashes at 19 MiB cost, making mass offline cracking economically impractical rather than a weekend job.
  • An app raises its bcrypt cost from 10 to 12 and transparently rehashes each user's password the next time they log in.
  • A signup form rejects a password found in the Pwned Passwords set, stopping credential stuffing before the account exists.

More examples

Verify, rehash and check against breaches

Failing open when the breach API is unreachable is deliberate: a third-party outage must not stop people creating accounts.

Example · javascript
import argon2 from 'argon2';
import { createHash } from 'crypto';

const OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };

export async function verifyAndUpgrade(user, password) {
  const ok = await argon2.verify(user.passwordHash, password).catch(() => false);
  if (!ok) return false;

  // We hold the plaintext exactly here and nowhere else — upgrade now or never.
  if (argon2.needsRehash(user.passwordHash, OPTIONS)) {
    await db.users.update(user.id, {
      passwordHash: await argon2.hash(password, OPTIONS),
    });
  }
  return true;
}

// Pwned Passwords: k-anonymity. Only the first 5 hash characters are sent, so
// the service never learns the password or even its full hash.
export async function isBreached(password) {
  const sha1 = createHash('sha1').update(password).digest('hex').toUpperCase();
  const prefix = sha1.slice(0, 5);
  const suffix = sha1.slice(5);

  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
    headers: { 'Add-Padding': 'true' },      // uniform response size
    signal: AbortSignal.timeout(2000),
  }).catch(() => null);

  if (!res?.ok) return false;                // fail open: never block signup on an outage

  return (await res.text()).split('\n')
    .some((line) => line.split(':')[0] === suffix);
}

export async function validatePassword(password) {
  if (password.length < 12) throw new Error('Use at least 12 characters.');
  if (password.length > 200) throw new Error('That is longer than we can accept.');
  if (await isBreached(password)) {
    throw new Error('That password has appeared in a data breach. Please choose another.');
  }
}

The equivalents in PHP and Python

Every mainstream language ships a correct implementation. Writing your own key-stretching loop is never the right call.

Example · php
<?php
// PHP — Argon2id when the extension is available, bcrypt otherwise
$hash = password_hash($password, PASSWORD_ARGON2ID, [
    'memory_cost' => 19456,   // KiB
    'time_cost'   => 2,
    'threads'     => 1,
]);

if (password_verify($password, $user['password_hash'])) {
    // Same rehash-on-login pattern
    if (password_needs_rehash($user['password_hash'], PASSWORD_ARGON2ID, $options)) {
        $new = password_hash($password, PASSWORD_ARGON2ID, $options);
        updatePasswordHash($user['id'], $new);
    }
}

// ❌ Every one of these is a finding in a security review
// md5($password)
// sha1($password . $salt)
// hash('sha256', $password)
// base64_encode($password)      // not even a hash

# ---------------------------------------------------------------
# Python — argon2-cffi
# from argon2 import PasswordHasher
# from argon2.exceptions import VerifyMismatchError, InvalidHash
#
# ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
# hashed = ph.hash(password)
#
# try:
#     ph.verify(hashed, password)
#     if ph.check_needs_rehash(hashed):
#         save(ph.hash(password))
# except (VerifyMismatchError, InvalidHash):
#     reject()

Discussion

  • Be the first to comment on this lesson.