Files
claudekit/skills/authentication/references/patterns.md
T
2026-04-19 14:10:38 +07:00

857 lines
28 KiB
Markdown

# 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<string> {
return new SignJWT({ role, type: "access" })
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function createRefreshToken(userId: string): Promise<string> {
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<TokenSet> {
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<TokenSet>;
}
```
**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<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
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