Authentication Flow Rules Skill
Iron Laws
-
PKCE IS MANDATORY FOR ALL CLIENTS — OAuth 2.1 requires PKCE for both public AND confidential clients; there are no exceptions and no legacy carve-outs. Every authorization code flow must generate and validate a code_challenge with method S256.
-
IMPLICIT FLOW IS PERMANENTLY REMOVED — Never use response_type=token ; tokens returned in URL fragments leak via browser history, referrer headers, and server logs. Migrate immediately to Authorization Code + PKCE.
-
TOKENS MUST NEVER BE STORED IN LOCALSTORAGE OR SESSIONSTORAGE — XSS vulnerabilities can exfiltrate tokens from JavaScript-accessible storage. Store access tokens in HttpOnly, Secure, SameSite=Strict cookies only.
-
ACCESS TOKEN LIFETIME MUST NOT EXCEED 15 MINUTES — Short-lived tokens limit the blast radius of token theft. Refresh tokens must rotate on every use and invalidate all sessions on reuse detection.
-
EXACT REDIRECT URI MATCHING IS NON-NEGOTIABLE — No wildcards, no partial matches, no trailing slash tolerance. Authorization server must reject any redirect_uri that does not match the pre-registered value exactly.
Anti-Patterns
Anti-Pattern Why It Fails Correct Approach
Storing tokens in localStorage
Exposed to XSS; any script on the page (including third-party) can read and exfiltrate Use HttpOnly cookies set server-side after token exchange
Using Implicit Flow (response_type=token ) Tokens in URL fragments leak via browser history, Referer headers, and proxy/CDN logs Use Authorization Code Flow with PKCE
Collecting user passwords directly (Resource Owner Password Credentials) Violates OAuth separation of concerns; client handles credentials it should never see Use Authorization Code Flow; direct users to the authorization server login page
Wildcard or partial redirect URI matching Open redirect attack; adversary registers https://evil.com which prefix-matches a wildcard Register exact URIs; server rejects any URI not in the pre-approved list
Long-lived access tokens (>15 min) without rotation Token theft window is unbounded; compromised token grants long-term access Keep access tokens ≤15 min; implement refresh token rotation with reuse detection
CRITICAL: Required Changes from OAuth 2.0
-
PKCE is REQUIRED for ALL clients (public AND confidential)
-
Implicit Flow is REMOVED - do not use, migrate immediately
-
Resource Owner Password Credentials REMOVED - never collect user passwords directly
-
Bearer tokens in URI query parameters FORBIDDEN - tokens only in Authorization headers or POST bodies
-
Exact redirect URI matching REQUIRED - no wildcards, no partial matches
Authorization Code Flow with PKCE (The ONLY User Flow)
Step 1: Generate PKCE Challenge
// Client-side: Generate code_verifier (43-128 chars, URL-safe) async function generatePKCE() { const array = new Uint8Array(32); crypto.getRandomValues(array); const verifier = base64UrlEncode(array);
// Hash verifier to create challenge const encoder = new TextEncoder(); const hash = await crypto.subtle.digest('SHA-256', encoder.encode(verifier)); const challenge = base64UrlEncode(new Uint8Array(hash));
return { verifier, challenge }; }
Step 2: Authorization Request
const { verifier, challenge } = await generatePKCE(); sessionStorage.setItem('pkce_verifier', verifier); // Temporary storage only
const authUrl = new URL(authorizationEndpoint); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirectUri); // MUST match exactly authUrl.searchParams.set('code_challenge', challenge); authUrl.searchParams.set('code_challenge_method', 'S256'); // SHA-256 required authUrl.searchParams.set('scope', 'openid profile email'); authUrl.searchParams.set('state', cryptoRandomState); // CSRF protection
window.location.href = authUrl.toString();
Step 3: Token Exchange with Code Verifier
const response = await fetch(tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: authorizationCode, code_verifier: sessionStorage.getItem('pkce_verifier'), // Prove possession client_id: clientId, redirect_uri: redirectUri, // MUST match authorization request }), });
// Clear verifier immediately after use sessionStorage.removeItem('pkce_verifier');
Token Security Best Practices
Token Lifetimes (RFC 8725)
-
Access tokens: ≤15 minutes maximum
-
Refresh tokens: Rotate on every use (sender-constrained or rotation required)
-
ID tokens: Short-lived, validate signature and claims
Token Storage (CRITICAL)
// ✅ CORRECT: HttpOnly, Secure, SameSite cookies // Server sets cookies after token exchange res.cookie('access_token', accessToken, { httpOnly: true, // Prevents XSS access secure: true, // HTTPS only sameSite: 'strict', // CSRF protection maxAge: 15 * 60 * 1000, // 15 minutes });
res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', // Limit scope maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days, rotate frequently });
// ❌ WRONG: NEVER store tokens in localStorage or sessionStorage localStorage.setItem('token', accessToken); // VULNERABLE TO XSS sessionStorage.setItem('token', accessToken); // VULNERABLE TO XSS
Refresh Token Rotation
// Server-side: Rotate refresh tokens on every use async function refreshTokens(oldRefreshToken) { // Validate old token const userId = await validateRefreshToken(oldRefreshToken);
// Detect token reuse (possible theft) if (await isTokenAlreadyUsed(oldRefreshToken)) { await revokeAllTokensForUser(userId); // Kill all sessions throw new Error('Token reuse detected - all sessions revoked'); }
// Mark old token as used BEFORE issuing new one await markTokenAsUsed(oldRefreshToken);
// Issue new tokens const newAccessToken = generateAccessToken(userId, '15m'); const newRefreshToken = generateRefreshToken(userId);
return { newAccessToken, newRefreshToken }; }
REMOVED Flows (Do Not Use)
❌ Implicit Flow (REMOVED in OAuth 2.1)
// NEVER DO THIS - Implicit Flow is REMOVED authUrl.searchParams.set('response_type', 'token'); // ❌ FORBIDDEN // Tokens in URL fragments leak via browser history, referrer headers, logs
Migration Path: Use Authorization Code Flow + PKCE instead.
❌ Resource Owner Password Credentials (REMOVED)
// NEVER DO THIS - Collecting passwords directly violates OAuth fetch(tokenEndpoint, { body: new URLSearchParams({ grant_type: 'password', // ❌ FORBIDDEN username: user.email, password: user.password, }), });
Migration Path: Use Authorization Code Flow for user auth, Client Credentials for service accounts.
Modern Authentication Patterns
Passkeys/WebAuthn (Recommended for 2026+)
// Register passkey const credential = await navigator.credentials.create({ publicKey: { challenge: serverChallenge, rp: { name: 'Your App' }, user: { id: userIdBytes, name: user.email, displayName: user.name, }, pubKeyCredParams: [{ alg: -7, type: 'public-key' }], authenticatorSelection: { authenticatorAttachment: 'platform', // Device-bound userVerification: 'required', }, }, });
// Authenticate with passkey const assertion = await navigator.credentials.get({ publicKey: { challenge: serverChallenge, rpId: 'yourapp.com', userVerification: 'required', }, });
Security Checklist
Before deploying:
-
PKCE enabled for ALL clients (no exceptions)
-
Implicit Flow completely removed/disabled
-
Password Credentials Flow removed/disabled
-
Exact redirect URI matching enforced (no wildcards)
-
Tokens NEVER in URL query parameters
-
Access tokens ≤15 minutes
-
Refresh token rotation enabled with reuse detection
-
Tokens stored in HttpOnly, Secure, SameSite=Strict cookies
-
All token transport over HTTPS only
-
State parameter used for CSRF protection
-
Token signature validation (JWT RS256 or ES256, not HS256)
Common Implementation Patterns
Login with OAuth 2.1 (email/password)
// Use Authorization Code Flow with PKCE even for first-party login async function login(email: string, password: string) { // 1. Start PKCE flow const { verifier, challenge } = await generatePKCE(); sessionStorage.setItem('pkce_verifier', verifier);
// 2. POST credentials to your auth server's login endpoint const response = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, code_challenge: challenge }), });
// 3. Server validates credentials, returns authorization code const { code } = await response.json();
// 4. Exchange code for tokens with verifier await exchangeCodeForTokens(code); }
Login with GitHub OAuth
async function loginWithGitHub() { const { verifier, challenge } = await generatePKCE(); sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = new URL('https://github.com/login/oauth/authorize'); authUrl.searchParams.set('client_id', GITHUB_CLIENT_ID); authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/auth/callback'); authUrl.searchParams.set('scope', 'user:email'); authUrl.searchParams.set('state', generateState()); // Note: GitHub doesn't support PKCE yet, but use it when available
window.location.href = authUrl.toString(); }
Logout
async function logout() { // 1. Clear client-side cookies document.cookie = 'access_token=; Max-Age=0; path=/'; document.cookie = 'refresh_token=; Max-Age=0; path=/';
// 2. Revoke tokens on server await fetch('/auth/logout', { method: 'POST' });
// 3. Redirect to login window.location.href = '/login'; }
Memory Protocol (MANDATORY)
Before starting:
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.