# Authentication — Patterns # Authentication & Authorization Patterns ## When to Use - Implementing login, signup, or logout flows for web applications - Setting up JWT access tokens and refresh token rotation - Building OAuth2 integrations (Google, GitHub, or custom providers) - Adding role-based or permission-based access control to API endpoints - Protecting routes with middleware guards in Next.js, Express, or FastAPI ## When NOT to Use - Public-only APIs that require no identity verification (e.g., open data endpoints) - Internal services secured entirely at the network level (VPC, service mesh mTLS) with no application-layer auth - Static sites with no user-specific content or server-side logic --- ## Core Patterns ### 1. JWT Patterns Use short-lived access tokens for API authorization and long-lived refresh tokens for session continuity. Never store access tokens in localStorage. **Token structure and signing** ```python # BAD - long-lived token, weak secret, symmetric HS256 with hardcoded key import jwt token = jwt.encode( {"user_id": 1, "exp": datetime.utcnow() + timedelta(days=365)}, "secret123", algorithm="HS256", ) # GOOD - short-lived access token, strong secret from env, RS256 for production import jwt import os from datetime import datetime, timedelta, timezone ACCESS_TOKEN_EXPIRY = timedelta(minutes=15) REFRESH_TOKEN_EXPIRY = timedelta(days=7) def create_access_token(user_id: int, role: str) -> str: now = datetime.now(timezone.utc) return jwt.encode( { "sub": str(user_id), "role": role, "iat": now, "exp": now + ACCESS_TOKEN_EXPIRY, "type": "access", }, os.environ["JWT_PRIVATE_KEY"], algorithm="RS256", ) def create_refresh_token(user_id: int) -> str: now = datetime.now(timezone.utc) return jwt.encode( { "sub": str(user_id), "iat": now, "exp": now + REFRESH_TOKEN_EXPIRY, "type": "refresh", "jti": str(uuid.uuid4()), # unique ID for revocation }, os.environ["JWT_PRIVATE_KEY"], algorithm="RS256", ) def decode_token(token: str) -> dict: return jwt.decode( token, os.environ["JWT_PUBLIC_KEY"], algorithms=["RS256"], options={"require": ["sub", "exp", "type"]}, ) ``` **TypeScript -- JWT creation and verification** ```typescript // GOOD - short-lived tokens with jose (works in Node.js and edge runtimes) import { SignJWT, jwtVerify } from "jose"; const privateKey = new TextEncoder().encode(process.env.JWT_SECRET!); async function createAccessToken(userId: string, role: string): Promise { return new SignJWT({ role, type: "access" }) .setProtectedHeader({ alg: "HS256" }) .setSubject(userId) .setIssuedAt() .setExpirationTime("15m") .sign(privateKey); } async function createRefreshToken(userId: string): Promise { return new SignJWT({ type: "refresh", jti: crypto.randomUUID() }) .setProtectedHeader({ alg: "HS256" }) .setSubject(userId) .setIssuedAt() .setExpirationTime("7d") .sign(privateKey); } async function verifyToken(token: string): Promise<{ sub: string; role?: string }> { const { payload } = await jwtVerify(token, privateKey, { algorithms: ["HS256"], requiredClaims: ["sub", "exp", "type"], }); return payload as { sub: string; role?: string }; } ``` **Secure cookie delivery** ```python # GOOD - deliver tokens in httpOnly cookies, not in response body from fastapi import Response def set_auth_cookies(response: Response, access_token: str, refresh_token: str): response.set_cookie( key="access_token", value=access_token, httponly=True, # not accessible via JavaScript secure=True, # HTTPS only samesite="lax", # CSRF protection max_age=int(ACCESS_TOKEN_EXPIRY.total_seconds()), path="/", ) response.set_cookie( key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=int(REFRESH_TOKEN_EXPIRY.total_seconds()), path="/auth/refresh", # only sent to refresh endpoint ) def clear_auth_cookies(response: Response): response.delete_cookie("access_token", path="/") response.delete_cookie("refresh_token", path="/auth/refresh") ``` ### 2. OAuth2 Flows **Authorization code flow with PKCE (for SPAs and mobile apps)** ```typescript // GOOD - PKCE flow for single-page applications (no client secret exposed) import crypto from "crypto"; // Step 1: Generate code verifier and challenge function generatePKCE(): { verifier: string; challenge: string } { const verifier = crypto.randomBytes(32).toString("base64url"); const challenge = crypto .createHash("sha256") .update(verifier) .digest("base64url"); return { verifier, challenge }; } // Step 2: Redirect user to authorization server function getAuthorizationUrl(codeChallenge: string): string { const params = new URLSearchParams({ response_type: "code", client_id: process.env.OAUTH_CLIENT_ID!, redirect_uri: process.env.OAUTH_REDIRECT_URI!, scope: "openid profile email", code_challenge: codeChallenge, code_challenge_method: "S256", state: crypto.randomBytes(16).toString("hex"), }); return `https://auth.example.com/authorize?${params}`; } // Step 3: Exchange code for tokens on the callback async function exchangeCode(code: string, codeVerifier: string): Promise { 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, redirect_uri: process.env.OAUTH_REDIRECT_URI!, client_id: process.env.OAUTH_CLIENT_ID!, code_verifier: codeVerifier, }), }); if (!response.ok) { throw new Error(`Token exchange failed: ${response.status}`); } return response.json() as Promise; } ``` **Client credentials flow (server-to-server)** ```python # GOOD - machine-to-machine auth with client credentials import httpx import os async def get_service_token() -> str: async with httpx.AsyncClient() as client: response = await client.post( "https://auth.example.com/token", data={ "grant_type": "client_credentials", "client_id": os.environ["SERVICE_CLIENT_ID"], "client_secret": os.environ["SERVICE_CLIENT_SECRET"], "scope": "read:data write:data", }, ) response.raise_for_status() return response.json()["access_token"] ``` **Python -- OAuth2 callback handling with FastAPI** ```python # GOOD - secure callback handler with state validation from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse import httpx router = APIRouter(prefix="/auth") @router.get("/callback") async def oauth_callback(request: Request, code: str, state: str): # Validate state to prevent CSRF stored_state = request.session.get("oauth_state") if not stored_state or state != stored_state: raise HTTPException(status_code=400, detail="Invalid state parameter") code_verifier = request.session.pop("code_verifier") async with httpx.AsyncClient() as client: token_response = await client.post( "https://auth.example.com/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": os.environ["OAUTH_REDIRECT_URI"], "client_id": os.environ["OAUTH_CLIENT_ID"], "code_verifier": code_verifier, }, ) token_response.raise_for_status() tokens = token_response.json() # Create local session from OAuth tokens user_info = await fetch_user_info(tokens["access_token"]) user = await get_or_create_user(user_info) response = RedirectResponse(url="/dashboard") set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id)) return response ``` ### 3. Password Security **Python -- argon2 (preferred) and bcrypt** ```python # BAD - MD5 or SHA-256 alone is trivially crackable import hashlib hashed = hashlib.sha256(password.encode()).hexdigest() # GOOD - argon2id (recommended by OWASP) from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError ph = PasswordHasher( time_cost=3, # iterations memory_cost=65536, # 64 MB parallelism=4, ) def hash_password(password: str) -> str: return ph.hash(password) def verify_password(password: str, hashed: str) -> bool: try: return ph.verify(hashed, password) except VerifyMismatchError: return False # GOOD - bcrypt alternative from passlib.hash import bcrypt hashed = bcrypt.using(rounds=12).hash(password) is_valid = bcrypt.verify(password, hashed) ``` **TypeScript -- bcrypt** ```typescript // GOOD - bcrypt with sufficient cost factor import bcrypt from "bcrypt"; const SALT_ROUNDS = 12; // ~250ms on modern hardware, adjust as needed async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS); } async function verifyPassword(password: string, hash: string): Promise { return bcrypt.compare(password, hash); } ``` **Password validation rules** ```typescript // GOOD - enforce minimum complexity without overly restrictive rules import { z } from "zod"; const PasswordSchema = z .string() .min(8, "Password must be at least 8 characters") .max(128, "Password must not exceed 128 characters") .regex(/[a-z]/, "Must contain at least one lowercase letter") .regex(/[A-Z]/, "Must contain at least one uppercase letter") .regex(/[0-9]/, "Must contain at least one digit"); // Python equivalent with Pydantic from pydantic import BaseModel, field_validator import re class PasswordInput(BaseModel): password: str @field_validator("password") @classmethod def validate_password(cls, v: str) -> str: if len(v) < 8: raise ValueError("Password must be at least 8 characters") if len(v) > 128: raise ValueError("Password must not exceed 128 characters") if not re.search(r"[a-z]", v): raise ValueError("Must contain at least one lowercase letter") if not re.search(r"[A-Z]", v): raise ValueError("Must contain at least one uppercase letter") if not re.search(r"[0-9]", v): raise ValueError("Must contain at least one digit") return v ``` **Timing-safe comparison** ```python # BAD - standard equality leaks timing information if stored_token == provided_token: grant_access() # GOOD - constant-time comparison prevents timing attacks import hmac def safe_compare(a: str, b: str) -> bool: return hmac.compare_digest(a.encode(), b.encode()) ``` ```typescript // GOOD - timing-safe comparison in Node.js import crypto from "crypto"; function safeCompare(a: string, b: string): boolean { if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } ``` ### 4. Session Management **Cookie-based sessions with Redis store (Express)** ```typescript // GOOD - server-side sessions stored in Redis import session from "express-session"; import RedisStore from "connect-redis"; import { createClient } from "redis"; const redisClient = createClient({ url: process.env.REDIS_URL }); await redisClient.connect(); app.use( session({ store: new RedisStore({ client: redisClient, prefix: "sess:" }), secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 30 * 60 * 1000, // 30 minutes }, name: "sid", // custom name -- do not use default "connect.sid" }), ); ``` **Session fixation prevention** ```typescript // GOOD - regenerate session ID after login to prevent fixation app.post("/login", async (req, res) => { const user = await authenticateUser(req.body.email, req.body.password); if (!user) return res.status(401).json({ error: "Invalid credentials" }); // Regenerate session to prevent fixation attacks req.session.regenerate((err) => { if (err) return res.status(500).json({ error: "Session error" }); req.session.userId = user.id; req.session.role = user.role; req.session.loginAt = Date.now(); res.json({ user: { id: user.id, name: user.name } }); }); }); // GOOD - clear session fully on logout app.post("/logout", (req, res) => { req.session.destroy((err) => { if (err) return res.status(500).json({ error: "Logout failed" }); res.clearCookie("sid"); res.json({ message: "Logged out" }); }); }); ``` **Python -- FastAPI session with Redis** ```python # GOOD - server-side session using Redis from fastapi import Request, Response import redis.asyncio as redis import uuid import json redis_client = redis.from_url(os.environ["REDIS_URL"]) SESSION_TTL = 1800 # 30 minutes async def create_session(response: Response, data: dict) -> str: session_id = str(uuid.uuid4()) await redis_client.setex(f"session:{session_id}", SESSION_TTL, json.dumps(data)) response.set_cookie( key="session_id", value=session_id, httponly=True, secure=True, samesite="lax", max_age=SESSION_TTL, ) return session_id async def get_session(request: Request) -> dict | None: session_id = request.cookies.get("session_id") if not session_id: return None data = await redis_client.get(f"session:{session_id}") if data: # Refresh TTL on access (sliding expiry) await redis_client.expire(f"session:{session_id}", SESSION_TTL) return json.loads(data) return None async def destroy_session(request: Request, response: Response): session_id = request.cookies.get("session_id") if session_id: await redis_client.delete(f"session:{session_id}") response.delete_cookie("session_id") ``` ### 5. RBAC Patterns **Role and permission model** ```python # GOOD - permission-based RBAC, not just role names from enum import Enum class Permission(str, Enum): READ_POSTS = "read:posts" WRITE_POSTS = "write:posts" DELETE_POSTS = "delete:posts" MANAGE_USERS = "manage:users" ADMIN_ALL = "admin:all" ROLE_PERMISSIONS: dict[str, set[Permission]] = { "viewer": {Permission.READ_POSTS}, "editor": {Permission.READ_POSTS, Permission.WRITE_POSTS}, "admin": {Permission.READ_POSTS, Permission.WRITE_POSTS, Permission.DELETE_POSTS, Permission.MANAGE_USERS}, "superadmin": {Permission.ADMIN_ALL}, } def has_permission(user_role: str, required: Permission) -> bool: permissions = ROLE_PERMISSIONS.get(user_role, set()) return required in permissions or Permission.ADMIN_ALL in permissions ``` **FastAPI -- dependency-based authorization** ```python # GOOD - reusable auth dependency with permission check from fastapi import Depends, HTTPException, Request async def get_current_user(request: Request) -> User: token = request.cookies.get("access_token") if not token: raise HTTPException(status_code=401, detail="Not authenticated") try: payload = decode_token(token) user = await user_repo.get(int(payload["sub"])) if not user: raise HTTPException(status_code=401, detail="User not found") return user except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token") def require_permission(permission: Permission): async def checker(user: User = Depends(get_current_user)): if not has_permission(user.role, permission): raise HTTPException(status_code=403, detail="Insufficient permissions") return user return checker @app.delete("/posts/{post_id}") async def delete_post( post_id: int, user: User = Depends(require_permission(Permission.DELETE_POSTS)), ): post = await post_repo.get(post_id) if not post: raise HTTPException(status_code=404) await post_repo.delete(post_id) return {"deleted": True} ``` **Express -- middleware-based authorization** ```typescript // GOOD - composable permission middleware interface AuthUser { id: string; role: string; permissions: string[]; } function requirePermission(...required: string[]) { return (req: Request, res: Response, next: NextFunction) => { const user = req.user as AuthUser | undefined; if (!user) { return res.status(401).json({ error: "Not authenticated" }); } const hasAll = required.every( (perm) => user.permissions.includes(perm) || user.permissions.includes("admin:all"), ); if (!hasAll) { return res.status(403).json({ error: "Insufficient permissions" }); } next(); }; } // Usage app.delete("/posts/:id", requirePermission("delete:posts"), deletePostHandler); app.get("/admin/users", requirePermission("manage:users"), listUsersHandler); ``` ### 6. Protected Routes **Next.js middleware (App Router)** ```typescript // middleware.ts -- runs on every matching request at the edge import { NextRequest, NextResponse } from "next/server"; import { jwtVerify } from "jose"; const PUBLIC_PATHS = ["/", "/login", "/signup", "/api/auth"]; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Allow public paths if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) { return NextResponse.next(); } const token = request.cookies.get("access_token")?.value; if (!token) { return NextResponse.redirect(new URL("/login", request.url)); } try { const secret = new TextEncoder().encode(process.env.JWT_SECRET!); await jwtVerify(token, secret); return NextResponse.next(); } catch { // Token invalid or expired -- redirect to login const response = NextResponse.redirect(new URL("/login", request.url)); response.cookies.delete("access_token"); return response; } } export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; ``` **FastAPI -- dependency injection guard** ```python # GOOD - protect entire router with a dependency from fastapi import APIRouter, Depends protected_router = APIRouter( prefix="/api/v1", dependencies=[Depends(get_current_user)], # all routes require auth ) @protected_router.get("/profile") async def get_profile(user: User = Depends(get_current_user)): return {"id": user.id, "name": user.name, "role": user.role} @protected_router.get("/admin/stats") async def admin_stats(user: User = Depends(require_permission(Permission.ADMIN_ALL))): return await compute_stats() # Mount protected and public routers separately app.include_router(auth_router) # /auth/* -- public app.include_router(protected_router) # /api/v1/* -- requires auth ``` **Express -- route-level guard** ```typescript // GOOD - auth middleware applied selectively function requireAuth(req: Request, res: Response, next: NextFunction) { const token = req.cookies.access_token; if (!token) { return res.status(401).json({ error: "Authentication required" }); } try { const payload = jwt.verify(token, process.env.JWT_SECRET!); req.user = payload as AuthUser; next(); } catch { res.clearCookie("access_token"); return res.status(401).json({ error: "Invalid or expired token" }); } } // Public routes app.post("/auth/login", loginHandler); app.post("/auth/register", registerHandler); // Protected routes app.use("/api", requireAuth); app.get("/api/profile", profileHandler); app.get("/api/posts", listPostsHandler); ``` ### 7. Multi-Factor Authentication (TOTP) **Python -- pyotp** ```python # GOOD - TOTP setup and verification import pyotp def generate_totp_secret() -> str: """Generate a new TOTP secret for a user.""" return pyotp.random_base32() def get_totp_provisioning_uri(secret: str, user_email: str, issuer: str = "MyApp") -> str: """Generate a QR code URI for authenticator app setup.""" return pyotp.totp.TOTP(secret).provisioning_uri( name=user_email, issuer_name=issuer, ) def verify_totp(secret: str, code: str) -> bool: """Verify a TOTP code with a 30-second window tolerance.""" totp = pyotp.TOTP(secret) return totp.verify(code, valid_window=1) # allows +/- 30 seconds ``` **TypeScript -- otplib** ```typescript // GOOD - TOTP with otplib import { authenticator } from "otplib"; function generateTotpSecret(): string { return authenticator.generateSecret(); } function getTotpUri(secret: string, email: string): string { return authenticator.keyuri(email, "MyApp", secret); } function verifyTotp(secret: string, code: string): boolean { return authenticator.check(code, secret); } ``` **Backup codes** ```python # GOOD - generate one-time backup codes for MFA recovery import secrets def generate_backup_codes(count: int = 10) -> list[str]: """Generate single-use backup codes. Store hashed, show once.""" return [secrets.token_hex(4).upper() for _ in range(count)] # Example output: ["A1B2C3D4", "E5F6A7B8", ...] # Store hashed backup codes in the database from argon2 import PasswordHasher ph = PasswordHasher() async def store_backup_codes(user_id: int, codes: list[str]): hashed_codes = [ph.hash(code) for code in codes] await db.execute( "UPDATE users SET backup_codes = $1 WHERE id = $2", [json.dumps(hashed_codes), user_id], ) async def verify_backup_code(user_id: int, code: str) -> bool: user = await db.get(User, user_id) hashed_codes = json.loads(user.backup_codes) for i, hashed in enumerate(hashed_codes): try: if ph.verify(hashed, code): # Remove used code (single-use) hashed_codes.pop(i) await db.execute( "UPDATE users SET backup_codes = $1 WHERE id = $2", [json.dumps(hashed_codes), user_id], ) return True except Exception: continue return False ``` **MFA login flow** ```python # GOOD - two-step login: credentials first, then MFA @router.post("/auth/login") async def login(credentials: LoginRequest, response: Response): user = await authenticate_user(credentials.email, credentials.password) if not user: raise HTTPException(status_code=401, detail="Invalid credentials") if user.mfa_enabled: # Issue a short-lived MFA challenge token (not a full session) mfa_token = create_mfa_challenge_token(user.id) return {"requires_mfa": True, "mfa_token": mfa_token} # No MFA -- issue full tokens set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id)) return {"user": {"id": user.id, "name": user.name}} @router.post("/auth/mfa/verify") async def verify_mfa(payload: MfaVerifyRequest, response: Response): # Validate the MFA challenge token challenge = decode_mfa_challenge_token(payload.mfa_token) user = await user_repo.get(challenge["user_id"]) if not verify_totp(user.totp_secret, payload.code): # Also check backup codes as fallback if not await verify_backup_code(user.id, payload.code): raise HTTPException(status_code=401, detail="Invalid MFA code") set_auth_cookies(response, create_access_token(user.id, user.role), create_refresh_token(user.id)) return {"user": {"id": user.id, "name": user.name}} ``` --- ## Best Practices 1. **Use short-lived access tokens (5-15 minutes) paired with refresh tokens (7-30 days).** Short access tokens limit the damage window if a token is compromised. Refresh tokens allow seamless re-authentication without re-entering credentials. 2. **Deliver tokens in httpOnly, secure, sameSite cookies.** Never return tokens in JSON response bodies for browser-based apps. httpOnly prevents XSS from reading the token, secure ensures HTTPS-only transmission, and sameSite=lax mitigates CSRF. 3. **Hash passwords with argon2id or bcrypt, never with MD5, SHA-1, or SHA-256 alone.** Adaptive hashing functions include a work factor that makes brute-force attacks computationally expensive. Increase the cost factor as hardware improves. 4. **Regenerate session IDs after login.** Session fixation attacks exploit predictable or reused session IDs. Always issue a new session ID after successful authentication. 5. **Validate the state parameter in OAuth2 callbacks.** The state parameter prevents CSRF attacks during the authorization flow. Generate a cryptographically random value, store it in the session, and verify it when the callback arrives. 6. **Implement token revocation for refresh tokens.** Store refresh token JTIs (unique identifiers) in a database or Redis. On logout, revoke all active refresh tokens for the user. Check the revocation list on every refresh attempt. 7. **Apply the principle of least privilege in RBAC.** Default new users to the most restrictive role. Grant permissions explicitly, not implicitly. Check permissions at the object level, not just the role level. 8. **Rate-limit authentication endpoints aggressively.** Apply strict rate limits (5-10 attempts per minute) on login, registration, password reset, and MFA verification endpoints. Use both IP-based and account-based limiting. --- ## Common Pitfalls 1. **Storing JWTs in localStorage.** Any XSS vulnerability can steal the token. Use httpOnly cookies instead. If you must use localStorage (e.g., for native apps), pair it with strict CSP and regular XSS auditing. 2. **Not validating the token type claim.** Without a `type` field in the JWT payload, a refresh token could be used as an access token and vice versa. Always include and verify a `type` claim. 3. **Using symmetric keys (HS256) with shared secrets across services.** If multiple services verify tokens, any service that can verify can also forge tokens. Use asymmetric keys (RS256/ES256) so only the auth service holds the private key. 4. **Checking authentication but not authorization.** A valid token proves identity but not permission. Always verify that the authenticated user has the specific permission required for the requested action. 5. **Returning different error messages for "user not found" vs "wrong password."** This leaks information about which accounts exist (user enumeration). Return a generic "Invalid credentials" message for both cases. 6. **Not setting absolute session timeouts.** Sliding expiry alone means a session can live forever with continuous activity. Set an absolute maximum lifetime (e.g., 8 hours) in addition to idle timeout (e.g., 30 minutes). --- ## Security Checklist - [ ] Access tokens expire within 15 minutes, refresh tokens within 7-30 days - [ ] Tokens delivered in httpOnly, secure, sameSite cookies (not localStorage) - [ ] Passwords hashed with argon2id or bcrypt (12+ rounds) - [ ] Session IDs regenerated after successful login - [ ] OAuth2 state parameter validated on callback - [ ] Refresh tokens have unique JTI and can be revoked - [ ] RBAC permissions checked at object level, not just role level - [ ] Login, registration, and password reset endpoints are rate-limited - [ ] Error messages do not distinguish between "user not found" and "wrong password" - [ ] MFA backup codes are hashed and single-use - [ ] TOTP secrets are stored encrypted at rest - [ ] Absolute session timeout enforced alongside sliding expiry - [ ] CSRF protection in place for all state-changing endpoints --- ## Related Skills - `owasp` - OWASP Top 10 security patterns and secure coding practices - `api-client` - HTTP client patterns including auth token injection and refresh flows - `fastapi` - FastAPI-specific dependency injection and middleware patterns - `nextjs` - Next.js middleware and route protection patterns