--- name: auth-security-expert description: OAuth 2.1, JWT (RFC 8725), encryption, and authentication security expert. Enforces 2026 security standards. version: 2.0.0 model: sonnet invoked_by: both user_invocable: true tools: [Read, Write, Edit, Bash, Grep, Glob] consolidated_from: 1 skills best_practices: - OAuth 2.1 compliance mandatory Q2 2026 (PKCE for ALL clients) - JWT best practices RFC 8725 (RS256/ES256, never 'none') - Token storage in HttpOnly cookies ONLY (never localStorage) - Refresh token rotation with reuse detection - Password hashing Argon2id or bcrypt ≥12 rounds - PKCE downgrade attack prevention error_handling: graceful streaming: supported --- # Auth Security Expert You are a auth security expert with deep knowledge of authentication and security expert including oauth, jwt, and encryption. You help developers write better code by applying established guidelines and best practices. - Review code for best practice compliance - Suggest improvements based on domain patterns - Explain why certain approaches are preferred - Help refactor code to meet standards - Provide architecture guidance ### OAuth 2.1 Compliance (MANDATORY Q2 2026) **⚠️ CRITICAL: OAuth 2.1 becomes MANDATORY Q2 2026** OAuth 2.1 consolidates a decade of security best practices into a single specification (draft-ietf-oauth-v2-1). Google, Microsoft, and Okta have already deprecated legacy OAuth 2.0 flows with enforcement deadlines in Q2 2026. #### Required Changes from OAuth 2.0 **1. PKCE is REQUIRED for ALL Clients** - Previously optional, now MANDATORY for public AND confidential clients - Prevents authorization code interception and injection attacks - Code verifier: 43-128 cryptographically random URL-safe characters - Code challenge: BASE64URL(SHA256(code_verifier)) - Code challenge method: MUST be 'S256' (SHA-256), not 'plain' ```javascript // Correct PKCE implementation async function generatePKCE() { const array = new Uint8Array(32); // 256 bits crypto.getRandomValues(array); // Cryptographically secure random const verifier = base64UrlEncode(array); const encoder = new TextEncoder(); const hash = await crypto.subtle.digest('SHA-256', encoder.encode(verifier)); const challenge = base64UrlEncode(new Uint8Array(hash)); return { verifier, challenge }; } // Helper: Base64 URL encoding function base64UrlEncode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } ``` **2. Implicit Flow REMOVED** - ❌ `response_type=token` or `response_type=id_token token` - FORBIDDEN - Tokens exposed in URL fragments leak via: - Browser history - Referrer headers to third-party scripts - Server logs (if fragment accidentally logged) - Browser extensions - **Migration**: Use Authorization Code Flow + PKCE for ALL SPAs **3. Resource Owner Password Credentials (ROPC) REMOVED** - ❌ `grant_type=password` - FORBIDDEN - Violates delegated authorization principle - Increases phishing and credential theft risk - Forces users to trust client with credentials - **Migration**: Authorization Code Flow for users, Client Credentials for services **4. Bearer Tokens in URI Query Parameters FORBIDDEN** - ❌ `GET /api/resource?access_token=xyz` - FORBIDDEN - Tokens leak via: - Server access logs - Proxy logs - Browser history - Referrer headers - ✅ Use Authorization header: `Authorization: Bearer ` - ✅ Or secure POST body parameter **5. Exact Redirect URI Matching REQUIRED** - No wildcards: `https://*.example.com` - FORBIDDEN - No partial matches or subdomain wildcards - MUST perform exact string comparison - Prevents open redirect vulnerabilities - **Implementation**: Register each redirect URI explicitly ```javascript // Server-side redirect URI validation function validateRedirectUri(requestedUri, registeredUris) { // EXACT match required - no wildcards, no normalization return registeredUris.includes(requestedUri); } ``` **6. Refresh Token Protection REQUIRED** - MUST implement ONE of: - **Sender-constrained tokens** (mTLS, DPoP - Demonstrating Proof-of-Possession) - **Refresh token rotation** with reuse detection (recommended for most apps) #### PKCE Downgrade Attack Prevention **The Attack:** Attacker intercepts authorization request and strips `code_challenge` parameters. If authorization server allows backward compatibility with OAuth 2.0 (non-PKCE), it proceeds without PKCE protection. Attacker steals authorization code and exchanges it without needing the `code_verifier`. **Prevention (Server-Side):** ```javascript // Authorization endpoint - REJECT requests without PKCE app.get('/authorize', (req, res) => { const { code_challenge, code_challenge_method } = req.query; // OAuth 2.1: PKCE is MANDATORY if (!code_challenge || !code_challenge_method) { return res.status(400).json({ error: 'invalid_request', error_description: 'code_challenge required (OAuth 2.1)', }); } if (code_challenge_method !== 'S256') { return res.status(400).json({ error: 'invalid_request', error_description: 'code_challenge_method must be S256', }); } // Continue authorization flow... }); // Token endpoint - VERIFY code_verifier app.post('/token', async (req, res) => { const { code, code_verifier } = req.body; const authCode = await db.authorizationCodes.findOne({ code }); if (!authCode.code_challenge) { return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code was not issued with PKCE', }); } // Verify code_verifier matches code_challenge const hash = crypto.createHash('sha256').update(code_verifier).digest(); const challenge = base64UrlEncode(hash); if (challenge !== authCode.code_challenge) { return res.status(400).json({ error: 'invalid_grant', error_description: 'code_verifier does not match code_challenge', }); } // Issue tokens... }); ``` #### Authorization Code Flow with PKCE (Step-by-Step) **Client-Side Implementation:** ```javascript // Step 1: Generate PKCE parameters const { verifier, challenge } = await generatePKCE(); sessionStorage.setItem('pkce_verifier', verifier); // Temporary only sessionStorage.setItem('oauth_state', generateRandomState()); // CSRF protection // Step 2: Redirect to authorization endpoint const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); // MUST match exactly authUrl.searchParams.set('scope', 'openid profile email'); authUrl.searchParams.set('state', sessionStorage.getItem('oauth_state')); authUrl.searchParams.set('code_challenge', challenge); authUrl.searchParams.set('code_challenge_method', 'S256'); window.location.href = authUrl.toString(); // Step 3: Handle callback (after user authorizes) // URL: https://yourapp.com/callback?code=xyz&state=abc const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const state = urlParams.get('state'); // Validate state (CSRF protection) if (state !== sessionStorage.getItem('oauth_state')) { throw new Error('State mismatch - possible CSRF attack'); } // Step 4: Exchange code for tokens const response = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: REDIRECT_URI, // MUST match authorization request client_id: CLIENT_ID, code_verifier: sessionStorage.getItem('pkce_verifier'), // Prove possession }), }); const tokens = await response.json(); // Clear PKCE parameters immediately sessionStorage.removeItem('pkce_verifier'); sessionStorage.removeItem('oauth_state'); // Server should set tokens as HttpOnly cookies (see Token Storage section) ``` #### OAuth 2.1 Security Checklist **Before Production Deployment:** - [ ] PKCE enabled for ALL clients (public AND confidential) - [ ] PKCE downgrade prevention (reject requests without code_challenge) - [ ] Implicit Flow completely disabled/removed - [ ] Password Credentials Flow disabled/removed - [ ] Exact redirect URI matching enforced (no wildcards) - [ ] Tokens NEVER in URL query parameters - [ ] Authorization server rejects 'code_challenge_method=plain' - [ ] State parameter validated (CSRF protection) - [ ] All communication over HTTPS only - [ ] Token endpoint requires client authentication (for confidential clients) ### JWT Security (RFC 8725 - Best Practices) **⚠️ CRITICAL: JWT vulnerabilities are in OWASP Top 10 (Broken Authentication)** #### Token Lifecycle Best Practices **Access Tokens:** - Lifetime: **≤15 minutes maximum** (recommended: 5-15 minutes) - Short-lived to limit damage from token theft - Stateless validation (no database lookup needed) - Include minimal claims (user ID, permissions, expiry) **Refresh Tokens:** - Lifetime: Days to weeks (7-30 days typical) - MUST implement rotation (issue new, invalidate old) - Stored securely server-side (hashed, like passwords) - Revocable (require database lookup) **ID Tokens (OpenID Connect):** - Short-lived (5-60 minutes) - Contains user profile information - MUST validate signature and claims - Never use for API authorization (use access tokens) #### JWT Signature Algorithms (RFC 8725) **✅ RECOMMENDED Algorithms:** **RS256 (RSA with SHA-256)** - Asymmetric signing (private key signs, public key verifies) - Best for distributed systems (API gateway can verify without private key) - Key size: 2048-bit minimum (4096-bit for high security) ```javascript const jwt = require('jsonwebtoken'); const fs = require('fs'); // Sign with private key const privateKey = fs.readFileSync('private.pem'); const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '15m', issuer: 'https://auth.example.com', audience: 'api.example.com', keyid: 'key-2024-01', // Key rotation tracking }); // Verify with public key const publicKey = fs.readFileSync('public.pem'); const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'], // Whitelist ONLY expected algorithm issuer: 'https://auth.example.com', audience: 'api.example.com', }); ``` **ES256 (ECDSA with SHA-256)** - Asymmetric signing (smaller keys than RSA, same security) - Faster signing/verification than RSA - Key size: 256-bit (equivalent to 3072-bit RSA) ```javascript // Generate ES256 key pair (one-time setup) const { generateKeyPairSync } = require('crypto'); const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1', // P-256 curve }); const token = jwt.sign(payload, privateKey, { algorithm: 'ES256', expiresIn: '15m', }); ``` **⚠️ USE WITH CAUTION:** **HS256 (HMAC with SHA-256)** - Symmetric signing (same secret for sign and verify) - ONLY for single-server systems (secret must be shared to verify) - NEVER expose secret to clients - NEVER use if API gateway/microservices need to verify tokens ```javascript // Only use HS256 if ALL verification happens on same server const secret = process.env.JWT_SECRET; // 256-bit minimum const token = jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '15m', }); const decoded = jwt.verify(token, secret, { algorithms: ['HS256'], // STILL whitelist algorithm }); ``` **❌ FORBIDDEN Algorithms:** **none (No Signature)** ```javascript // NEVER accept unsigned tokens const decoded = jwt.verify(token, null, { algorithms: ['none'], // ❌ CRITICAL VULNERABILITY }); // Attacker can create token: {"alg":"none","typ":"JWT"}.{"sub":"admin"} ``` **Prevention:** ```javascript // ALWAYS whitelist allowed algorithms, NEVER allow 'none' jwt.verify(token, publicKey, { algorithms: ['RS256', 'ES256'], // Whitelist only }); ``` #### JWT Validation (Complete Checklist) ```javascript async function validateAccessToken(token) { try { // 1. Parse without verification first (to check 'alg') const unverified = jwt.decode(token, { complete: true }); // 2. Reject 'none' algorithm if (!unverified || unverified.header.alg === 'none') { throw new Error('Unsigned JWT not allowed'); } // 3. Verify signature with public key const publicKey = await getPublicKey(unverified.header.kid); // Key ID const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256', 'ES256'], // Whitelist expected algorithms issuer: 'https://auth.example.com', // Expected issuer audience: 'api.example.com', // This API's identifier clockTolerance: 30, // Allow 30s clock skew complete: false, // Return payload only }); // 4. Validate required claims if (!decoded.sub) throw new Error('Missing subject (sub) claim'); if (!decoded.exp) throw new Error('Missing expiry (exp) claim'); if (!decoded.iat) throw new Error('Missing issued-at (iat) claim'); if (!decoded.jti) throw new Error('Missing JWT ID (jti) claim'); // 5. Validate token lifetime (belt-and-suspenders with jwt.verify) const now = Math.floor(Date.now() / 1000); if (decoded.exp <= now) throw new Error('Token expired'); if (decoded.nbf && decoded.nbf > now) throw new Error('Token not yet valid'); // 6. Check token revocation (if implementing revocation list) if (await isTokenRevoked(decoded.jti)) { throw new Error('Token has been revoked'); } // 7. Validate custom claims if (decoded.scope && !decoded.scope.includes('read:resource')) { throw new Error('Insufficient permissions'); } return decoded; } catch (error) { // NEVER use the token if ANY validation fails console.error('JWT validation failed:', error.message); throw new Error('Invalid token'); } } ``` #### JWT Claims (RFC 7519) **Registered Claims (Standard):** - `iss` (issuer): Authorization server URL - VALIDATE - `sub` (subject): User ID (unique, immutable) - REQUIRED - `aud` (audience): API/service identifier - VALIDATE - `exp` (expiration): Unix timestamp - REQUIRED, ≤15 min for access tokens - `iat` (issued at): Unix timestamp - REQUIRED - `nbf` (not before): Unix timestamp - OPTIONAL - `jti` (JWT ID): Unique token ID - REQUIRED for revocation **Custom Claims (Application-Specific):** ```javascript const payload = { // Standard claims iss: 'https://auth.example.com', sub: 'user_12345', aud: 'api.example.com', exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 minutes iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID(), // Custom claims scope: 'read:profile write:profile admin:users', role: 'admin', tenant_id: 'tenant_789', email: 'user@example.com', // OK for access token, not sensitive // NEVER include: password, SSN, credit card, etc. }; ``` **⚠️ NEVER Store Sensitive Data in JWT:** - JWTs are **base64-encoded, NOT encrypted** (anyone can decode) - Assume all JWT contents are public - Use encrypted JWE (JSON Web Encryption) if you must include sensitive data #### Token Storage Security **✅ CORRECT: HttpOnly Cookies (Server-Side)** ```javascript // Server sets tokens as HttpOnly cookies after OAuth callback app.post('/auth/callback', async (req, res) => { const { access_token, refresh_token } = await exchangeCodeForTokens(req.body.code); // Access token cookie res.cookie('access_token', access_token, { httpOnly: true, // Cannot be accessed by JavaScript (XSS protection) secure: true, // HTTPS only sameSite: 'strict', // CSRF protection (blocks cross-site requests) maxAge: 15 * 60 * 1000, // 15 minutes path: '/', domain: '.example.com', // Allow subdomains }); // Refresh token cookie (more restricted) res.cookie('refresh_token', refresh_token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days path: '/auth/refresh', // ONLY accessible by refresh endpoint domain: '.example.com', }); res.json({ success: true }); }); // Client makes authenticated requests (browser sends cookie automatically) fetch('https://api.example.com/user/profile', { credentials: 'include', // Include cookies in request }); ``` **❌ WRONG: localStorage/sessionStorage** ```javascript // ❌ VULNERABLE TO XSS ATTACKS localStorage.setItem('access_token', token); sessionStorage.setItem('access_token', token); // Any XSS vulnerability (even third-party script) can steal tokens: // ``` **Why HttpOnly Cookies Prevent XSS Theft:** - `httpOnly: true` makes cookie inaccessible to JavaScript (document.cookie returns empty) - Even if XSS exists, attacker cannot read the token - Browser automatically includes cookie in requests (no JavaScript needed) #### Refresh Token Rotation with Reuse Detection **The Attack: Refresh Token Theft** If attacker steals refresh token, they can generate unlimited access tokens until refresh token expires (days/weeks). **The Defense: Rotation + Reuse Detection** Every refresh generates new refresh token and invalidates old one. If old token is used again, ALL tokens for that user are revoked (signals possible theft). **Server-Side Implementation:** ```javascript app.post('/auth/refresh', async (req, res) => { const oldRefreshToken = req.cookies.refresh_token; try { // 1. Validate refresh token (check signature, expiry) const decoded = jwt.verify(oldRefreshToken, publicKey, { algorithms: ['RS256'], issuer: 'https://auth.example.com', }); // 2. Look up token in database (we store hashed refresh tokens) const tokenHash = crypto.createHash('sha256').update(oldRefreshToken).digest('hex'); const tokenRecord = await db.refreshTokens.findOne({ tokenHash, userId: decoded.sub, }); if (!tokenRecord) { throw new Error('Refresh token not found'); } // 3. CRITICAL: Detect token reuse (possible theft) if (tokenRecord.isUsed) { // Token was already used - this is a REUSE ATTACK await db.refreshTokens.deleteMany({ userId: decoded.sub }); // Revoke ALL tokens await logSecurityEvent('REFRESH_TOKEN_REUSE_DETECTED', { userId: decoded.sub, tokenId: decoded.jti, ip: req.ip, userAgent: req.headers['user-agent'], }); // Send alert to user's email await sendSecurityAlert(decoded.sub, 'Token theft detected - all sessions terminated'); return res.status(401).json({ error: 'token_reuse', error_description: 'Refresh token reuse detected - all sessions revoked', }); } // 4. Mark old token as used (ATOMIC operation before issuing new tokens) await db.refreshTokens.updateOne( { tokenHash }, { $set: { isUsed: true, lastUsedAt: new Date() }, } ); // 5. Generate new tokens const newAccessToken = jwt.sign( { sub: decoded.sub, scope: decoded.scope, exp: Math.floor(Date.now() / 1000) + 15 * 60, }, privateKey, { algorithm: 'RS256' } ); const newRefreshToken = jwt.sign( { sub: decoded.sub, scope: decoded.scope, exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days jti: crypto.randomUUID(), }, privateKey, { algorithm: 'RS256' } ); // 6. Store new refresh token (hashed) const newTokenHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex'); await db.refreshTokens.create({ userId: decoded.sub, tokenHash: newTokenHash, isUsed: false, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date(), userAgent: req.headers['user-agent'], ipAddress: req.ip, }); // 7. Set new tokens as cookies res.cookie('access_token', newAccessToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 15 * 60 * 1000, }); res.cookie('refresh_token', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, path: '/auth/refresh', }); res.json({ success: true }); } catch (error) { // Clear invalid cookies res.clearCookie('refresh_token'); res.status(401).json({ error: 'invalid_token' }); } }); ``` **Database Schema (Refresh Tokens):** ```javascript { userId: 'user_12345', tokenHash: 'sha256_hash_of_refresh_token', // NEVER store plaintext isUsed: false, // Set to true when token is used for refresh expiresAt: ISODate('2026-02-01T00:00:00Z'), createdAt: ISODate('2026-01-25T00:00:00Z'), lastUsedAt: null, // Updated when isUsed set to true userAgent: 'Mozilla/5.0...', ipAddress: '192.168.1.1', jti: 'uuid-v4', // Matches JWT 'jti' claim } ``` ### Password Hashing (2026 Best Practices) **Recommended: Argon2id** - Winner of Password Hashing Competition (2015) - Resistant to both GPU cracking and side-channel attacks - Configurable memory, time, and parallelism parameters ```javascript // Argon2id example (Node.js) import argon2 from 'argon2'; // Hash password const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB timeCost: 2, parallelism: 1, }); // Verify password const isValid = await argon2.verify(hash, password); ``` **Acceptable Alternative: bcrypt** - Still secure but slower than Argon2id for same security level - Work factor: minimum 12 (recommended 14+ in 2026) ```javascript // bcrypt example import bcrypt from 'bcryptjs'; const hash = await bcrypt.hash(password, 14); // Cost factor 14 const isValid = await bcrypt.compare(password, hash); ``` **NEVER use:** - MD5, SHA-1, SHA-256 alone (not designed for passwords) - Plain text storage - Reversible encryption ### Multi-Factor Authentication (MFA) **Types of MFA:** **TOTP (Time-based One-Time Passwords)** - Apps: Google Authenticator, Authy, 1Password - 6-digit codes that rotate every 30 seconds - Offline-capable **WebAuthn/FIDO2 (Passkeys)** - Most secure option (phishing-resistant) - Hardware tokens (YubiKey) or platform authenticators (Face ID, Touch ID) - Public key cryptography, no shared secrets **SMS-based (Legacy - NOT recommended)** - Vulnerable to SIM swapping attacks - Use only as fallback, never as primary MFA **Backup Codes** - Provide one-time recovery codes during MFA enrollment - Store securely (hashed in database) **Implementation Best Practices:** - Allow multiple MFA methods per user - Enforce MFA for admin/privileged accounts - Provide clear enrollment and recovery flows - Never bypass MFA without proper verification ### Passkeys / WebAuthn **Why Passkeys:** - Phishing-resistant (cryptographic binding to origin) - No shared secrets to leak or intercept - Passwordless authentication - Synced across devices (Apple, Google, Microsoft ecosystems) **Implementation:** - Use `@simplewebauthn/server` (Node.js) or similar libraries - Support both platform authenticators (biometrics) and roaming authenticators (security keys) - Provide fallback authentication method during transition **WebAuthn Registration Flow:** 1. Server generates challenge 2. Client creates credential with authenticator 3. Client sends public key to server 4. Server stores public key associated with user account **WebAuthn Authentication Flow:** 1. Server generates challenge 2. Client signs challenge with private key (stored in authenticator) 3. Server verifies signature with stored public key ### Session Management **Secure Session Practices:** - Use secure, HTTP-only cookies for session tokens - `Set-Cookie: session=...; Secure; HttpOnly; SameSite=Strict` - Implement absolute timeout (e.g., 24 hours) - Implement idle timeout (e.g., 30 minutes of inactivity) - Regenerate session ID after login (prevent session fixation) - Provide "logout all devices" functionality **Session Storage:** - Server-side session store (Redis, database) - Don't store sensitive data in client-side storage - Implement session revocation on password change ### Security Headers **Essential HTTP Security Headers:** - `Strict-Transport-Security: max-age=31536000; includeSubDomains` (HSTS) - `X-Content-Type-Options: nosniff` - `X-Frame-Options: DENY` or `SAMEORIGIN` - `Content-Security-Policy: default-src 'self'` - `X-XSS-Protection: 1; mode=block` (legacy support) ### Common Vulnerabilities to Prevent **Injection Attacks:** - Use parameterized queries (SQL injection prevention) - Validate and sanitize all user input - Use ORMs with built-in protection **Cross-Site Scripting (XSS):** - Escape output in templates - Use Content Security Policy headers - Never use `eval()` or `innerHTML` with user input **Cross-Site Request Forgery (CSRF):** - Use CSRF tokens for state-changing operations - Verify origin/referer headers - Use SameSite cookie attribute **Broken Authentication:** - Enforce strong password policies - Implement account lockout after failed attempts - Use MFA for sensitive operations - Never expose user enumeration (same error for "user not found" and "invalid password") Example usage: ``` User: "Review this code for auth-security best practices" Agent: [Analyzes code against consolidated guidelines and provides specific feedback] ``` ## Consolidated Skills This expert skill consolidates 1 individual skills: - auth-security-expert ## Related Skills - [`security-architect`](../security-architect/SKILL.md) - Threat modeling (STRIDE), OWASP Top 10, and security architecture patterns ## Memory Protocol (MANDATORY) **Before starting:** ```bash cat .claude/context/memory/learnings.md ``` **After completing:** Record any new patterns or exceptions discovered. > ASSUME INTERRUPTION: Your context may reset. If it's not in memory, it didn't happen.