Authentication Security skill for Codex

Close every side door into the account. Codex inventories every route into an account, including reset, email change, MFA disable and OAuth callbacks, and holds each one to NIST 800-63B rev 4 rules. It checks enumeration through messages and timing, adds per-account and per-source throttling instead of naive lockout, tests MFA bypass paths, and writes failing tests for each gap before fixing it.

  • Security
  • $authentication-security
  • 197 lines
  • 2 files
  • ~2,300 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- authentication-security

Installs to ~/.agents/skills/authentication-security/ 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 $authentication-security, 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

Hardens login, sign-up, MFA and account recovery flows against credential stuffing, brute force, account enumeration, phishing and recovery takeover, following NIST SP 800-63B rev 4 and OWASP ASVS 5.0. Covers passkeys/WebAuthn, TOTP, rate limits and lockout, breached-password checks, session fixation and step-up auth. Use when building or reviewing a login form, adding MFA or passkeys, designing password reset or email change, or after a credential-stuffing spike.

Use it when

  • Building or reviewing login, sign-up or password reset endpoints
  • Adding MFA, passkeys or recovery codes
  • Responding to a spike in failed logins or account takeovers
  • Designing email change or other sensitive account actions

Use something else when

  • You only need to choose or migrate a password hashing algorithm: use password-storage
  • You are reviewing an OAuth or OpenID Connect integration: use oauth-security-review
  • You need cookie session storage design rather than login hardening: use session-auth

How to check the result

Tests show identical responses for unknown and known users, a 429 after repeated OTP guesses, and session ID rotation on login; a reset link always uses the configured base URL.

The mistake it prevents

Locking down the login form while the password reset, email change or API token path still lets an attacker in with less proof.

~/.agents/skills/authentication-security/SKILL.md

--- name: authentication-security description: Hardens login, sign-up, MFA and account recovery flows against credential stuffing, brute force, account enumeration, phishing and recovery takeover,… license: MIT ---

Done well means: an attacker with a list of leaked passwords, a phishing page, or access to the victim’s email inbox for ten minutes still cannot take the account, while a real user can always get back in through a recovery path that is no weaker than login itself.

Before changing anything

  1. Read AGENTS.md for the framework, auth library and test command.
  2. Map every way into an account, not just the login form:
rg -n -i "login|signin|sign_in|authenticate|reset|forgot|recover|magic.?link|otp|totp|webauthn|passkey|change.?email|verify" \
  --type-add 'web:*.{ts,tsx,js,py,rb,go,java,kt,php,cs}' -t web -l
  1. List each entry point in a table before reviewing: login, sign-up, password reset request, reset confirm, email change, MFA enrol, MFA disable, recovery codes, OAuth callback, API token login, admin impersonation. The weakest one is the real login.
  2. Find out whether auth is hand-rolled or delegated (Auth.js, Devise, Django allauth, Spring Security, Keycloak, Clerk, Cognito). If delegated, audit configuration, not the library internals.

Baseline requirements

AreaRequirement
Password lengthMinimum 15 chars when the password is the only factor, 8 when always paired with MFA (800-63B-4). Accept at least 64. Allow spaces and all Unicode; normalise with NFKC before hashing
Composition rulesNone. No forced digits/symbols, no periodic expiry. Force a change only on evidence of compromise
Breached passwordsReject passwords found in a breach corpus (HIBP k-anonymity range API, or an offline list when network is restricted)
StorageArgon2id or bcrypt per $password-storage
Failed attemptsPer-account throttling plus per-IP and global anomaly limits; 800-63B caps consecutive failures at 100 per account before a stronger control
ErrorsOne generic message for unknown user, wrong password, locked or unverified account
SessionNew session ID after login and after privilege change; old one invalidated
MFAOffer passkeys first, TOTP second; SMS only as a fallback with risk noted
RecoverySingle-use, short-lived, hashed tokens; never weaker than login
Sensitive actionsRe-authenticate (step-up) for email change, password change, MFA disable, payout details

Workflow

- [ ] 1. Inventory entry points (table above) and the auth stack
- [ ] 2. Check enumeration on login, sign-up and reset (responses AND timing)
- [ ] 3. Check throttling on every credential-accepting endpoint
- [ ] 4. Check session handling at login, logout, password change
- [ ] 5. Check MFA enrolment, verification and bypass paths
- [ ] 6. Check recovery and email-change flows end to end
- [ ] 7. Write failing tests for each gap, then fix, then re-run
- [ ] 8. Report findings with severity and residual risk

Account enumeration

Enumeration turns a credential-stuffing list into a targeted one. Check three channels:

  • Message: “Invalid email or password” for every failure. Reset request always says “If an account exists, we sent a link.”
  • Status and shape: same HTTP status, same JSON keys, same redirect target.
  • Timing: when the user is unknown, still run a dummy hash verify so the response takes as long as a real one.
DUMMY_HASH = ph.hash("dummy-password-for-timing")

def authenticate(email: str, password: str) -> User | None:
    user = users.find_by_email(normalise_email(email))
    if user is None:
        try:
            ph.verify(DUMMY_HASH, password)   # equalise timing
        except VerifyMismatchError:
            pass
        return None
    try:
        ph.verify(user.password_hash, password)
    except VerifyMismatchError:
        return None
    return user

Sign-up is the hardest place to hide existence. Accepted pattern: always respond “check your email”, and send the existing user a “someone tried to register with your address” email instead of creating a duplicate.

Throttling and lockout

Hard lockout after N failures lets anyone lock out any user. Prefer escalating friction:

SignalResponse
5 failures on one account in 15 minExponential delay per account (1 s, 2 s, 4 s … capped)
10+ failures on one accountRequire CAPTCHA or email verification for the next attempt
One IP or ASN failing across many accountsThrottle that source; this is stuffing, not a forgetful user
Global failure ratio jumps (e.g. 3x baseline)Alert; enable challenge for all logins
Success after many failuresNotify the user; consider forcing MFA or reset

Key throttles on the normalised account identifier and on the source, both. Store counters in Redis or the DB, not process memory, or multiple instances reset each other. Apply the same limits to reset confirm, OTP verify and recovery-code endpoints: a six-digit TOTP is brute-forceable at 1,000,000 guesses without a limit. For the limiter implementation itself, see $api-rate-limiting.

MFA

FactorPhishing resistantNotes
Passkey / WebAuthnYesOrigin-bound. Default for new builds. Use a maintained library (SimpleWebAuthn, py_webauthn, webauthn4j, go-webauthn)
TOTP (RFC 6238)No30 s step, accept +/-1 step, reject reuse of an already-accepted code, encrypt the seed at rest
Push approvalNoRequire number matching to stop fatigue attacks
SMS / voice OTPNoSIM swap risk; fallback only
Recovery codesn/a10 single-use codes, about 50 bits each, stored hashed, shown once

Common bypasses to test:

  • MFA checked on the login page but the session is already fully authenticated before the second step. The pre-MFA session must only be able to call the MFA endpoint.
  • The “remember this device” cookie is not bound to the user or never expires.
  • MFA can be disabled, or the email changed, without re-entering a factor.
  • API tokens or OAuth logins skip MFA that the password login enforces.
  • The OTP verify endpoint has no attempt limit.

Recovery and email change

- [ ] Reset token: >= 128 bits from a CSPRNG, stored as SHA-256 hash, single use
- [ ] Expires in 15-60 minutes; issuing a new token invalidates older ones
- [ ] Reset link built from a configured base URL, never the request Host header
- [ ] Reset page sends Referrer-Policy: no-referrer (token is in the URL)
- [ ] Successful reset revokes all sessions and refresh tokens, notifies the user
- [ ] Reset does not bypass MFA: ask for the second factor or a recovery code after it
- [ ] Email change: confirm via the NEW address, notify the OLD address with an undo link
- [ ] Security questions are not used

Host header poisoning is the classic: request.host used to build the reset URL lets an attacker send the victim a link to the attacker’s domain carrying a valid token.

Sessions at the auth boundary

  • Rotate the session identifier on login (fixation) and on MFA completion.
  • Cookies: Secure, HttpOnly, SameSite=Lax (or Strict for admin apps), __Host- prefix when no subdomain sharing is needed.
  • Logout invalidates server-side state, not just the cookie.
  • Password change offers “sign out other sessions” and does it by default after a reset.
  • For session storage design, see $session-auth; for JWT specifics, $jwt-auth.

Verification

Write tests that fail before the fix:

def test_login_does_not_enumerate(client):
    a = client.post("/login", data={"email": "[email protected]", "password": "x" * 16})
    b = client.post("/login", data={"email": "[email protected]", "password": "wrong" * 4})
    assert a.status_code == b.status_code
    assert a.json() == b.json()

def test_otp_attempts_are_limited(client, logged_in_pre_mfa):
    for _ in range(10):
        client.post("/mfa/verify", data={"code": "000000"})
    r = client.post("/mfa/verify", data={"code": valid_code()})
    assert r.status_code == 429

Also measure timing for unknown vs known users over 50 requests each on a local build; medians should be within a few milliseconds. Run tests only against local or staging environments the user controls.

Output template

## Authentication review: <app>
Stack: <framework / auth library> | Entry points reviewed: <n>

| # | Severity | Entry point | Finding | Evidence | Fix |
|---|---|---|---|---|---|
| 1 | High | POST /password/reset | Reset URL uses Host header | app/auth/reset.py:41 | Use settings.BASE_URL |

### Passed checks
### Residual risk and follow-ups

Gotchas

  • Normalise identifiers once (trim, lowercase the domain, NFKC) and use the same function at sign-up, login and reset, or [email protected] and [email protected] become two accounts.
  • Rate limits keyed only on IP do nothing against residential-proxy stuffing; key on the account too.
  • Truncating passwords silently (bcrypt at 72 bytes, DB column length) makes long passphrases weaker than users think.
  • Magic links in email are a password reset on every login: same token rules apply.
  • Admin “log in as user” must be audited and must not reuse the user’s MFA state.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

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: "Authentication Security"
  short_description: "Close every side door into the account"
  default_prompt: "$authentication-security Review our login and password reset flow for security holes"

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