M09 — Authentication & Authorization
- Broken authentication is #1 on OWASP's list of critical web security risks
- A single mistake — MD5 passwords, weak JWT secret, missing
HttpOnlyflag — can expose every user account - Auth bugs are often invisible: the system works but is trivially bypassable
- Security-in-depth requires both correct concepts and correct implementation
| Mechanism | State | Best for | Main risk |
|---|---|---|---|
| Session cookies | Server-side | Traditional web apps, SSR | CSRF, session fixation |
| JWT (Bearer) | Stateless | APIs, SPA, mobile | Token theft, alg=none, weak secret |
| OAuth2 / OIDC | Delegated | Third-party login, SSO | Open redirect, CSRF on callback |
| API Keys | Stateless | M2M, developer APIs | Key exposure, no expiry |
| mTLS | Cert-based | Service-to-service | Cert rotation complexity |
- 1Sessions & Cookies — server-side state,
HttpOnly+Secure+SameSite, Redis storage, session fixation, CSRF tokens - 2JWT — header.payload.signature, signing algorithms (HS256/RS256/ES256), claims, verification, pitfalls (
alg:none, weak secrets) - 3Access + Refresh tokens — short-lived access, long-lived refresh, rotation, revocation in DB
- 4OAuth2 — Authorization Code + PKCE, Client Credentials, Device Code; OIDC layer for identity
- 5API Keys — CSPRNG generation, SHA-256 hashing at rest, prefix-based lookup, scoping, rotation
- 6RBAC / ABAC — role hierarchy, permission matrices, policy evaluation, OPA integration
- 7Password security — bcrypt, Argon2id parameters, timing-safe comparison, pepper strategy
- 1User submits credentials (username + password) over HTTPS
- 2Server verifies password hash, then calls
session_create()→ generates cryptographically random session ID - 3Server stores session data (user_id, role, created_at, expires_at) in Redis/DB keyed by session ID
- 4Server sends:
Set-Cookie: sid=<random_id>; HttpOnly; Secure; SameSite=Strict; Max-Age=3600 - 5Browser automatically includes cookie on every subsequent same-origin request
- 6Server looks up session ID in Redis → retrieves user context → authorizes request
- 7On logout: delete the session record from Redis (server-side invalidation) + clear cookie
| Attribute | Purpose |
|---|---|
HttpOnly | JS cannot read cookie → blocks XSS token theft |
Secure | Only sent over HTTPS → no cleartext leakage |
SameSite=Strict | Cookie not sent cross-site → blocks CSRF |
SameSite=Lax | Sent on top-level GET nav; blocks form-based CSRF |
Max-Age | Seconds until expiry (prefer over Expires) |
Path=/ | Scope to whole domain (usually what you want) |
Domain | Omit to restrict to exact domain (more secure) |
EXPIRE session:a3f9b2c1… 3600
# Lookup on every request (sub-millisecond) HGETALL session:a3f9b2c1…
sid=ATTACKER_KNOWN_ID.
Tricks victim into using that same ID (e.g., via URL parameter ?sid=...).
Victim logs in. Server associates victim's identity with attacker's known session ID.
Attacker is now authenticated as victim.
// 2. CRITICAL: destroy old session (session fixation defence) const char *old_sid = get_cookie(req, “sid”); if (old_sid) redis_del(old_sid);
// 3. Generate new session ID (128-bit random) uint8_t raw[16]; RAND_bytes(raw, sizeof(raw)); /* OpenSSL CSPRNG */ char sid[33]; bin2hex(raw, sid, 16);
// 4. Store in Redis with TTL redis_hset(“session:”, sid, “user_id”, user->id); redis_expire(“session:”, sid, 3600);
// 5. Set HttpOnly Secure SameSite=Strict cookie set_cookie(res, “sid”, sid, “HttpOnly; Secure; SameSite=Strict; Max-Age=3600”); }
| CSRF Defence | How it works | When to use |
|---|---|---|
SameSite=Strict | Cookie never sent cross-site | Best — use for auth cookies |
SameSite=Lax | Sent on top-level GET nav only | Good fallback, allows OAuth redirects |
| CSRF token (synchronizer) | Server issues random token, validates on POST | Needed when SameSite not supported |
| Double-submit cookie | Cookie + header must match | Stateless CSRF protection |
| Origin/Referer check | Validate request origin header | Defense-in-depth only |
- Session ID is ≥128 bits from CSPRNG (OpenSSL
RAND_bytes, notrand()) - Session ID regenerated on every login (session fixation defence)
- Cookie:
HttpOnly+Secure+SameSite=Strict - Session TTL enforced server-side (Redis EXPIRE), not just client-side cookie
- Logout deletes session from Redis (not just clears cookie)
- Concurrent session limit enforced (revoke old sessions on new login, or cap at N)
- Session ID not in URL (prevents log leakage)
// Header (decoded): { “alg”: “HS256”, “typ”: “JWT” }
// Payload (decoded): { “sub”: “42”, // subject — user ID “iss”: “api.example.com”, // issuer “aud”: “frontend”, // audience — who can use this token “role”: “admin”, // custom claim “iat”: 1711000000, // issued at (Unix timestamp) “exp”: 1711003600, // expiry — 1 hour later “jti”: “a3f9b2c1-…” // JWT ID — for revocation }
// Signature = HMAC-SHA256(base64url(header) + ”.” + base64url(payload), secret)
| Algorithm | Type | Key material | Verify cost | Best for |
|---|---|---|---|---|
| HS256 | Symmetric HMAC-SHA256 | One shared secret — all services that verify must have it | Very fast | Monolith or single-service systems |
| RS256 | Asymmetric RSA-PKCS1v15 | Private key signs, public key verifies — distributable JWKS endpoint | Slow (RSA) | Multi-service; public key can be published |
| ES256 | Asymmetric ECDSA P-256 | Same as RS256 but smaller keys (256-bit vs 2048-bit) | Moderate | Modern APIs, mobile, IoT |
| Property | Access Token | Refresh Token |
|---|---|---|
| Lifetime | 15 min – 1 hour | 7 – 30 days |
| Storage (client) | Memory (SPA) or HttpOnly cookie | HttpOnly Secure cookie |
| Validation | Signature only — no DB lookup | DB lookup — can be revoked |
| Rotation | Not rotated | Single-use: new token on each use |
| On theft detection | Wait for expiry | Revoke entire refresh token family |
| Attack | How it works | Defence |
|---|---|---|
| alg:none | Attacker sets "alg":"none" in header and removes signature. Some libraries accept unsigned tokens. |
Hardcode expected algorithm — never trust the header's alg field. Reject none. |
| Algorithm confusion | RS256 server given token with alg:HS256; HMAC key = public key (which attacker knows). Library uses public key as HMAC secret. |
Always specify algorithm explicitly in verification call, never pass allowed_algs=all. |
| Weak HS256 secret | Offline brute-force with hashcat using any valid JWT. | Use ≥256 bits from CSPRNG. Rotate regularly. |
| Missing exp validation | Expired tokens accepted indefinitely. | Always validate exp, nbf, iss, aud. |
| JWT stored in localStorage | XSS can read localStorage and exfiltrate the token. |
Store in memory (SPA) or HttpOnly cookie. Never localStorage. |
exp unless you implement revocation:
| Strategy | How | Cost | Scale |
|---|---|---|---|
| Short expiry | 15-min access tokens; only refresh revocable | None | Excellent |
| jti denylist | Store revoked jti values in Redis; check on every request | 1 Redis lookup/req | Good |
| Token family in DB | Store token generation counter per user; reject if stale | 1 DB lookup/req | Moderate |
| JWKS key rotation | Rotate signing key; old tokens signed with revoked key rejected | None per-req | Excellent (bulk revoke) |
| Role | Who they are | Example |
|---|---|---|
| Resource Owner | The user | Alice |
| Client | App requesting access | Your app |
| Authorization Server | Issues tokens after user consent | GitHub, Google, Auth0 |
| Resource Server | API that accepts tokens | GitHub API |
// Response: { “access_token”: “eyJ…”, “token_type”: “Bearer”, “expires_in”: 3600, “scope”: “payments:read inventory:write” }
- 1Generation: 32 bytes from CSPRNG → base62 or hex encode → prefix with identifier (e.g.,
sk_live_for lookup without full hash scan) - 2Storage: Never store plaintext. Store
SHA-256(key)in DB. Show full key to user exactly once on creation. - 3Lookup:
prefixcolumn (first 8 chars) for fast DB lookup + constant-time comparison of hash - 4Scoping: Attach permissions to key (e.g.,
read:payments,write:orders) - 5Rotation: Allow multiple active keys; deactivate old key after grace period
- 6Rate limiting: Limit by key, not just IP — prevents key sharing abuse
| Vulnerability | Description | Defence |
|---|---|---|
| Open redirect | redirect_uri not validated → tokens sent to attacker | Exact match against allowlist of registered URIs |
| CSRF on callback | Attacker initiates OAuth flow, tricks user's browser to complete it | Use state parameter (random nonce, validated on callback) |
| Token leakage in logs | Access tokens appear in access logs via URL params | Always use Authorization header, never URL params |
| Implicit flow | Token returned in URL fragment (deprecated) → history/referrer leakage | Use Authorization Code + PKCE instead of Implicit flow |
| aud not validated | Token issued for service A accepted by service B | Always validate aud claim matches expected audience |
— Check: can user 42 read invoices? SELECT 1 FROM user_roles ur JOIN role_permissions rp ON ur.role_id = rp.role_id JOIN permissions p ON rp.permission_id = p.id WHERE ur.user_id = 42 AND p.name = ‘invoices:read’ LIMIT 1;
| Role | Permissions |
|---|---|
| viewer | invoices:read, orders:read |
| editor | + invoices:write, orders:write |
| admin | + users:manage, settings:write |
| billing | invoices:*, payments:* |
// Allow read if user is in same org as document allow { input.action == “read” input.subject.org_id == input.resource.org_id }
// Allow write only for managers in same org allow { input.action == “write” input.subject.role == “manager” input.subject.org_id == input.resource.org_id }
// Deny after business hours (environment attribute) deny { input.action == “export” hour := time.clock(time.now_ns())[0] hour >= 18 }
| RBAC | ABAC | |
|---|---|---|
| Granularity | Coarse (role-level) | Fine (any attribute) |
| Complexity | Simple | Complex (policy management) |
| Context-awareness | None | Time, IP, device, location |
| Auditing | Easy | Harder (policy explosion) |
| Use when | Clear role hierarchy | Multi-tenant, fine-grained rules |
| Pattern | How | Problem it solves |
|---|---|---|
| Middleware check | Auth middleware runs before route handler; rejects if insufficient role | Coarse-grained: route-level protection |
| Resource ownership | WHERE user_id = current_user_id in every query | Prevents horizontal privilege escalation (user A reading user B's data) |
| Policy as code | OPA sidecar or in-process evaluation | Complex/dynamic rules; audit trail |
| Field-level authz | Strip sensitive fields from response if requester lacks permission | Fine-grained: same resource, different views |
GET /invoices/9999 — always check that invoice 9999 belongs to the authenticated user.
Never rely only on "authenticated" — always check ownership.
| Hash | Speed on GPU | Time to crack 8-char password |
|---|---|---|
| MD5 | ~200 billion/sec | Seconds |
| SHA-256 | ~20 billion/sec | Minutes to hours |
| bcrypt (cost=10) | ~10,000/sec | Months to years |
| Argon2id (recommended) | ~1,000/sec | Years to decades |
/* Key properties: / / - Salt is embedded in the hash string (no separate salt column needed) / / - Output always 60 chars — use VARCHAR(60) or CHAR(60) / / - Cost factor should be re-evaluated every 2 years / / - Max input 72 bytes (silently truncates longer passwords) */
strcmp() returns early on the first differing byte.
An attacker can measure how long the comparison takes to deduce how many bytes of their guess match.
With enough measurements, they can recover a secret byte-by-byte.
/* CORRECT — constant-time comparison (OpenSSL) / #include <openssl/crypto.h> int result = CRYPTO_memcmp(computed_hmac, stored_hmac, HMAC_LEN); / result == 0 means equal — no early exit / / Also safe: libsodium’s constant-time equal / #include <sodium.h> int ok = sodium_memcmp(a, b, len); / 0 = equal / / Use constant-time comparison for: / / - HMAC verification (JWT, CSRF tokens, webhook signatures) / / - API key comparison (though hash-then-compare is better) / / Note: For passwords, use crypto_pwhash_str_verify() — which handles timing internally */
/* Then Argon2id the peppered value */ crypto_pwhash_str(out, (const char )peppered, 32, crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE); } / Benefit: even if DB is stolen, attacker also needs the pepper to crack passwords. Pepper rotation requires rehashing all users. */
void base64url_encode(const uint8_t *data, size_t len, char *out) { size_t i = 0, j = 0; for (; i + 2 < len; i += 3) { out[j++] = b64url_chars[(data[i] >> 2) & 0x3F]; out[j++] = b64url_chars[((data[i] & 3) << 4) | (data[i+1] >> 4)]; out[j++] = b64url_chars[((data[i+1] & 0xF) << 2) | (data[i+2] >> 6)]; out[j++] = b64url_chars[data[i+2] & 0x3F]; } if (i < len) { out[j++] = b64url_chars[(data[i] >> 2) & 0x3F]; if (i + 1 == len) { out[j++] = b64url_chars[(data[i] & 3) << 4]; } else { out[j++] = b64url_chars[((data[i] & 3) << 4) | (data[i+1] >> 4)]; out[j++] = b64url_chars[(data[i+1] & 0xF) << 2]; } } out[j] = ‘\0’; }
/* Create JWT: header.payload — returns malloc’d string */ char *jwt_create_hs256(const char *payload_json, const uint8_t secret, size_t secret_len) { / Fixed header: {“alg”:“HS256”,“typ”:“JWT”} */ const char *hdr_json = ”{“alg”:“HS256”,“typ”:“JWT”}”; char hdr_b64[64], pay_b64[512], sig_b64[64];
base64url_encode((<span class="ck">const uint8_t</span> *)hdr_json, strlen(hdr_json), hdr_b64);
base64url_encode((<span class="ck">const uint8_t</span> *)payload_json, strlen(payload_json), pay_b64);
/* Signing input: base64url(header) + ”.” + base64url(payload) */ char signing_input[600]; snprintf(signing_input, sizeof(signing_input), “%s.%s”, hdr_b64, pay_b64);
/* HMAC-SHA256 */ uint8_t sig[32]; uint32_t sig_len; HMAC(EVP_sha256(), secret, (int)secret_len, (const uint8_t *)signing_input, strlen(signing_input), sig, &sig_len);
base64url_encode(sig, sig_len, sig_b64);
char *token = malloc(700); snprintf(token, 700, “%s.%s”, signing_input, sig_b64); return token; }
/* Constant-time comparison */ static int const_time_eq(const uint8_t *a, const uint8_t *b, size_t n) { uint8_t diff = 0; for (size_t i = 0; i < n; i++) diff |= a[i] ^ b[i]; return diff == 0; }
/* Verify JWT signature — returns 1 on success, 0 on failure */ int jwt_verify_hs256(const char *token, const uint8_t *secret, size_t secret_len, char *payload_out) { / Split: find the two dots */ const char *dot1 = strchr(token, ’.’); if (!dot1) return 0; const char *dot2 = strchr(dot1 + 1, ’.’); if (!dot2) return 0;
/* signing_input = everything before last dot */ size_t si_len = dot2 - token; char signing_input[600]; if (si_len >= sizeof(signing_input)) return 0; memcpy(signing_input, token, si_len); signing_input[si_len] = ‘\0’;
/* Recompute HMAC */ uint8_t computed[32]; uint32_t clen; HMAC(EVP_sha256(), secret, (int)secret_len, (const uint8_t *)signing_input, si_len, computed, &clen);
/* TODO: base64url-decode the signature portion (dot2+1) and compare / / Omitted for brevity — see labs for complete implementation */
*payload_out = (<span class="ck">char</span> *)(dot1 + <span class="cn">1</span>); <span class="cm">/* base64url(payload) */</span>
return 1; /* simplified: full impl decodes and constant-time compares */ }
/* Build key string / char hex[65]; for (int i = 0; i < 32; i++) sprintf(hex + i2, “%02x”, raw[i]); snprintf(out_key, key_len, “sk_live_%s”, hex);
/* First 8 chars after prefix = prefix for DB lookup */ strncpy(out_prefix, hex, 8); out_prefix[8] = ‘\0’;
/* SHA-256 hash for DB storage */ uint8_t hash[32]; SHA256((const uint8_t )out_key, strlen(out_key), hash); for (int i = 0; i < 32; i++) sprintf(out_hash_hex + i2, “%02x”, hash[i]); out_hash_hex[64] = ‘\0’; }
/* Verify submitted key against stored hash */ int verify_api_key(const char *submitted_key, const char *stored_hash_hex) { uint8_t computed_hash[32]; SHA256((const uint8_t *)submitted_key, strlen(submitted_key), computed_hash);
char computed_hex[65]; for (int i = 0; i < 32; i++) sprintf(computed_hex + i*2, “%02x”, computed_hash[i]); computed_hex[64] = ‘\0’;
/* Constant-time comparison — prevent timing attacks */ return CRYPTO_memcmp(computed_hex, stored_hash_hex, 64) == 0; }
int main(void) { char key[80], prefix[9], hash_hex[65]; generate_api_key(key, sizeof(key), prefix, hash_hex);
printf(<span class="cs">"API Key (show user ONCE): %s\n"</span>, key);
printf(<span class="cs">"Store in DB - prefix: %s\n"</span>, prefix);
printf(<span class="cs">"Store in DB - hash: %s\n"</span>, hash_hex);
printf(<span class="cs">"Verify: %s\n"</span>, verify_api_key(key, hash_hex) ? <span class="cs">"OK"</span> : <span class="cs">"FAIL"</span>);
printf(<span class="cs">"Verify (wrong): %s\n"</span>, verify_api_key(<span class="cs">"sk_live_wrong"</span>, hash_hex) ? <span class="cs">"OK"</span> : <span class="cs">"FAIL"</span>);
return 0; } /* gcc api_key.c -o api_key -lssl -lcrypto */
/* Verify a password against stored hash */ int verify_password(const char *stored_hash, const char password) { int ret = crypto_pwhash_str_verify( stored_hash, password, strlen(password) ); return ret == 0; / 0 = match */ }
/* Check if hash needs re-hash (cost factor upgraded) */ int needs_rehash(const char *stored_hash) { return crypto_pwhash_str_needs_rehash( stored_hash, crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE ); }
int main(void) { if (sodium_init() < 0) { fprintf(stderr, “sodium init failed\n”); return 1; }
char stored_hash[crypto_pwhash_STRBYTES]; const char *password = “correct-horse-battery-staple”;
hash_password(password, stored_hash);
printf(<span class="cs">"Stored: %.50s...\n"</span>, stored_hash);
printf(<span class="cs">"Verify correct: %s\n"</span>, verify_password(stored_hash, password) ? <span class="cs">"OK"</span> : <span class="cs">"FAIL"</span>);
printf(<span class="cs">"Verify wrong: %s\n"</span>, verify_password(stored_hash, <span class="cs">"wrongpassword"</span>) ? <span class="cs">"OK"</span> : <span class="cs">"FAIL"</span>);
printf(<span class="cs">"Needs rehash: %s\n"</span>, needs_rehash(stored_hash) ? <span class="cs">"yes"</span> : <span class="cs">"no"</span>);
return 0; } /* gcc argon2_demo.c -o argon2_demo -lsodium */
needs_rehash() — if true, transparently re-hash the plaintext password (which you have in memory at login time only) and update the DB. This handles algorithm upgrades without forcing password resets.int main(void) { char sid[33]; generate_session_id(sid); printf(“Session ID: %s\n”, sid); return 0; } /* gcc session_id.c -o session_id -lssl -lcrypto */
rand(), srand(time(NULL)), or sequential counters for session IDs. These are predictable. Only use a CSPRNG: RAND_bytes() (OpenSSL), randombytes_buf() (libsodium), or /dev/urandom directly.jwt.c. Implement base64url_encode() and base64url_decode().jwt_sign_hs256(payload_json, secret) → token_string. The header is fixed as {"alg":"HS256","typ":"JWT"}.jwt_verify_hs256(token, secret, payload_out) — splits on dots, recomputes HMAC, constant-time compares, extracts payload.jwt_get_claim(payload, "exp") → string using simple string parsing (no JSON library).exp = now + 60. Verify it passes. Modify one byte of the token. Verify it fails. Advance time past expiry. Verify expiry check fails."alg":"none" and no signature. Ensure your verifier rejects it.sudo apt install libsodium-dev- SHA-256 (baseline — show why it's wrong)
- Argon2id INTERACTIVE params
- Argon2id MODERATE params
- Argon2id SENSITIVE params
needs_rehash().apikey_issue(user_id, name, scopes[]) → key_string:
- Generate key:
sk_test_+ 32 random bytes hex - Compute SHA-256 hash
- Insert (prefix, hash, user_id, name, scopes) into api_keys
- Return the full key string (only time it's visible)
apikey_verify(submitted_key) → {user_id, scopes} or NULL:
- Extract prefix (first 8 chars after
sk_test_) - Query:
SELECT ... FROM api_keys WHERE prefix=$1 AND revoked_at IS NULL - Compute hash of submitted key, constant-time compare with stored hash
- Update last_used timestamp
- Return user context on match
apikey_revoke(key_id) — sets revoked_at = now().POST /login, GET /api/invoices, DELETE /api/invoices/:id, GET /api/admin/users.POST /login: accepts JSON {username, password}. Verifies against hardcoded users (Argon2id hashes). Returns JWT with sub, role, exp=now+900 (15 min).Authorization: Bearer <token> header, verifies signature, checks exp, populates request context with user_id and role. Returns 401 if missing/invalid.GET /api/invoices— roles: viewer, editor, adminDELETE /api/invoices/:id— roles: editor, admin onlyGET /api/admin/users— role: admin only → returns 403 for others
curl -H “Authorization: Bearer TOKEN” localhost:8080/api/invoices/1 curl -H “Authorization: Bearer $TOKEN” localhost:8080/api/admin/users # expect 403
POST /refresh with a refresh token (store in Redis-like in-memory map), implement rotation and reuse detection.Check each item after you can explain it clearly and implement it without referencing notes.
- Session ID is ≥128-bit CSPRNG output, hex or base64url encoded, never predictable
- Session ID regenerated on login — prevents session fixation attacks
- Session stored server-side (Redis) with TTL; client only holds the ID
- Cookie attributes:
HttpOnly(no JS access),Secure(HTTPS only),SameSite=Strict(no CSRF) - Logout deletes session from Redis, doesn't just expire the cookie
- CSRF: SameSite=Strict prevents cross-site request forgery for modern browsers
- JWT = base64url(header) + "." + base64url(payload) + "." + base64url(signature)
- Payload is NOT encrypted — only signed. Never store secrets in payload.
- Standard claims:
iss,sub,aud,exp,nbf,iat,jti - HS256 = symmetric HMAC; RS256/ES256 = asymmetric; hardcode algorithm in verifier
- Always verify: signature,
exp,nbf,iss,aud - Never accept
alg:none— explicitly reject it in your verifier - Access token: 15min, stateless. Refresh token: 7-30d, stored in DB for revocation
- Refresh token rotation: each use issues a new token; reuse detection revokes family
- OAuth2 = authorization framework (delegation), not authentication
- PKCE: code_verifier → SHA-256 → code_challenge; prevents authorization code interception
- State parameter on OAuth callback prevents CSRF
- redirect_uri must exactly match registered URI (open redirect prevention)
- API keys: generate with CSPRNG, store SHA-256 hash in DB, show plaintext once
- API key lookup: prefix column (fast) + constant-time hash comparison
- RBAC: users → roles → permissions; simple, auditable, works for most systems
- ABAC: policy evaluates subject + resource + action + environment attributes
- Always check resource ownership (IDOR prevention):
WHERE user_id = $current - JWT role/permission claims go stale — short access token TTL limits the window
- Never use MD5/SHA-256 for passwords — they're too fast (>billion/sec on GPU)
- Argon2id (OWASP recommended): memory-hard + time-hard + side-channel resistant
- bcrypt: cost factor 12+, ~100ms target; max 72 bytes input
- Use constant-time comparison for all security tokens (CRYPTO_memcmp / sodium_memcmp)
- Implement transparent rehash-on-login for cost factor upgrades
- Pepper = secret mixed in before hashing; stored in secrets manager, not DB
| Mistake | Consequence | Correct approach |
|---|---|---|
| JWT in localStorage | XSS can steal token → full account takeover | Memory (SPA) or HttpOnly cookie |
Missing exp check | Expired tokens valid forever | Always validate all standard claims |
Trusting alg header | alg:none bypass, algorithm confusion | Hardcode expected algorithm |
| Weak HS256 secret | Offline brute-force from any valid token | 32+ bytes from CSPRNG |
| MD5/SHA-256 for passwords | Full crack in hours after DB breach | Argon2id or bcrypt |
| Non-constant-time compare | Timing oracle reveals token byte-by-byte | CRYPTO_memcmp / sodium_memcmp |
| No session fixation protection | Attacker elevates pre-auth session | Regenerate session ID on login |
| Missing IDOR check | User A reads/writes user B's data | Always scope queries to current user |