# how to store passwords: hashing done right

## TL;DR

Hash passwords with Argon2id (bcrypt is fine too), one unique random salt per password, and let the library handle the salt and the comparison. Never roll your own scheme, never use a fast hash like SHA-256 alone, and never store passwords in plain text or reversible encryption. When in doubt, use the library defaults; they are chosen by people who think about this full time.

```text
how to store passwords: hashing done right
```

## Use this when

- You are implementing login for the first time
- You inherited a codebase and need to check the password storage
- You are migrating from a weak hash (MD5, SHA-1, plain SHA-256) to a proper one
- A security review asked "how are passwords stored" and you want the right answer

## Not for this skill when

- You need passwordless auth (passkeys, magic links; different skill)
- You are choosing a password policy for users (related but separate)
- You need to hash API keys or tokens (those get different treatment; see the variant below)
- You want the math behind key derivation functions (not covered here)

## Steps

### 1. Pick Argon2id (or bcrypt)

Argon2id is the current best choice: memory-hard, resistant to GPU cracking rigs, and the OWASP recommendation. bcrypt remains acceptable, especially where Argon2 libraries are awkward to deploy. scrypt is fine too. What is not fine: MD5, SHA-1, or plain SHA-256, which a single GPU chews through at billions of guesses per second.

```bash
python3 -m pip install argon2-cffi
```

Expected: the package installs cleanly. (argon2-cffi 23.x; on Node use the argon2 package, on Go the x/crypto argon2 package, on Ruby the argon2 gem.)

### 2. Hash with a unique salt per password

Let the library generate the salt; you never create or store it separately. The hashed output already embeds the salt, the parameters, and the version.

```python
from argon2 import PasswordHasher
ph = PasswordHasher()
stored = ph.hash([user-supplied password])
```

Expected: `stored` looks like a long encoded string starting with `$argon2id$`. Hash the same password twice and confirm the two outputs differ; identical outputs mean the salt is broken or missing.

### 3. Verify with the library, not with ==

Use the library's verify function, which compares in constant time. Never compare hashes with == in your own code, and never "decrypt" anything; password hashes are one-way by design.

```python
ph.verify(stored, [user-supplied password])
```

Expected: verify passes silently on a match and raises on a mismatch. Catch the mismatch and return a generic "invalid credentials" response; do not say whether the username or the password was wrong.

### 4. Add a pepper if you can manage it

A pepper is a secret value mixed into every hash, stored separately from the database (HSM, vault, or at minimum a different host). It means a stolen database alone is not enough to start cracking passwords offline.

```bash
printf 'pepper lives in: [vault path]\npepper rotation runbook: [link]\n' | tee pepper-notes.txt
```

Expected: the pepper lives somewhere the database backup does not go, and rotating it is a documented procedure. If managing a pepper is beyond your ops maturity, skip it; Argon2id with unique salts is already solid. A pepper you lose locks out every user.

### 5. Plan the migration for legacy hashes

If you have old MD5 or SHA-1 hashes, do not mass-reset everyone on day one. On next login, verify against the old hash, then immediately re-hash with Argon2id and store the new value. Flag accounts that never log in for a forced reset after a deadline.

```bash
printf 'legacy hashes remaining: [N] ([P] percent)\n' | tee migration-status.txt
```

Expected: the share of legacy hashes shrinks with every login, and you can report the percentage remaining. After the deadline, expire whatever is left; a hash nobody migrates is a hash nobody uses.

### Variant: work factors and tuning

Argon2id library defaults are sane for interactive logins: roughly a fraction of a second per hash. Raise memory and time cost until hashing takes about half a second on your production hardware, then stop; slower logins buy little extra security and annoy users. Re-tune every couple of years as hardware improves. For bcrypt, cost 12 is the usual floor.

### Variant: API keys and tokens are not passwords

Do not bcrypt your API keys; you need to look them up by value, which password hashing breaks. Store a SHA-256 hash of the key for lookup plus the metadata alongside, and show the raw key to the user exactly once at creation. Different problem, different tool.

### Variant: checking an existing implementation quickly

Look for three things: the hash function name (argon2, bcrypt, scrypt good; md5, sha1, sha256-alone bad), a per-password salt (the same password hashed twice must differ), and verify-via-library (no hand-rolled comparison). If all three check out, you are done; if any fail, schedule the migration.

## Why this happens

Password hashing exists because databases leak: backups, SQL injection, insider access, discarded drives. A fast hash lets an attacker test billions of candidate passwords per second against the stolen file, and most human passwords fall in hours. Memory-hard hashes like Argon2id force the attacker to spend real memory per guess, which is what GPUs cannot cheaply parallelize. The salt stops rainbow tables and makes identical passwords hash differently. None of this is new; it is just frequently skipped under deadline pressure.

## Edge cases and pitfalls

- Truncation: bcrypt silently truncates past 72 bytes; if you accept long passphrases with bcrypt, pre-hash or prefer Argon2id.
- Unicode: normalize passphrases before hashing so the same phrase typed on different devices hashes the same.
- Do not cap password length low to dodge the bcrypt limit; long passphrases should be welcome.
- Timing leaks on username lookup: keep the error generic and response times similar for "user not found" vs "wrong password".
- Logging: never log passwords or hashes, even at debug level; grep your logs for the field names after shipping.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_8nIY_9zCmTgW7cexadTRzg
