Password Storage skill for Codex
Argon2id now, legacy MD5 rows gone for good. Codex first profiles what your users table really holds with a bundled script that counts hash schemes and flags weak or under-tuned ones. It then implements Argon2id or bcrypt at current OWASP parameters, adds a versioned pepper kept outside the database, and migrates legacy MD5 or SHA-1 rows by wrapping them immediately and rehashing on login, so dormant accounts are protected too.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- password-storage Installs to ~/.agents/skills/password-storage/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install password-storage The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project password-storage Installs to .agents/skills/password-storage/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/password-storage.zipunzip -o password-storage.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) password-storage PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $password-storage, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Stores user passwords with Argon2id, bcrypt, scrypt or PBKDF2 at current OWASP parameters, adds an HMAC pepper held outside the database, and migrates legacy MD5, SHA-1, salted SHA or unsalted hashes without forcing a mass reset, using rehash-on-login and hash wrapping. Use when writing sign-up or login code, auditing how a users table stores credentials, tuning hash cost, or upgrading an app that still has weak or mixed password hashes.
Use it when
- Writing sign-up or login code that stores passwords
- Auditing which hash schemes exist in a users table
- Migrating legacy or mixed password hashes
- Tuning hash cost after a hardware or traffic change
Use something else when
- You need the wider login, MFA and recovery review: use authentication-security
- You are hashing or encrypting data other than passwords: use cryptography-usage
How to check the result
identify_hashes.py reports zero weak or unknown rows after migration, a legacy fixture user logs in and ends with an $argon2id$ hash, and one verify takes 100-300 ms on production hardware.
The mistake it prevents
Relying on rehash-on-login alone, which leaves every dormant account on the old weak hash indefinitely.
Done well means: every stored credential is a slow, salted, memory-hard hash in a self-describing format; a stolen database alone does not let an attacker test guesses (pepper); and no user is left on a legacy hash, even ones who never log in again.
Inspect first
- Read
AGENTS.mdfor language, framework and migration tooling. - Find the hashing code and the column it writes:
rg -n -i "hash|bcrypt|argon|scrypt|pbkdf|md5|sha1|sha256|password_digest|set_password|check_password" -g '!*.lock' -g '!node_modules'
- Profile what is actually stored. Export only the hash column (never plaintext emails alongside it) and classify it:
psql "$DATABASE_URL" -Atc "select password_hash from users" > /tmp/hashes.txt
python3 scripts/identify_hashes.py /tmp/hashes.txt
The script reports counts per scheme (argon2id, bcrypt with cost, PBKDF2 with iterations, Django and passlib formats, bare hex MD5/SHA-1/SHA-256, unknown) and exits 1 if any weak or unknown formats exist. Delete the export afterwards.
Algorithm and parameters
Use the first row your platform supports well. Parameters below are the OWASP Password Storage Cheat Sheet minimums; raise them until a single verify costs roughly 100-300 ms on production hardware under expected login load.
| Algorithm | Minimum parameters | Notes |
|---|---|---|
| Argon2id | m=19456 KiB (19 MiB), t=2, p=1 | Default choice. Alternative: m=47104 (46 MiB), t=1, p=1 |
| scrypt | N=2^17, r=8, p=1 | When Argon2 bindings are unavailable |
| bcrypt | cost 12 (never below 10) | Only uses the first 72 bytes; see below |
| PBKDF2-HMAC-SHA256 | 600,000 iterations | Only when FIPS-140 compliance demands it |
Never: plain or salted SHA-*/MD5, encryption instead of hashing, a single global salt, or a home-built “hash N times” loop.
Reference implementations
Python (argon2-cffi; pip install argon2-cffi, needs network or an approved install):
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
def hash_password(pw: str) -> str:
return ph.hash(pw)
def verify_and_upgrade(user, pw: str) -> bool:
try:
ph.verify(user.password_hash, pw)
except (VerifyMismatchError, InvalidHashError):
return False
if ph.check_needs_rehash(user.password_hash):
user.password_hash = ph.hash(pw)
user.save(update_fields=["password_hash"])
return True
Node (argon2 package, native build):
import argon2 from "argon2";
const opts = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
export const hashPassword = (pw) => argon2.hash(pw, opts);
export async function verifyAndUpgrade(user, pw) {
if (!(await argon2.verify(user.passwordHash, pw))) return false;
if (argon2.needsRehash(user.passwordHash, opts)) {
await users.update(user.id, { passwordHash: await argon2.hash(pw, opts) });
}
return true;
}
Framework defaults worth using instead of custom code:
| Framework | Setting |
|---|---|
| Django | Put Argon2PasswordHasher first in PASSWORD_HASHERS (needs argon2-cffi); old hashers stay listed so they still verify and auto-upgrade |
| Spring Security | Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8() inside a DelegatingPasswordEncoder |
| Rails | has_secure_password (bcrypt); set BCrypt::Engine.cost per environment |
| Laravel | HASH_DRIVER=argon2id in config/hashing.php; Hash::needsRehash() |
| ASP.NET Core Identity | PasswordHasherOptions.IterationCount (PBKDF2) - raise it; Identity rehashes on login |
| Go | golang.org/x/crypto/bcrypt, or x/crypto/argon2.IDKey with your own PHC-string encoding |
Pepper
A pepper is a secret key applied to every password, stored outside the database (KMS, HSM, or a secrets manager injected as an environment variable). It turns a DB-only leak into an unusable one.
import hmac, hashlib, base64
def peppered(pw: str, pepper: bytes) -> str:
mac = hmac.new(pepper, pw.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(mac).decode() # base64 avoids NUL bytes and fits bcrypt's 72-byte limit
stored = ph.hash(peppered(pw, PEPPER_V1))
Rules:
- Store a pepper version alongside each hash (
pepper_idcolumn or a prefix) so you can rotate. Rotation happens on next login, like an algorithm upgrade. - Losing the pepper locks out every user. Back it up with the same care as a root key.
- Never commit it, never log it, never put it in the same database or backup set.
bcrypt specifics
- Input beyond 72 bytes is ignored. A 100-character passphrase and its first 72 bytes are the same password. Prehash with HMAC-SHA256 and base64 as above when you must keep bcrypt and accept long passwords. Do not prehash to raw bytes: bcrypt stops at NUL.
- Some libraries (Go
x/crypto/bcryptsince 2022, recent PHP) reject > 72 bytes with an error instead of truncating. Handle the error; do not let it become a 500 on sign-up.
Migrating legacy hashes
Two paths, used together:
- Rehash on login (default). On a successful verify with the old scheme, hash with the new one and save. Simple, but users who never return stay weak.
- Wrap now. For every legacy row, compute
argon2id(legacy_hash)immediately and mark the rowscheme = 'argon2id(md5)'. On login, compute the legacy hash of the input, verify through the wrapper, then replace with a plain Argon2id hash of the password. After the batch job, no weak hash exists at rest.
- [ ] Add scheme/version column (or rely on PHC prefixes) - zero-downtime migration
- [ ] Ship verify code that understands old, wrapped and new formats
- [ ] Batch-wrap legacy rows in chunks; log progress, not hashes
- [ ] Rehash-on-login replaces wrapped hashes over time
- [ ] After N months, expire remaining wrapped accounts to a forced reset (ask the user to confirm this policy)
- [ ] Remove legacy verify code once the count reaches zero
Run the batch-wrap against a staging copy first and confirm the user before running it on
production data. For the schema change itself, see $zero-downtime-migrations.
Verification
- Unit test: hash, verify correct password, reject wrong password,
needs_rehashis true for a hash made with lower parameters. - Migration test: a fixture user with an MD5 hash logs in successfully and ends up with a
$argon2id$hash; a wrapped user does the same. - Timing check: measure one verify on production-class hardware; keep it under about 300 ms and confirm login concurrency does not exhaust memory (19 MiB per concurrent verify adds up: 200 concurrent logins is ~3.8 GB).
- Re-run
scripts/identify_hashes.pyafter migration: weak and unknown counts are zero.
Output template
## Password storage assessment
Current: <scheme counts from identify_hashes.py>
Target: Argon2id m=<> t=<> p=<>, pepper <yes/no, where held>
Migration: <rehash-on-login | wrap + rehash>, batch size <>, ETA <>
Measured verify time: <ms> on <hardware>
Open risks: <accounts still on legacy, pepper backup status>
Gotchas
- Comparing hashes with
==in your own code leaks timing; libraryverifyfunctions are constant-time, so always call them. - Hashing on the client does not replace server hashing: the client hash becomes the password.
- Memory-hard parameters on small containers or Lambda can OOM under load; load-test
login before raising
m. - Logging request bodies on the auth endpoint stores plaintext passwords. Check middleware and APM body capture.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Password Storage"
short_description: "Argon2id now, legacy MD5 rows gone for good"
default_prompt: "$password-storage Our users table still has MD5 password hashes — migrate them to Argon2id" scripts/identify_hashes.py
#!/usr/bin/env python3
"""Classify stored password hashes by scheme and strength.
Usage: python3 identify_hashes.py <file> (one hash per line; '-' reads stdin)
Prints a count per scheme with parameters, never the hashes themselves.
Exit code: 0 if every hash is a modern scheme at or above OWASP minimums,
1 if any weak, under-parameterised or unknown hash is present, 2 on usage error.
"""
import re
import sys
from collections import Counter
ARGON = re.compile(r"^\$(argon2id|argon2i|argon2d)\$(?:v=\d+\$)?m=(\d+),t=(\d+),p=(\d+)\$")
BCRYPT = re.compile(r"^\$2[abxy]?\$(\d{2})\$[./A-Za-z0-9]{53}$")
SCRYPT_PHC = re.compile(r"^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$")
DJANGO_PBKDF2 = re.compile(r"^pbkdf2_(sha256|sha1)\$(\d+)\$")
PASSLIB_PBKDF2 = re.compile(r"^\$pbkdf2(?:-(sha256|sha512))?\$(\d+)\$")
WERKZEUG = re.compile(r"^(pbkdf2|scrypt):([a-z0-9]+)(?::(\d+))?(?::(\d+):(\d+))?\$")
HEX = re.compile(r"^[0-9a-fA-F]+$")
def classify(h: str) -> tuple[str, bool]:
"""Return (label, ok)."""
m = ARGON.match(h)
if m:
kind, mem, t, p = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
ok = kind == "argon2id" and ((mem >= 19456 and t >= 2) or (mem >= 47104 and t >= 1))
return f"{kind} m={mem} t={t} p={p}", ok
m = BCRYPT.match(h)
if m:
cost = int(m.group(1))
return f"bcrypt cost={cost}", cost >= 10
m = SCRYPT_PHC.match(h)
if m:
ln = int(m.group(1))
return f"scrypt N=2^{ln} r={m.group(2)} p={m.group(3)}", ln >= 17
m = DJANGO_PBKDF2.match(h)
if m:
algo, it = m.group(1), int(m.group(2))
return f"django pbkdf2_{algo} iterations={it}", algo == "sha256" and it >= 600_000
m = PASSLIB_PBKDF2.match(h)
if m:
algo, it = m.group(1) or "sha1", int(m.group(2))
return f"passlib pbkdf2-{algo} iterations={it}", algo != "sha1" and it >= 600_000
m = WERKZEUG.match(h)
if m:
kind = m.group(1)
if kind == "pbkdf2":
it = int(m.group(3) or 0)
return f"werkzeug pbkdf2:{m.group(2)} iterations={it}", it >= 600_000
return f"werkzeug scrypt n={m.group(2)}", int(m.group(2)) >= 2 ** 15
if h.startswith("argon2$") or h.startswith("bcrypt_sha256$") or h.startswith("bcrypt$"):
return "django " + h.split("$", 1)[0], True
if h.startswith("$1$"):
return "md5-crypt (weak)", False
if h.startswith("$5$") or h.startswith("$6$"):
return "sha-crypt (" + h[1] + ")", False
if h.startswith("md5$") or h.startswith("sha1$") or h.startswith("unsalted_"):
return "django legacy " + h.split("$", 1)[0] + " (weak)", False
if HEX.match(h):
names = {32: "md5 or ntlm", 40: "sha1", 64: "sha256", 128: "sha512"}
return f"bare hex {names.get(len(h), str(len(h)) + ' chars')} (weak)", False
return "unknown", False
def main() -> int:
if len(sys.argv) != 2:
print(__doc__.strip(), file=sys.stderr)
return 2
src = sys.stdin if sys.argv[1] == "-" else open(sys.argv[1], encoding="utf-8", errors="replace")
counts: Counter[str] = Counter()
bad: Counter[str] = Counter()
empty = 0
with src:
for line in src:
h = line.strip()
if not h:
empty += 1
continue
label, ok = classify(h)
counts[label] += 1
if not ok:
bad[label] += 1
total = sum(counts.values())
if total == 0:
print("no hashes found")
return 2
width = max(len(k) for k in counts)
for label, n in counts.most_common():
flag = "NEEDS UPGRADE" if label in bad else "ok"
print(f"{label:<{width}} {n:>8} {n / total:6.1%} {flag}")
if empty:
print(f"(skipped {empty} empty rows: accounts without a password, e.g. SSO-only)")
weak = sum(bad.values())
print(f"\ntotal={total} needs_upgrade={weak}")
return 1 if weak else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.