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.

  • Security
  • $password-storage
  • 186 lines
  • 3 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- password-storage

Installs to ~/.agents/skills/password-storage/ for every project on this machine.

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

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.

~/.agents/skills/password-storage/SKILL.md

--- name: password-storage description: Stores user passwords with Argon2id, bcrypt, scrypt or PBKDF2 at current OWASP parameters, adds an HMAC pepper held outside the database, and… license: MIT ---

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

  1. Read AGENTS.md for language, framework and migration tooling.
  2. 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'
  1. 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.

AlgorithmMinimum parametersNotes
Argon2idm=19456 KiB (19 MiB), t=2, p=1Default choice. Alternative: m=47104 (46 MiB), t=1, p=1
scryptN=2^17, r=8, p=1When Argon2 bindings are unavailable
bcryptcost 12 (never below 10)Only uses the first 72 bytes; see below
PBKDF2-HMAC-SHA256600,000 iterationsOnly 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:

FrameworkSetting
DjangoPut Argon2PasswordHasher first in PASSWORD_HASHERS (needs argon2-cffi); old hashers stay listed so they still verify and auto-upgrade
Spring SecurityArgon2PasswordEncoder.defaultsForSpringSecurity_v5_8() inside a DelegatingPasswordEncoder
Railshas_secure_password (bcrypt); set BCrypt::Engine.cost per environment
LaravelHASH_DRIVER=argon2id in config/hashing.php; Hash::needsRehash()
ASP.NET Core IdentityPasswordHasherOptions.IterationCount (PBKDF2) - raise it; Identity rehashes on login
Gogolang.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_id column 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/bcrypt since 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:

  1. 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.
  2. Wrap now. For every legacy row, compute argon2id(legacy_hash) immediately and mark the row scheme = '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_rehash is 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.py after 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; library verify functions 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.yamlgenerated from this page

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.py101 lines, 3.9 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem