auth-ops
Authentication and authorization patterns - JWT, OAuth2, sessions, RBAC, ABAC, passkeys, MFA, identity-aware proxies, and Better Auth. Use for: authentication, jwt, oauth2, session, login, rbac, abac, passkey, mfa, totp, api key, token, cookie, csrf, bearer token, refresh token,
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/auth-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Auth Operations
Comprehensive authentication and authorization patterns for secure application development across languages and frameworks.
Authentication Method Decision Tree
Use this tree to select the right authentication strategy for your use case.
What are you building?
│
├─ Traditional web application (server-rendered)?
│ └─ Session-based authentication
│ ├─ Server stores session data (Redis/DB)
│ ├─ Session ID in httpOnly cookie
│ └─ Best for: monoliths, SSR apps, admin panels
│
├─ API consumed by multiple clients?
│ └─ JWT (JSON Web Tokens)
│ ├─ Stateless, self-contained tokens
│ ├─ Access token (short-lived) + refresh token (long-lived)
│ └─ Best for: microservices, mobile apps, SPAs via BFF
│
├─ Service-to-service communication?
│ └─ API keys or Client Credentials (OAuth2)
│ ├─ API keys: simple, scoped, rotatable
│ ├─ Client Credentials: OAuth2 standard, token-based
│ └─ Best for: internal services, third-party integrations
│
├─ Third-party login (Google, GitHub, etc.)?
│ └─ OAuth2 / OpenID Connect
│ ├─ Authorization Code + PKCE for web/mobile
│ ├─ Delegate identity to trusted providers
│ └─ Best for: consumer apps, social login
│
├─ Passwordless authentication?
│ └─ Passkeys (WebAuthn) or Magic Links
│ ├─ Passkeys: phishing-resistant, biometric/hardware
│ ├─ Magic links: email-based, time-limited
│ └─ Best for: high-security, modern UX
│
└─ Internal tool / staff app with an existing IdP?
└─ Identity-aware proxy (Cloudflare Access)
├─ Authn enforced at the edge, before your origin
├─ Origin verifies the proxy's signed JWT (never a bare header)
└─ Best for: admin panels, partner portals, not consumer signup
JWT Quick Reference
Structure
Header.Payload.Signature
Header: { "alg": "RS256", "typ": "JWT" }
Payload: { "iss": "auth.example.com", "sub": "user_123", ... }
Signature: RSASHA256(base64(header) + "." + base64(payload), privateKey)
Common Claims
| Claim | Name | Purpose | Example |
|---|---|---|---|
iss |
Issuer | Who issued the token | "auth.example.com" |
sub |
Subject | Who the token represents | "user_123" |
exp |
Expiration | When the token expires | 1700000000 (Unix timestamp) |
iat |
Issued At | When the token was created | 1699999100 |
aud |
Audience | Intended recipient(s) | "api.example.com" |
jti |
JWT ID | Unique token identifier | "a1b2c3d4" (for revocation) |
nbf |
Not Before | Token not valid before this time | 1699999100 |
Signing Algorithms
| Algorithm | Type | Key | Use When |
|---|---|---|---|
| RS256 | Asymmetric (RSA) | Public/private key pair | Distributed systems, multiple verifiers |
| ES256 | Asymmetric (ECDSA) | Public/private key pair | Same as RS256, smaller keys/signatures |
| HS256 | Symmetric (HMAC) | Shared secret | Single service, simple setups |
Rule of thumb: Use asymmetric (RS256/ES256) when the token issuer and verifier are different services. Use HS256 only when a single service both creates and verifies tokens.
Access + Refresh Token Pattern
┌──────────┐ ┌──────────┐
│ Client │─── login ────────>│ Auth │
│ │<── access (15m) ──│ Server │
│ │<── refresh (7d) ──│ │
│ │ └──────────┘
│ │─── API call ─────>┌──────────┐
│ │ (access token) │ Resource │
│ │<── response ──────│ Server │
│ │ └──────────┘
│ │─── access expired │ │
│ │─── refresh ──────>│ Auth │
│ │<── new access ────│ Server │
│ │<── new refresh ───│ (rotate)│
└──────────┘ └──────────┘
- Access token: Short-lived (5-15 minutes), used for API calls
- Refresh token: Long-lived (7-30 days), used to get new access tokens
- Rotation: Issue a new refresh token with each use, invalidate the old one
- Family detection: Track refresh token lineage; if a revoked token is reused, invalidate the entire family
OAuth2 Flow Decision Tree
What type of client?
│
├─ Web app with backend (Next.js, Rails, Django)?
│ └─ Authorization Code + PKCE
│ ├─ Redirect user to authorization server
│ ├─ Receive code at callback URL
│ ├─ Exchange code for tokens server-side
│ └─ PKCE prevents code interception attacks
│
├─ SPA (React, Vue) without backend?
│ └─ Authorization Code + PKCE (via BFF)
│ ├─ Use a Backend-for-Frontend to handle tokens
│ ├─ Never store tokens in browser-accessible storage
│ └─ BFF proxies API calls with token attached
│
├─ Mobile app (iOS, Android)?
│ └─ Authorization Code + PKCE
│ ├─ Use custom URI scheme or universal links for redirect
│ ├─ PKCE is mandatory (public client)
│ └─ Store tokens in secure enclave/keystore
│
├─ Server-to-server (no user)?
│ └─ Client Credentials
│ ├─ Authenticate with client_id + client_secret
│ ├─ No user context, service-level access
│ └─ Token cached until expiry
│
├─ CLI tool or smart TV?
│ └─ Device Code
│ ├─ Display code and URL to user
│ ├─ User authenticates on another device
│ ├─ CLI/TV polls for completion
│ └─ Good UX for input-constrained devices
│
└─ Microservice acting on behalf of a user?
└─ Token Exchange (RFC 8693)
├─ Exchange user's token for a scoped downstream token
├─ Maintains user context across services
└─ Use `act` claim for delegation chain
Authorization Model Decision Tree
How complex are your access control needs?
│
├─ Simple: just "can user X do action Y"?
│ └─ Permission-based (direct)
│ ├─ user_permissions table
│ ├─ Simple to implement, hard to scale
│ └─ Good for: small apps, prototypes
│
├─ Users grouped into roles with fixed permissions?
│ └─ RBAC (Role-Based Access Control)
│ ├─ Roles: admin, editor, viewer
│ ├─ Each role has a set of permissions
│ ├─ Users assigned one or more roles
│ └─ Good for: most apps, admin panels, team tools
│
├─ Decisions depend on attributes (time, location, resource owner)?
│ └─ ABAC (Attribute-Based Access Control)
│ ├─ Policies evaluate subject + resource + environment attributes
│ ├─ "Allow if user.department == resource.department AND time < 17:00"
│ ├─ Flexible but complex
│ └─ Good for: enterprise, compliance-heavy, context-dependent access
│
└─ Access based on relationships (owner, parent, shared with)?
└─ ReBAC (Relationship-Based Access Control)
├─ Google Zanzibar model
├─ Tuples: user:alice#viewer@document:report
├─ Supports inheritance: folder viewer → document viewer
├─ Tools: OpenFGA, SpiceDB, Ory Keto
└─ Good for: file sharing, nested resources, social features
Session Management Quick Reference
Cookie Security Settings
| Setting | Value | Purpose |
|---|---|---|
SameSite |
Strict |
Cookie sent only for same-site requests (best CSRF protection) |
SameSite |
Lax |
Cookie sent for top-level navigations (good default) |
SameSite |
None |
Cookie sent for cross-site requests (requires Secure) |
Secure |
true |
Cookie only sent over HTTPS |
HttpOnly |
true |
Cookie not accessible via JavaScript (prevents XSS theft) |
__Host- prefix |
N/A | Requires Secure, no Domain, Path=/ (strictest) |
__Secure- prefix |
N/A | Requires Secure flag |
Max-Age |
seconds | Cookie lifetime (prefer over Expires) |
Path |
/ |
Scope cookie to path (usually /) |
Recommended Cookie Configuration
Set-Cookie: __Host-session=abc123;
Secure;
HttpOnly;
SameSite=Lax;
Max-Age=86400;
Path=/
Session Expiry Strategies
| Strategy | Typical Value | Notes |
|---|---|---|
| Idle timeout | 15-30 minutes | Reset on each request |
| Absolute timeout | 8-24 hours | Force re-authentication |
| Sliding window | 30 min idle, 8h max | Best balance |
| Remember me | 30 days | Extended session, reduced privileges |
Password Handling Quick Reference
Hashing Algorithms
| Algorithm | Verdict | Notes |
|---|---|---|
| argon2id | BEST | Memory-hard, resists GPU attacks, recommended by OWASP |
| bcrypt | GOOD | Battle-tested, cost factor 12+, 72-byte input limit |
| scrypt | GOOD | Memory-hard, less common library support |
| PBKDF2 | ACCEPTABLE | FIPS compliant, use 600k+ iterations with SHA-256 |
| SHA-256/512 | BAD | Too fast, no salt built-in, easily brute-forced |
| MD5 | NEVER | Broken, rainbow tables widely available |
Password Rules (NIST 800-63B)
| Rule | Guidance |
|---|---|
| Minimum length | 8 characters (12+ recommended) |
| Maximum length | At least 64 characters |
| Complexity rules | Do NOT require special chars/uppercase/numbers |
| Breached password check | Check against known breached passwords (HaveIBeenPwned API) |
| Password hints | Do NOT allow |
| Forced rotation | Do NOT force periodic changes (only on breach) |
| Paste into password field | ALLOW (supports password managers) |
Rate Limiting Login Attempts
| Attempt | Response |
|---|---|
| 1-5 | Normal login |
| 6-10 | CAPTCHA required |
| 11-20 | Progressive delays (2s, 4s, 8s...) |
| 20+ | Temporary account lockout (15-30 min) |
Important: Use consistent response times for both success and failure to prevent timing-based username enumeration.
MFA Quick Reference
Methods Ranked by Security
| Method | Security | UX | Notes |
|---|---|---|---|
| WebAuthn/Passkeys | Highest | Good | Phishing-resistant, hardware-backed |
| TOTP (Authenticator) | High | Medium | App-based (Google/Microsoft Authenticator) |
| Push notifications | High | Good | Requires mobile app |
| Email OTP | Medium | Medium | Depends on email security |
| SMS OTP | Low | Easy | SIM swap vulnerable, use as fallback only |
TOTP Implementation Checklist
- Generate 160-bit secret (base32 encoded)
- Build otpauth:// URI with issuer and account
- Display QR code for authenticator scanning
- Require verification of first code before enabling
- Accept current window +/- 1 (30-second steps)
- Generate 8-10 single-use backup codes
- Hash backup codes before storing
- Allow recovery via verified identity
Passkey/WebAuthn Checklist
- Generate cryptographic challenge on server
- Set relying party ID (your domain)
- Store credential public key and ID
- Verify signature on authentication
- Support multiple credentials per user
- Handle platform vs cross-platform authenticators
- Provide fallback auth method
Identity-Aware Proxy Quick Reference
When authn is delegated to a proxy edge (Cloudflare Access, Google IAP, oauth2-proxy), two invariants carry the whole model:
- Verify the assertion. The proxy's identity header is a signed JWT — verify signature + issuer + per-application audience against the proxy's JWKS on every request. Never trust the plain email convenience headers.
- Close every path around the proxy. The header is only meaningful if the proxy is the only way to reach the origin (
workers_dev = false, firewalled origin, or tunnel). An open origin makes any header forgeable.
Proxy edge (authn) ──JWT header──> Origin verifies JWT ──> app user lookup ──> role/scope binding
│ │ 403 on any failure │ 403 if no row (server-side)
└ IdP / OTP login, sessions └ cached JWKS, └ proxy admits ≠ app authorizes
rate limits, bot defense refetch on unknown kid
Machine routes (webhooks, ingest) get Service-Auth/Bypass at the edge + bearer keys at the origin, mounted outside the human-auth middleware. Full treatment: references/cloudflare-access.md.
Common Gotchas
| Gotcha | Why It's Dangerous | Fix |
|---|---|---|
| JWT stored in localStorage | XSS can steal tokens, no expiry enforcement by browser | Use httpOnly cookies or BFF pattern |
| Missing PKCE in OAuth2 | Authorization code interception attacks possible | Always use PKCE, even for confidential clients |
| Role explosion in RBAC | Hundreds of roles become unmanageable | Move to ABAC or ReBAC for complex scenarios |
| String comparison for tokens | Timing attacks reveal token value character by character | Use constant-time comparison (crypto.timingSafeEqual) |
| No token revocation strategy | Cannot invalidate compromised JWTs before expiry | Short expiry + refresh tokens, or maintain a blocklist |
CORS with credentials: true |
Access-Control-Allow-Origin: * does not work with credentials |
Specify exact origin, set Access-Control-Allow-Credentials: true |
SameSite=None without Secure |
Browser silently rejects the cookie | Always pair SameSite=None with Secure flag |
| Refresh token reuse without detection | Stolen refresh tokens grant indefinite access | Rotate refresh tokens, detect reuse (token families) |
| Using OAuth2 Implicit grant | Tokens exposed in URL fragment, no refresh tokens | Use Authorization Code + PKCE instead (Implicit is deprecated) |
| Password in URL or logs | URLs are logged by proxies, browsers, and servers | Always send credentials in request body or headers |
| Missing CSRF protection with cookies | Cookie-based auth is vulnerable to cross-site request forgery | Use SameSite cookies + CSRF tokens for state-changing ops |
| Long-lived access tokens (hours/days) | Large attack window if token is compromised | Keep access tokens to 5-15 minutes, use refresh tokens |
| Storing API keys in plaintext | Database breach exposes all keys | Hash stored keys (SHA-256 of key), store prefix for lookup |
Not validating JWT aud claim |
Token meant for Service A accepted by Service B | Always validate aud matches your service identifier |
| Session fixation | Attacker sets session ID before login, then hijacks it | Regenerate session ID after authentication |
| Hardcoded secrets in code | Secrets leak via source control | Use environment variables or secret managers (Vault, AWS SSM) |
| Trusting an identity-aware proxy's plain email header | Headers are attacker-settable on any unproxied path | Verify the proxy's signed JWT (sig + issuer + audience); close every path around the proxy |
| Auth-library middleware as the only session check | Framework middleware can be bypassed (Next.js CVE-2025-29927 class) | Re-check the session in the data-access layer / route handlers |
Reference Files
| File | Contents | Lines |
|---|---|---|
references/jwt-sessions.md |
JWT structure, signing, sessions, cookies, CSRF, storage | ~650 |
references/oauth2-oidc.md |
OAuth2 flows, OIDC, provider integration, social login | ~700 |
references/authorization.md |
RBAC, ABAC, ReBAC, RLS, multi-tenant, audit logging | ~600 |
references/implementation.md |
Password hashing, MFA, rate limiting, API keys, reset flows | ~550 |
references/cloudflare-access.md |
Identity-aware proxies via Cloudflare Access: app/policy anatomy, token claims, JWT verification, closed-origin precondition, service auth, sessions/logout/SPA, local dev | ~330 |
references/better-auth.md |
Better Auth library: server/client setup, adapters, session model, social login, plugin catalog (passkey/2FA/org/SSO), Hono integration, migration | ~240 |
See Also
- security-ops - Broader security patterns: OWASP, headers, input validation, encryption
- api-design-ops - API design including authentication endpoints, rate limiting
- postgres-ops - Row-level security (RLS) policies for database authorization
- cloudflare-ops - Workers runtime, wrangler config, secrets, deploy mechanics behind an Access-fronted origin
Files (claude-mods)
-
assets
-
.gitkeep 0 B · in bundle
-
-
references
-
authorization.md 27.4 KB
# Authorization Patterns Comprehensive reference for authorization models: RBAC, ABAC, ReBAC, row-level security, multi-tenant, and audit logging. ## RBAC (Role-Based Access Control) The most common authorization model. Users are assigned roles, and roles have permissions. ### Data Model ```sql -- Core RBAC tables CREATE TABLE roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT UNIQUE NOT NULL, -- 'admin', 'editor', 'viewer' description TEXT, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), resource TEXT NOT NULL, -- 'posts', 'users', 'settings' action TEXT NOT NULL, -- 'create', 'read', 'update', 'delete' UNIQUE(resource, action) ); CREATE TABLE role_permissions ( role_id UUID REFERENCES roles(id) ON DELETE CASCADE, permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id) ); CREATE TABLE user_roles ( user_id UUID REFERENCES users(id) ON DELETE CASCADE, role_id UUID REFERENCES roles(id) ON DELETE CASCADE, granted_by UUID REFERENCES users(id), granted_at TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (user_id, role_id) ); ``` ### Role Hierarchy ``` super_admin └─ admin ├─ editor │ └─ viewer └─ moderator └─ viewer ``` ```sql -- Role hierarchy table CREATE TABLE role_hierarchy ( parent_role_id UUID REFERENCES roles(id), child_role_id UUID REFERENCES roles(id), PRIMARY KEY (parent_role_id, child_role_id) ); -- Query: Get all permissions for a user (including inherited) WITH RECURSIVE effective_roles AS ( -- Direct roles SELECT role_id FROM user_roles WHERE user_id = $1 UNION -- Inherited roles (parent inherits child permissions) SELECT rh.child_role_id FROM role_hierarchy rh JOIN effective_roles er ON er.role_id = rh.parent_role_id ) SELECT DISTINCT p.resource, p.action FROM effective_roles er JOIN role_permissions rp ON rp.role_id = er.role_id JOIN permissions p ON p.id = rp.permission_id; ``` ### Middleware Patterns #### Node.js / Express ```javascript // Permission checking middleware function requirePermission(resource, action) { return async (req, res, next) => { const userId = req.auth.sub; const hasPermission = await db.query(` WITH RECURSIVE effective_roles AS ( SELECT role_id FROM user_roles WHERE user_id = $1 UNION SELECT rh.child_role_id FROM role_hierarchy rh JOIN effective_roles er ON er.role_id = rh.parent_role_id ) SELECT EXISTS ( SELECT 1 FROM effective_roles er JOIN role_permissions rp ON rp.role_id = er.role_id JOIN permissions p ON p.id = rp.permission_id WHERE p.resource = $2 AND p.action = $3 ) `, [userId, resource, action]); if (!hasPermission.rows[0].exists) { return res.status(403).json({ error: 'Forbidden', required: `${action}:${resource}`, }); } next(); }; } // Usage app.get('/api/posts', requirePermission('posts', 'read'), listPosts); app.post('/api/posts', requirePermission('posts', 'create'), createPost); app.delete('/api/posts/:id', requirePermission('posts', 'delete'), deletePost); ``` #### Python / FastAPI ```python from fastapi import Depends, HTTPException, status from functools import wraps def require_permission(resource: str, action: str): async def checker(user: User = Depends(get_current_user)): has_perm = await db.fetch_val(""" SELECT EXISTS ( SELECT 1 FROM user_roles ur JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN permissions p ON p.id = rp.permission_id WHERE ur.user_id = $1 AND p.resource = $2 AND p.action = $3 ) """, user.id, resource, action) if not has_perm: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing permission: {action}:{resource}", ) return user return Depends(checker) # Usage @app.get("/api/posts") async def list_posts(user: User = require_permission("posts", "read")): return await get_posts() @app.post("/api/posts") async def create_post( post: PostCreate, user: User = require_permission("posts", "create"), ): return await insert_post(post, user.id) ``` #### Go ```go // Middleware pattern func RequirePermission(resource, action string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("userID").(string) allowed, err := checkPermission(r.Context(), userID, resource, action) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } } // Usage with chi router r.Route("/api/posts", func(r chi.Router) { r.With(RequirePermission("posts", "read")).Get("/", listPosts) r.With(RequirePermission("posts", "create")).Post("/", createPost) r.With(RequirePermission("posts", "delete")).Delete("/{id}", deletePost) }) ``` ### When RBAC Breaks Down | Signal | Problem | Solution | |--------|---------|----------| | 50+ roles | Role explosion | Consider ABAC | | Roles like "alice-docs-editor" | Per-user roles | Consider ReBAC | | Roles vary by context | Context-dependent access | Consider ABAC | | "Owner can edit own resources" | Relationship-based | Consider ReBAC | ## ABAC (Attribute-Based Access Control) Decisions based on attributes of the subject, resource, action, and environment. ### Policy Structure ``` PERMIT if: subject.role == "doctor" AND resource.type == "medical_record" AND resource.department == subject.department AND environment.time BETWEEN 08:00 AND 18:00 AND action == "read" ``` ### Implementation ```javascript // Policy engine class PolicyEngine { constructor(policies) { this.policies = policies; } evaluate(subject, resource, action, environment) { for (const policy of this.policies) { const result = policy.evaluate(subject, resource, action, environment); if (result === 'PERMIT') return true; if (result === 'DENY') return false; // 'NOT_APPLICABLE' continues to next policy } return false; // Default deny } } // Define policies const policies = [ { name: 'owner-full-access', evaluate: (subject, resource, action, env) => { if (resource.ownerId === subject.id) return 'PERMIT'; return 'NOT_APPLICABLE'; }, }, { name: 'department-read', evaluate: (subject, resource, action, env) => { if ( action === 'read' && subject.department === resource.department ) { return 'PERMIT'; } return 'NOT_APPLICABLE'; }, }, { name: 'business-hours-only', evaluate: (subject, resource, action, env) => { const hour = env.currentTime.getHours(); if (resource.classification === 'restricted' && (hour < 8 || hour > 18)) { return 'DENY'; } return 'NOT_APPLICABLE'; }, }, ]; // Usage const engine = new PolicyEngine(policies); const allowed = engine.evaluate( { id: 'user_123', role: 'doctor', department: 'cardiology' }, { type: 'record', ownerId: 'user_456', department: 'cardiology', classification: 'normal' }, 'read', { currentTime: new Date() } ); ``` ### Combining Algorithms | Algorithm | Behavior | |-----------|----------| | **Deny-overrides** | Any DENY wins (most restrictive) | | **Permit-overrides** | Any PERMIT wins (most permissive) | | **First-applicable** | First matching policy decides | | **Only-one-applicable** | Error if multiple policies match | **Recommendation:** Use deny-overrides for security-critical systems, first-applicable for performance. ## ReBAC (Relationship-Based Access Control) Based on Google's Zanzibar paper. Access is determined by relationships between users and resources. ### Core Concepts **Relationship tuple:** `user:alice#viewer@document:report` - Subject: `user:alice` - Relation: `viewer` - Object: `document:report` Reading: "Alice is a viewer of document:report" ### Authorization Model (OpenFGA/SpiceDB) ```yaml # OpenFGA model model: schema 1.1 type user type organization relations define member: [user] define admin: [user] type folder relations define org: [organization] define owner: [user] define editor: [user, organization#member] or owner define viewer: [user, organization#member] or editor type document relations define parent: [folder] define owner: [user] define editor: [user] or owner or editor from parent define viewer: [user] or editor or viewer from parent ``` ### Relationship Tuples ``` # Alice owns the Engineering folder user:alice#owner@folder:engineering # Engineering folder belongs to Acme org organization:acme#org@folder:engineering # Bob is a member of Acme user:bob#member@organization:acme # Report document is in Engineering folder folder:engineering#parent@document:report # Now: Can Bob view document:report? # Bob is member of Acme → Acme is org of Engineering folder # → folder viewers include org members → document viewers include folder viewers # → YES, Bob can view document:report ``` ### OpenFGA Integration ```javascript // OpenFGA SDK import { OpenFgaClient } from '@openfga/sdk'; const fga = new OpenFgaClient({ apiUrl: process.env.OPENFGA_API_URL, storeId: process.env.OPENFGA_STORE_ID, }); // Write a relationship await fga.write({ writes: [ { user: 'user:alice', relation: 'editor', object: 'document:report', }, ], }); // Check access const { allowed } = await fga.check({ user: 'user:bob', relation: 'viewer', object: 'document:report', }); if (!allowed) { return res.status(403).json({ error: 'Access denied' }); } // List objects a user can access const { objects } = await fga.listObjects({ user: 'user:alice', relation: 'viewer', type: 'document', }); // objects: ['document:report', 'document:spec', ...] // List users with access to an object const { users } = await fga.listUsers({ object: { type: 'document', id: 'report' }, relation: 'viewer', user_filters: [{ type: 'user' }], }); ``` ### SpiceDB Integration ```go // SpiceDB with authzed-go import ( v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" "github.com/authzed/authzed-go/v1" ) client, err := authzed.NewClient( "localhost:50051", grpc.WithInsecure(), grpcutil.WithInsecureBearerToken("my-token"), ) // Write relationship _, err = client.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{ Updates: []*v1.RelationshipUpdate{ { Operation: v1.RelationshipUpdate_OPERATION_CREATE, Relationship: &v1.Relationship{ Resource: &v1.ObjectReference{ ObjectType: "document", ObjectId: "report", }, Relation: "viewer", Subject: &v1.SubjectReference{ Object: &v1.ObjectReference{ ObjectType: "user", ObjectId: "alice", }, }, }, }, }, }) // Check permission resp, err := client.CheckPermission(ctx, &v1.CheckPermissionRequest{ Resource: &v1.ObjectReference{ ObjectType: "document", ObjectId: "report", }, Permission: "view", Subject: &v1.SubjectReference{ Object: &v1.ObjectReference{ ObjectType: "user", ObjectId: "bob", }, }, }) if resp.Permissionship == v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION { // Access granted } ``` ### When to Use ReBAC | Scenario | RBAC | ReBAC | |----------|------|-------| | "Admins can manage users" | Good fit | Overkill | | "Owner can edit their documents" | Awkward | Good fit | | "Folder viewers can view contained documents" | Cannot model | Good fit | | "Shared-with users can view" | Cannot model | Good fit | | "Org members can access org resources" | Possible but fragile | Good fit | ## Row-Level Security (RLS) Database-level authorization that filters query results based on the current user. ### PostgreSQL RLS ```sql -- Enable RLS on table ALTER TABLE documents ENABLE ROW LEVEL SECURITY; -- Force RLS for table owner too (optional, for safety) ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- Policy: Users can only see their own documents CREATE POLICY "users_own_documents" ON documents FOR ALL USING (owner_id = current_setting('app.current_user_id')::uuid); -- Policy: Users can see documents shared with them CREATE POLICY "shared_documents" ON documents FOR SELECT USING ( id IN ( SELECT document_id FROM document_shares WHERE user_id = current_setting('app.current_user_id')::uuid ) ); -- Policy: Admins can see all documents CREATE POLICY "admin_full_access" ON documents FOR ALL USING ( EXISTS ( SELECT 1 FROM user_roles WHERE user_id = current_setting('app.current_user_id')::uuid AND role = 'admin' ) ); -- Set the current user context before queries SET app.current_user_id = 'user_abc123'; SELECT * FROM documents; -- Only returns allowed rows ``` ### Supabase RLS ```sql -- Supabase provides auth.uid() and auth.jwt() functions -- Users can read their own profile CREATE POLICY "read_own_profile" ON profiles FOR SELECT USING (auth.uid() = id); -- Users can update their own profile CREATE POLICY "update_own_profile" ON profiles FOR UPDATE USING (auth.uid() = id); -- Users can read posts in their organization CREATE POLICY "org_posts" ON posts FOR SELECT USING ( org_id IN ( SELECT org_id FROM org_members WHERE user_id = auth.uid() ) ); -- Service role bypasses RLS (for admin operations) -- Use supabase.createClient(url, SERVICE_ROLE_KEY) for admin access ``` ### Application-Level Row Filtering When you can't use RLS (e.g., non-PostgreSQL databases): ```javascript // Query builder pattern class AuthorizedQuery { constructor(user) { this.user = user; this.filters = []; } forResource(table) { if (this.user.role === 'admin') { // No filter for admins } else if (this.user.role === 'manager') { this.filters.push(`${table}.org_id = ?`, this.user.orgId); } else { this.filters.push(`${table}.owner_id = ?`, this.user.id); } return this; } apply(queryBuilder) { for (const filter of this.filters) { queryBuilder.where(filter); } return queryBuilder; } } // Usage const query = new AuthorizedQuery(currentUser) .forResource('documents') .apply(db('documents').select('*')); ``` ## Multi-Tenant Authorization ### Tenant Isolation Strategies | Strategy | Isolation | Complexity | Use When | |----------|-----------|------------|----------| | **Shared database, shared schema** | Row-level (tenant_id column) | Low | SaaS with many small tenants | | **Shared database, separate schemas** | Schema-level | Medium | Moderate data isolation needs | | **Separate databases** | Complete | High | Strict compliance, large tenants | ### Shared Schema with tenant_id ```sql -- Every table has a tenant_id CREATE TABLE documents ( id UUID PRIMARY KEY, tenant_id UUID NOT NULL REFERENCES tenants(id), title TEXT NOT NULL, content TEXT, owner_id UUID NOT NULL REFERENCES users(id), CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ); -- RLS policy enforces tenant isolation CREATE POLICY "tenant_isolation" ON documents FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid); -- Index for performance CREATE INDEX idx_documents_tenant ON documents(tenant_id); ``` ### Tenant-Scoped Roles ```sql -- Roles are scoped to a tenant CREATE TABLE tenant_user_roles ( tenant_id UUID REFERENCES tenants(id), user_id UUID REFERENCES users(id), role TEXT NOT NULL, -- 'owner', 'admin', 'member', 'viewer' PRIMARY KEY (tenant_id, user_id) ); -- A user can be admin in one tenant and viewer in another INSERT INTO tenant_user_roles VALUES ('tenant_1', 'alice', 'admin'), ('tenant_2', 'alice', 'viewer'); ``` ### Middleware: Tenant Context ```javascript // Express middleware - set tenant context async function tenantContext(req, res, next) { // Determine tenant from subdomain, header, or JWT claim const tenantId = req.headers['x-tenant-id'] || req.auth?.tenantId || extractFromSubdomain(req.hostname); if (!tenantId) { return res.status(400).json({ error: 'Tenant not specified' }); } // Verify user belongs to tenant const membership = await db.query( 'SELECT role FROM tenant_user_roles WHERE tenant_id = $1 AND user_id = $2', [tenantId, req.auth.sub] ); if (!membership.rows.length) { return res.status(403).json({ error: 'Not a member of this tenant' }); } req.tenant = { id: tenantId, role: membership.rows[0].role }; // Set PostgreSQL session variable for RLS await db.query("SET app.current_tenant_id = $1", [tenantId]); next(); } ``` ### Cross-Tenant Access ```javascript // Controlled cross-tenant access (e.g., shared documents) async function checkCrossTenantAccess(userId, resourceId, targetTenantId) { // Check if resource has cross-tenant sharing enabled const share = await db.query(` SELECT permission FROM cross_tenant_shares WHERE resource_id = $1 AND shared_with_tenant_id = $2 AND (expires_at IS NULL OR expires_at > now()) `, [resourceId, targetTenantId]); if (!share.rows.length) return false; return share.rows[0].permission; // 'read', 'write', etc. } ``` ## API Authorization ### Scope-Based (OAuth2 Scopes) ```javascript // Middleware that checks OAuth2 scopes function requireScopes(...scopes) { return (req, res, next) => { const tokenScopes = req.auth.scope?.split(' ') || []; const missing = scopes.filter((s) => !tokenScopes.includes(s)); if (missing.length) { return res.status(403).json({ error: 'insufficient_scope', missing, }); } next(); }; } ``` ### Claims-Based (JWT Claims) ```javascript // Check JWT claims for authorization function requireClaim(claim, value) { return (req, res, next) => { if (req.auth[claim] !== value) { return res.status(403).json({ error: `Required claim: ${claim}=${value}` }); } next(); }; } // Usage app.get('/admin', requireClaim('role', 'admin'), adminDashboard); app.get('/org/:orgId', (req, res, next) => { if (req.auth.org_id !== req.params.orgId) { return res.status(403).json({ error: 'Wrong organization' }); } next(); }, orgDashboard); ``` ### API Key Permissions ```javascript // API key with scoped permissions async function apiKeyAuth(req, res, next) { const apiKey = req.headers['x-api-key']; if (!apiKey) return res.status(401).json({ error: 'API key required' }); // Look up by prefix, verify by hash const prefix = apiKey.substring(0, 8); const keyRecord = await db.query( 'SELECT * FROM api_keys WHERE prefix = $1 AND revoked = false', [prefix] ); if (!keyRecord.rows.length) { return res.status(401).json({ error: 'Invalid API key' }); } const record = keyRecord.rows[0]; const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); if (!crypto.timingSafeEqual( Buffer.from(keyHash), Buffer.from(record.key_hash) )) { return res.status(401).json({ error: 'Invalid API key' }); } // Check expiry if (record.expires_at && record.expires_at < new Date()) { return res.status(401).json({ error: 'API key expired' }); } req.apiKey = { id: record.id, permissions: record.permissions, // ['read:data', 'write:data'] rateLimitTier: record.rate_limit_tier, }; next(); } ``` ## Feature Flags as Authorization Feature flags can serve as a lightweight authorization mechanism for feature rollouts. ```javascript // Simple feature flag implementation class FeatureFlags { constructor(config) { this.flags = config; } isEnabled(flag, context = {}) { const config = this.flags[flag]; if (!config) return false; // Global enable/disable if (typeof config === 'boolean') return config; // Percentage rollout if (config.percentage !== undefined) { const hash = this.hashUser(context.userId); return hash % 100 < config.percentage; } // User allowlist if (config.allowedUsers?.includes(context.userId)) return true; // Role-based if (config.allowedRoles?.includes(context.role)) return true; // Tenant-based if (config.allowedTenants?.includes(context.tenantId)) return true; return config.defaultValue ?? false; } hashUser(userId) { return parseInt( crypto.createHash('md5').update(userId).digest('hex').slice(0, 8), 16 ) % 100; } } // Usage const flags = new FeatureFlags({ new_editor: { percentage: 25 }, beta_api: { allowedRoles: ['admin'], allowedTenants: ['acme'] }, dark_mode: true, }); if (flags.isEnabled('new_editor', { userId: user.id })) { // Show new editor } ``` ## Audit Logging ### What to Log | Category | Events | |----------|--------| | **Authentication** | Login success/failure, logout, password change, MFA enable/disable | | **Authorization** | Permission denied, role changes, policy evaluations | | **Data access** | Read sensitive data, export data, search queries | | **Data modification** | Create, update, delete operations | | **Admin actions** | User management, configuration changes, key rotation | | **Security** | Suspicious activity, rate limit hits, blocked requests | ### Audit Log Schema ```sql CREATE TABLE audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), actor_id UUID, -- Who did it actor_type TEXT NOT NULL, -- 'user', 'service', 'system' action TEXT NOT NULL, -- 'user.login', 'document.delete' resource TEXT, -- 'document:123', 'user:456' outcome TEXT NOT NULL, -- 'success', 'failure', 'denied' metadata JSONB DEFAULT '{}', -- Additional context ip_address INET, user_agent TEXT, tenant_id UUID, request_id UUID -- Correlation ID ); -- Index for common queries CREATE INDEX idx_audit_actor ON audit_logs(actor_id, timestamp DESC); CREATE INDEX idx_audit_action ON audit_logs(action, timestamp DESC); CREATE INDEX idx_audit_resource ON audit_logs(resource, timestamp DESC); CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id, timestamp DESC); -- Prevent modification (append-only) REVOKE UPDATE, DELETE ON audit_logs FROM app_user; ``` ### Audit Logging Implementation ```javascript // Audit logger class AuditLogger { constructor(db) { this.db = db; } async log(event) { await this.db.query(` INSERT INTO audit_logs (actor_id, actor_type, action, resource, outcome, metadata, ip_address, user_agent, tenant_id, request_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, [ event.actorId, event.actorType || 'user', event.action, event.resource, event.outcome || 'success', JSON.stringify(event.metadata || {}), event.ipAddress, event.userAgent, event.tenantId, event.requestId, ]); } } // Express middleware for automatic audit logging function auditMiddleware(action, resourceFn) { return (req, res, next) => { const originalJson = res.json.bind(res); res.json = (body) => { const resource = resourceFn ? resourceFn(req, body) : undefined; const outcome = res.statusCode < 400 ? 'success' : 'failure'; // Fire and forget (don't block response) auditLogger.log({ actorId: req.auth?.sub, actorType: 'user', action, resource, outcome, metadata: { method: req.method, path: req.path, statusCode: res.statusCode, }, ipAddress: req.ip, userAgent: req.headers['user-agent'], tenantId: req.tenant?.id, requestId: req.headers['x-request-id'], }).catch(console.error); return originalJson(body); }; next(); }; } // Usage app.delete('/api/documents/:id', auditMiddleware('document.delete', (req) => `document:${req.params.id}`), requirePermission('documents', 'delete'), deleteDocument ); ``` ### Compliance Considerations | Requirement | Implementation | |-------------|----------------| | **Immutability** | Append-only table, no UPDATE/DELETE permissions | | **Retention** | Partition by month, archive to cold storage after N months | | **Integrity** | Hash chain (each entry includes hash of previous) | | **Access** | Separate read permissions for audit logs | | **Availability** | Async writes with retry queue, separate storage | | **Search** | Index on actor, action, resource, timestamp | ## Testing Authorization ### Unit Testing Policies ```javascript // Test RBAC permissions describe('Authorization', () => { it('admin can delete posts', async () => { const allowed = await checkPermission('admin', 'posts', 'delete'); expect(allowed).toBe(true); }); it('viewer cannot delete posts', async () => { const allowed = await checkPermission('viewer', 'posts', 'delete'); expect(allowed).toBe(false); }); it('editor can update posts', async () => { const allowed = await checkPermission('editor', 'posts', 'update'); expect(allowed).toBe(true); }); }); ``` ### Permission Matrix Testing ```javascript // Test every role × resource × action combination const matrix = { admin: { posts: ['create', 'read', 'update', 'delete'], users: ['create', 'read', 'update', 'delete'] }, editor: { posts: ['create', 'read', 'update'], users: ['read'] }, viewer: { posts: ['read'], users: ['read'] }, }; for (const [role, permissions] of Object.entries(matrix)) { for (const [resource, actions] of Object.entries(permissions)) { for (const action of ['create', 'read', 'update', 'delete']) { const expected = actions.includes(action); it(`${role} ${expected ? 'can' : 'cannot'} ${action} ${resource}`, async () => { const result = await checkPermission(role, resource, action); expect(result).toBe(expected); }); } } } ``` ### Integration Testing ```javascript // Test authorization at the HTTP level describe('POST /api/posts', () => { it('returns 403 for viewer', async () => { const res = await request(app) .post('/api/posts') .set('Authorization', `Bearer ${viewerToken}`) .send({ title: 'Test' }); expect(res.status).toBe(403); }); it('returns 201 for editor', async () => { const res = await request(app) .post('/api/posts') .set('Authorization', `Bearer ${editorToken}`) .send({ title: 'Test' }); expect(res.status).toBe(201); }); it('prevents cross-tenant access', async () => { const res = await request(app) .get('/api/posts/123') // belongs to tenant_1 .set('Authorization', `Bearer ${tenant2UserToken}`); expect(res.status).toBe(404); // 404 not 403 to avoid info leakage }); }); ``` -
better-auth.md 19.4 KB
# Better Auth (TypeScript) Deep-dive reference for [Better Auth](https://www.better-auth.com) — the framework-agnostic TypeScript authentication library: owned auth (your database, your users table) with batteries included (social login, passkeys, 2FA, organizations) via a plugin system. > **Freshness note:** Better Auth moves fast — plugin names, option shapes, and adapter APIs change between minor versions. The patterns below are architectural and stable, and specifics were verified against better-auth.com/docs as of 2026-08 — but **re-verify exact API signatures against the current docs before applying them.** Where this file and the live docs disagree, the live docs win. The docs ship an `llms.txt` index (`better-auth.com/llms.txt`) — fetch it to enumerate current pages before deep-diving. ## Where It Sits | Approach | You own | They own | Examples | |----------|---------|----------|----------| | **Hand-rolled** (Lucia-style: library-assisted sessions, you write the flows) | Everything — flows, tokens, edge cases, security hardening | Nothing | Lucia (now a learning resource), custom JWT/session code per `jwt-sessions.md` | | **Auth library, your DB** | Data, deployment, customization | Flow implementation, plugin features, security patches | **Better Auth**, Auth.js/NextAuth | | **Hosted IdP / auth SaaS** | Integration code | Everything else — including your user data | Auth0, Clerk, WorkOS, Supabase Auth, Cognito | | **Identity-aware proxy** | App authorization | Authentication entirely, at the network edge | Cloudflare Access (see `cloudflare-access.md`) | Better Auth's pitch: the feature ceiling of a hosted IdP (social login, passkeys, 2FA, orgs/multi-tenant, magic links) without surrendering user data, per-MAU pricing, or the login UX to a third party. Users live in **your** database in **your** schema (extended by plugins), and every flow runs in your process. ### When to choose which ``` Who are your users, and who should own the credential risk? │ ├─ Staff/partners behind an existing IdP, internal tools? │ └─ Identity-aware proxy (Cloudflare Access) — don't build login at all │ └─ see cloudflare-access.md │ ├─ Consumer/SaaS product, TypeScript stack, want to own user data? │ └─ Better Auth │ ├─ Full-featured via plugins (passkeys, 2FA, orgs, magic links) │ ├─ Your DB, your schema, no per-MAU bill │ └─ You own uptime and patching of the auth path │ ├─ Compliance/enterprise-sales pressure (the buyer's checklist names a │ vendor), or a team with no capacity to own auth code? │ └─ Hosted IdP (Auth0/Clerk/WorkOS) │ ├─ Someone else's pager owns the auth path │ ├─ Costs scale per-MAU; user data lives with the vendor │ └─ Note: enterprise SSO alone no longer forces this — Better Auth │ ships sso (SAML/OIDC) + SCIM plugins; the trade is ownership │ └─ Unusual auth model no library expresses (exotic tokens, research)? └─ Hand-rolled on the primitives in jwt-sessions.md / implementation.md └─ Budget for the hardening checklist you inherit (rate limits, enumeration, rotation, reset flows — see implementation.md) ``` The Lucia lesson: its maintainers deprecated the library and turned it into a tutorial, concluding that a thin session library saves too little over hand-rolling while still hiding the parts you need to understand. The ecosystem's answer to "I want auth *implemented*, not just assisted" is a full-featured library — which in TypeScript today usually means Better Auth. ## Core Setup: Server Instance + Client Two halves, mirrored: a **server instance** (`betterAuth(...)`) that owns the database and exposes an HTTP handler + server API, and a **client** (`createAuthClient(...)`) whose methods call those endpoints. Plugins come in pairs too — a server plugin and its client counterpart. ```typescript // server: auth.ts — the single source of truth for auth config import { betterAuth } from 'better-auth'; export const auth = betterAuth({ database: /* adapter — see next section */, emailAndPassword: { enabled: true, // hand the email-sending to YOUR mailer; Better Auth calls these hooks sendResetPassword: async ({ user, url }) => { /* send url to user.email */ }, }, socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, }, plugins: [/* passkey(), twoFactor(), organization(), ... */], }); ``` ```typescript // client: auth-client.ts — framework-specific import path // (react / vue / svelte / solid / vanilla variants exist) import { createAuthClient } from 'better-auth/react'; export const authClient = createAuthClient({ baseURL: 'https://app.example.com', // where the auth routes are mounted plugins: [/* client halves of the server plugins */], }); // usage: authClient.signIn.email({...}), authClient.signIn.social({provider: 'github'}), // authClient.signUp.email({...}), authClient.signOut(), authClient.useSession() (hook) ``` The server instance exposes: | Surface | What | Use for | |---------|------|---------| | `auth.handler` | A `Request => Response` fetch-style handler serving all auth routes (conventionally mounted at `/api/auth/*`) | Wiring into your framework's router | | `auth.api.*` | Server-side callable endpoints (e.g. get the session from request headers) | Middleware, server components, RSC/loader code | Mount the handler once; never build login/logout routes by hand next to it. ## Database Adapters Better Auth owns its tables (user, session, account, verification — plus plugin tables) inside **your** database, through an adapter: | Adapter family | Notes | |----------------|-------| | Kysely-based direct connections | Postgres / MySQL / SQLite via the built-in layer | | ORM adapters | Drizzle, Prisma, MongoDB adapters wrap your existing ORM instance | | Serverless/edge databases | Work through the same adapters (e.g. D1 via Drizzle) — check current docs for your combination | Schema management is CLI-driven: the Better Auth CLI can **generate** the schema (migration files / ORM schema for your adapter) and, for direct connections, **migrate** the database. Plugins add columns/tables; re-run generate after adding one. Treat the generated schema as owned artifacts in your repo — review and commit them like any migration. **Secondary storage** (optional): a KV/Redis-style store can be configured alongside the database for hot data (sessions, rate-limit counters), keeping per-request reads off the primary DB. On serverless platforms this is the natural home for session lookups. ## Session Model Better Auth's default is **database-backed sessions with a cookie** — the `jwt-sessions.md` "session" column, not the JWT column: - Sign-in creates a session row; the browser holds an httpOnly, secure session cookie. - Every request resolves cookie → session row → user. Revocation is immediate (delete the row); there's no stateless-token revocation problem. The API ships revocation at three granularities — one session (`revokeSession`), all-but-current (`revokeOtherSessions`), and all (`revokeSessions`) — plus `revokeOtherSessions: true` on password change, which should be your default there. - Expiration is a sliding window: sessions live `expiresIn` (default 7 days) and are re-extended once older than `updateAge` (default 1 day) — so an active user never logs in again, an idle one ages out. - **Cookie cache** (`session.cookieCache`): an optional short-lived signed cookie carrying the session data (`enabled`, `maxAge`, an encoding `strategy`, auto-`refreshCache`, and a `version` string that bulk-invalidates all cached sessions when bumped). Most requests skip the DB read and only re-validate on cache expiry. This is the latency escape hatch for serverless/edge — with immediate-revocation traded down to "within the cache window." - **Secondary storage** takes over session reads by default when configured; `storeSessionInDatabase` keeps the DB copy too, and `preserveSessionInDatabase` retains revoked-session rows for audit. - A JWT plugin exists for handing tokens to *other* services (a separate API consuming identity), not as a replacement for the cookie session between your SPA and your server. Server-side session access is the integration point for everything else in your app: ```typescript // in middleware / a loader / an RSC — shape per current docs const session = await auth.api.getSession({ headers: request.headers }); if (!session) return unauthorized(); // session.user is YOUR user row (plus plugin fields) — feed it to your // authorization layer (roles, tenant scope) exactly as in authorization.md ``` The same fail-closed layering as every other auth source applies: Better Auth authenticates; your role/scope binding on `session.user` authorizes. Never trust identity from a request body. ## Email/Password + Social Providers **Email/password** is a config flag plus hooks. Better Auth implements the flows (signup, sign-in, verification, password reset with single-use expiring tokens, password hashing) and calls *your* functions to actually send email — it deliberately does not ship a mailer. Turn on email verification for real deployments; wire the reset/verification senders to your provider (Resend, SES, Cloudflare Email, …). The hardening in `implementation.md` (rate limiting, enumeration-safe responses) is largely handled by the library — configuration, not reimplementation. **Social providers** are config entries per provider (OAuth2/OIDC under the hood — the flows from `oauth2-oidc.md`, implemented for you): - Built-ins for the majors (Google, GitHub, Apple, Microsoft, Discord, …) plus a **generic OAuth plugin** for any OIDC-conformant provider. - Redirect URI is derived from where the handler is mounted (`<baseURL>/api/auth/callback/<provider>` by convention) — register that with the provider. - **Account linking** connects a social login to an existing user with the same verified email (configurable — auto-link only trusted, email-verifying providers; see gotchas). - The `account` table stores the provider linkage and tokens per user — one user, many linked providers. ## Plugins: Passkeys, 2FA, Organizations Plugins are the differentiating layer. Each has a server half (routes + schema) and a client half (typed methods). Representative set — check current docs for the full catalog: | Plugin | Gives you | Notes | |--------|-----------|-------| | **passkey** | WebAuthn registration + sign-in | The `implementation.md` passkey checklist, implemented: challenge handling, credential storage, multiple credentials per user | | **twoFactor** | TOTP + backup codes (OTP-on-login) | Enable/verify flows, recovery codes; gate it on your risk model | | **organization** | Orgs/teams, membership, roles, invitations | The multi-tenant building block — org rows, member rows with roles, invitation email hooks; pair with your data-layer tenant scoping (`authorization.md`) — the plugin manages *membership*, your queries must still enforce *scope* | | **admin** | User administration (list, ban, impersonate) | Impersonation should stay audited — log the real admin identity on writes | | **magicLink** / **emailOTP** | Email magic-link or emailed-code sign-in | You send the email; the library handles token issue/verify | | **sso** / **scim** | Enterprise SAML/OIDC SSO and SCIM user provisioning | The plugins that let a self-hosted Better Auth answer enterprise-IT checklists — the capability that used to force a hosted IdP | | **oidcProvider** / **oauthProvider** / **mcp** | Your app *issues* tokens — act as an OIDC/OAuth provider (including for MCP clients) | Turns the app into the IdP for its own satellite services | | **apiKey** / **bearer** / **jwt** | Machine callers and token handoff to other services | Keep machine routes structurally separate from human session routes (same doctrine as `cloudflare-access.md` service-auth section) | | **genericOAuth** | Any OIDC-conformant provider not built in | For long-tail IdPs | The full catalog is considerably larger (40+ official plugins: username, anonymous, phoneNumber, multiSession, oneTap, oneTimeToken, deviceAuthorization, captcha, haveIBeenPwned breached-password checks, siwe, payments integrations like stripe/polar, openAPI, test-utils, …) — enumerate the current list via the docs' llms.txt rather than from memory. Plugin doctrine: add the server plugin, add its client counterpart, re-run schema generation, and let the plugin own its flow end-to-end — don't hand-build a parallel 2FA/passkey path beside it. ## Middleware Integration (incl. Hono) Better Auth speaks fetch-standard `Request`/`Response`, so any framework that exposes those integrates the same way: **route `/api/auth/*` to `auth.handler`, and read the session in middleware for everything else.** ```typescript // Hono (Workers/Node/Bun) — verified against the official integration docs 2026-08 import { Hono } from 'hono'; import { auth } from './auth'; const app = new Hono(); // 0. If the frontend is on another origin: CORS middleware BEFORE the routes, // with credentials: true (and credentials: 'include' on the client fetch). // 1. Mount the auth routes — GET and POST both reach the handler app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)); // 2. Session middleware for your app routes app.use('/api/*', async (c, next) => { const session = await auth.api.getSession({ headers: c.req.raw.headers }); if (!session) return c.json({ error: 'unauthorized' }, 401); c.set('user', session.user); // then bind roles/tenant scope server-side await next(); }); ``` Cross-origin cookie shape, when the SPA and API live on different hosts: same-site subdomains → enable `crossSubDomainCookies` and keep `SameSite=Lax`; genuinely different domains → `sameSite: "none"` + `secure: true` cookie attributes (and accept the third-party-cookie fragility that entails — a shared parent domain is the saner architecture, per `jwt-sessions.md`). Framework notes (details per current docs): - **Next.js**: a catch-all route handler (`app/api/auth/[...all]/route.ts`) exporting the handler's GET/POST; session via `auth.api.getSession({ headers: headers() })` in server components/actions. Treat proper session checks in data-access code — not just in `middleware.ts` — as the real gate (Next middleware alone has been bypassable; CVE-2025-29927). - **SvelteKit / Nuxt / SolidStart / TanStack Start / Astro / Remix**: same two moves via each framework's handler-mounting idiom. - **Express/Fastify (Node)**: adapt Node req/res to fetch `Request` (helpers exist — `toNodeHandler` or the framework's own adapter). - **Serverless/edge (Workers)**: works — pair with an edge-resident DB or secondary storage so session reads aren't cross-region; enable cookie caching. One rule regardless of framework: the auth config object lives in **one** module; handler mounting and session reads both import it. Two `betterAuth()` instances with drifted config is a subtle way to break sessions. ## Extending the User Model The user/session tables are extensible from config (`user.additionalFields`-style options): declare extra fields, re-run schema generation, and the server types pick them up; the client can infer them via the type-inference plugin so `session.user` stays end-to-end typed. Use this for *identity-adjacent* fields (display name, locale, onboarding flags). Keep *authorization* data (roles, tenant membership) in your own domain tables keyed by user id — mixing authz into the auth library's schema couples your permission model to its migrations. ## Migrating In Official migration guides exist for Auth0, Clerk, NextAuth/Auth.js, Supabase Auth, and WorkOS — start there. The architectural points that make migrations tractable: - **Password hashes import.** The password hashing functions are configurable, so existing bcrypt/argon2 hashes can be verified as-is (or verified-then-rehashed on first login) instead of forcing a global reset. - **Users/accounts map cleanly**: exported users become `user` rows; per-provider identities become `account` rows. Social-login users need no secret material at all — only the provider linkage. - **Sessions don't migrate.** Plan for a one-time global re-login at cutover; communicate it. ## Operational Notes - **Secrets**: a `BETTER_AUTH_SECRET`-style signing secret plus per-provider OAuth credentials — platform secret store, never committed (see `implementation.md` on secret handling). - **Rate limiting**: built-in on auth endpoints (tighter on sign-in/sign-up) — configure storage (memory/DB/secondary) appropriately for multi-instance deployments; memory-only limits don't coordinate across serverless instances. - **Hooks/lifecycle**: before/after hooks on auth events (user created, session created, …) are the place for provisioning side-effects — creating a tenant on signup, audit rows, welcome email. Keep them idempotent. - **Upgrades**: fast-moving library — read the changelog on every minor bump, re-run schema generation after upgrading or adding plugins, and keep an integration test that exercises sign-up → sign-in → session → sign-out against a real database. ## Common Gotchas | Gotcha | Why It's Dangerous | Fix | |--------|--------------------|-----| | Pinning API snippets from memory or old tutorials | The API surface shifts between minors; stale option names fail silently or at type-check | Verify against current docs; keep Better Auth in one module so upgrades touch one file | | Building login/reset routes beside the mounted handler | Two auth paths, one hardened, one yours | Everything auth goes through `auth.handler` / `auth.api` / plugins | | Skipping schema regeneration after adding a plugin | Runtime errors on missing tables/columns | Re-run CLI generate/migrate on every plugin add and version bump | | Auto-linking accounts from providers with unverified emails | Account takeover: attacker registers your email at a lax provider, links into your account | Restrict auto-linking to trusted, email-verifying providers; require verification otherwise | | Auth checks only in framework middleware (Next.js) | Middleware can be bypassed (CVE-2025-29927-class bugs) | Check the session in the data-access layer / route handlers too | | DB-per-request session reads on edge/serverless with a distant DB | Latency tax on every request | Cookie cache and/or secondary storage near the compute | | Treating org membership as data scoping | Membership says who's *in* the org; queries still need tenant filters | Enforce tenant scope at the repository/query layer (`authorization.md`) | | Memory rate-limit storage on multi-instance deploys | Each instance counts separately — limits are ~N× looser | Database or secondary-storage backed rate limiting | | Secrets in client-reachable config | Provider secrets leak to the bundle | Server module only; client gets nothing but `baseURL` and plugin client halves | | Ignoring the mailer hooks (no verification/reset emails wired) | Signup verification and password reset silently can't complete | Wire `sendResetPassword` / verification senders to a real mailer before launch | ## See Also - `jwt-sessions.md` — the session/cookie model Better Auth implements (and when raw JWTs fit instead) - `oauth2-oidc.md` — the flows underneath `socialProviders` - `implementation.md` — password hashing, MFA, rate limiting fundamentals (what the library is doing for you) - `authorization.md` — roles/tenant scoping to layer on `session.user` - `cloudflare-access.md` — the delegate-it-entirely alternative for staff/internal apps -
cloudflare-access.md 24.9 KB
# Identity-Aware Proxies (Cloudflare Access) Deep-dive reference for identity-aware proxy (IAP) authentication, worked through Cloudflare Access (Zero Trust). The patterns — verify the proxy's signed identity assertion, close every path around the proxy, layer app authorization on top — generalize to any IAP (Google IAP, AWS Verified Access, Pomerium, oauth2-proxy). Access-specific facts (claims, endpoints) verified against Cloudflare docs as of 2026-08. ## The Model An identity-aware proxy moves *authentication* out of your application and into an enforcement edge in front of it: ``` ┌─────────┐ ┌──────────────────────┐ ┌───────────────┐ │ Browser │───>│ Identity-aware proxy │────> │ Origin (your │ │ │ │ (Cloudflare Access) │ │ app/Worker) │ │ │ │ - IdP login (SSO) │ JWT │ - verify JWT │ │ │ │ - OTP for externals │ hdr │ - user lookup │ │ │ │ - session mgmt │ │ - roles/scope │ │ │ │ - bot mitigation │ │ │ └─────────┘ └──────────────────────┘ └───────────────┘ ``` What the proxy owns: login UI, IdP federation, OTP delivery, session lifetime, rate limiting, bot mitigation. What your origin still owns: **verifying the proxy's assertion, mapping identity to an application user, and every authorization decision.** The proxy asserts identity to the origin via a signed JWT in a request header — for Access, `Cf-Access-Jwt-Assertion`. Everything below follows from one question: *can you trust that header?* Answer: only after cryptographic verification, and only if the proxy is the sole path to the origin. ### When an IAP is the right call | Situation | Fit | |-----------|-----| | Internal tools / admin panels for staff with an existing IdP (Google Workspace, Entra) | Excellent — SSO for free, no credential storage | | Small external audiences (partners, counterparties) who need occasional access | Good — one-time PIN policies avoid provisioning them in your IdP | | Security-critical app where you don't want to own login hardening (rate limits, CSRF, bot defense, OTP delivery) | Excellent — the edge is hardened and audited for you | | Consumer-facing product with self-signup, thousands of users | Poor — use an auth library or hosted IdP (see `better-auth.md`, `oauth2-oidc.md`) | | You need a fully branded login experience | Weak — the proxy's hosted login page is minimally themeable | ## Access Application + Policy Anatomy An Access deployment has four moving parts: | Part | What it is | Example | |------|------------|---------| | **Team domain** | Your Zero Trust tenant; also the JWT issuer | `example.cloudflareaccess.com` → issuer `https://example.cloudflareaccess.com` | | **Application** | A "self-hosted" app bound to a hostname (or hostname + path) | Domain `app.example.com` | | **Policies** | Ordered Allow/Deny/Bypass/Service-Auth rules on the application | Allow staff, Allow named partners | | **AUD tag** | Per-application audience identifier; goes in the JWT's `aud` claim | Copied from the dashboard into origin config | Typical policy set for an app with staff + external users: 1. **Allow — staff:** identity provider login (e.g. Google), include rule "emails ending in `@example.com`". 2. **Allow — external counterparties:** login method **One-time PIN**, include rule listing the specific partner emails. Both policies authenticate; neither authorizes. The emails Access admits must still map to rows/roles in your application's user store (see [Fail-Closed Layering](#fail-closed-layering) below). Setup sequence (order matters — the AUD tag doesn't exist until the app does): ``` 1. Zero Trust dashboard -> Access -> Applications -> Add -> Self-hosted 2. Set the application domain (the exact hostname the origin serves) 3. Add Allow policies (IdP for staff, OTP for externals) 4. Copy the application AUD tag 5. Configure the origin with team domain + AUD (Workers: wrangler secret put CF_ACCESS_AUD, then redeploy) ``` One application per hostname is the natural multi-tenant shape: each tenant hostname gets its own Access app, its own AUD, and its own policy set — onboarding a tenant is dashboard config plus data, no code change. Access apps and policies are also manageable as infrastructure-as-code (Terraform `cloudflare_zero_trust_access_application` / `cloudflare_zero_trust_access_policy`) — worth it once you have more than a couple of apps, so the policy set is reviewable and reproducible. ## What's in the Token Two token shapes arrive at the origin, depending on how the caller authenticated (verified against Cloudflare docs, 2026-08): **Identity-based login** (IdP or one-time PIN): | Claim | Contents | |-------|----------| | `aud` | **Array** of application AUD tags | | `email` | The authenticated email, verified by the IdP / OTP flow | | `iss` | `https://<team-domain>` | | `exp` / `iat` / `nbf` | Standard timing claims | | `type` | `app` (application token) or `org` (global session token) | | `sub` | Access user UUID — unique per email per account, but **regenerated** if the user is removed and re-added to the Zero Trust org | | `identity_nonce` | Cache key for the identity endpoint (below) | | `country` | Country the user authenticated from | | `custom` | Custom SAML attributes / OIDC claims, **best-effort only** (see warning) | **Service-token authentication** (Service Auth policy): same envelope, but `common_name` carries the service token's Client ID and `sub` is an **empty string** — there is no user. Origins can verify service-auth callers with the same JWKS + issuer + AUD check, then branch on `common_name` instead of `email`. Two claims deserve suspicion: - **`custom` is trimmed.** Access drops configured custom claims once the serialized `custom` claim exceeds roughly 1 KB — groups first, since they're usually largest. A user in many IdP groups can silently receive a token *without* their groups while colleagues keep theirs. **Never make authorization decisions on `custom`/groups claims from the JWT**; if you need full identity (all groups), call `GET /cdn-cgi/access/get-identity` on the protected hostname with the user's `CF_Authorization` cookie. Better: keep roles in your own user store (next section) and ignore `custom` entirely. - **`email` vs `sub` as the join key.** Email is human-meaningful and survives org remove/re-add; `sub` is opaque and doesn't. Most apps key their user store on canonicalized email — fine, as long as you canonicalize (trim + lower-case) everywhere. ## Verifying the JWT at the Origin The proxy injects `Cf-Access-Jwt-Assertion` (also available as the `CF_Authorization` cookie). Verify it on **every request** — signature, issuer, and audience — against the team's public JWKS at `https://<team-domain>/cdn-cgi/access/certs`. ```typescript // Cloudflare Access JWT verification (Workers / jose). // Never trust the Cf-Access-Jwt-Assertion header without verifying // signature, issuer, and audience. import { createRemoteJWKSet, jwtVerify } from 'jose'; export interface AccessConfig { /** Access team domain, e.g. "example.cloudflareaccess.com" (no scheme). */ teamDomain: string; /** The Access application AUD tag for this hostname. */ aud: string; } export class AccessAuthError extends Error { constructor(message: string) { super(message); this.name = 'AccessAuthError'; } } // One JWKS per team domain, cached for the process/isolate lifetime. // createRemoteJWKSet caches keys and refetches on an unknown `kid`, // so Access key rotation does not cause a 403 storm. const jwksByIssuer = new Map<string, ReturnType<typeof createRemoteJWKSet>>(); function jwksFor(issuer: string) { let jwks = jwksByIssuer.get(issuer); if (!jwks) { jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); jwksByIssuer.set(issuer, jwks); } return jwks; } /** * Verify an Access JWT and return the caller's email (lower-cased). * Throws AccessAuthError on any failure — missing token, bad signature, * wrong issuer/audience, expired, or no email claim. Map to 403. */ export async function verifyAccessJwt( token: string | undefined, config: AccessConfig, ): Promise<string> { if (!token) throw new AccessAuthError('missing Access token'); if (!config.teamDomain || !config.aud) { throw new AccessAuthError('Access is not configured'); } const issuer = `https://${config.teamDomain}`; try { const { payload } = await jwtVerify(token, jwksFor(issuer), { issuer, audience: config.aud, algorithms: ['RS256'], }); const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : ''; if (!email) throw new AccessAuthError('Access token has no email claim'); return email; } catch (err) { if (err instanceof AccessAuthError) throw err; throw new AccessAuthError( `Access token verification failed: ${(err as Error).message}`, ); } } ``` The details that matter: | Detail | Why | |--------|-----| | Verify `iss` against the team domain | Rejects tokens signed by any *other* Access tenant — the JWKS URL alone doesn't pin the tenant | | Verify `aud` against **this application's** AUD tag | A valid token for a different app on the same team must not open this one. With per-hostname apps, resolve the expected AUD from the request hostname | | Pin `algorithms: ['RS256']` | Never accept whatever `alg` the token declares | | Cache the JWKS per process/isolate | The JWKS endpoint is remote; fetching it per-request adds latency and a availability dependency | | Refetch on unknown `kid` (jose's `createRemoteJWKSet` does this) | Access rotates signing keys; without kid-triggered refetch, rotation causes a spurious-403 storm until the cache expires | | Lower-case + trim the email before lookup | Email comparison against your user store must be canonical — `Alice@Example.com` and `alice@example.com` are the same person | | Fail closed: any error → 403 | Misconfigured team domain or missing AUD must deny, never pass through | ## THE Trust Precondition: the Proxy Must Be the Only Path **An identity-aware proxy header is worthless if the origin is directly reachable.** The header is just a request header; anyone who can reach the origin without going through the proxy can set it themselves. Signature verification protects against *forged tokens*, not against a *forged unverified header* on a code path that skips verification — and more subtly, an open origin invites "trust the header, skip the JWT" shortcuts that turn into real vulnerabilities. On Cloudflare Workers, closing the origin means: ```toml # wrangler.toml / wrangler.jsonc workers_dev = false # no <name>.workers.dev origin exists # routes/custom domains: only the Access-protected hostname(s) ``` With `workers_dev = false` and routes only on Access-protected hostnames, there is no unproxied URL on which the header could be forged — the classic prototype bug (trusting `Cf-Access-*` headers, then discovering anyone hitting the `*.workers.dev` origin could claim any email and become admin) is structurally impossible. Generalized, for any IAP: | Deployment | How to close the side door | |------------|---------------------------| | Cloudflare Workers | `workers_dev = false`; routes only on protected hostnames | | Origin server behind Cloudflare | Firewall the origin to Cloudflare IP ranges + authenticated origin pulls (mTLS); otherwise anyone who finds the origin IP bypasses Access entirely | | `cloudflared` tunnel | Best case — the origin has no public inbound at all; only the tunnel reaches it. Cloudflare's docs treat JWT validation as optional for tunnel-connected origins; verify anyway — defense in depth costs one function call | | Google IAP / AWS ALB + OIDC | Security groups / ingress rules so only the load balancer reaches the backends; verify the signed-identity header (`x-goog-iap-jwt-assertion` etc.), not the plain email header | | Kubernetes + oauth2-proxy / Pomerium | NetworkPolicy so app pods accept traffic only from the proxy | If you cannot close the side door, JWT verification is your only line of defense — which is exactly why you verify the JWT even when you *think* the origin is closed. Defense in depth: closed origin **and** verified assertion. ## Fail-Closed Layering The proxy authenticates; it must never implicitly authorize. Layer strictly, each step failing closed: ``` 1. Verify the Access JWT -> verified email, or 403 2. Look up the email in YOUR users -> application user + role, or 403 store (active users only) 3. Bind role/tenant scope -> scoped data access, enforced server-side server-side on every query ``` Rules for the layering: - **Never trust identity from a request body, query param, or client-set header.** The only identity input is the verified JWT. A `{"email": ...}` field in a POST body is display data at most. - **Access admitting an email ≠ the email having an account.** Policies are coarse (a whole staff domain, a PIN list that lags reality). The user-store lookup is the fine-grained gate: no active row → 403, regardless of a valid JWT. - **Roles and scopes live in your database, never in the assertion.** The Access JWT tells you *who*; your `users` table tells you *what they may do*. (Access can forward IdP group claims, but treating those as app roles couples your authorization to dashboard/IdP config — keep authorization in the app.) - **Enforce scope at the data layer**, not per-handler: resolve `{user, role, tenant}` once in middleware, then have every query go through a repository that is constructed with — and cannot escape — that scope. See `authorization.md` for the RBAC/scoping patterns. ### Auto-provisioning vs explicit user rows Two workable enrollment models, often combined: | Model | How | Use for | |-------|-----|---------| | **Auto-provision on first login** | A verified email at the org's staff domain with no user row gets a real, **audited** user row created on first request (e.g. as an admin of their tenant) | Staff — the IdP domain is the trust anchor; removes a bootstrap/onboarding step | | **Explicit rows for everyone else** | External emails (OTP policies) must be pre-created by an admin; unknown email → 403 | Partners, clients, contractors — OTP proves mailbox control, nothing more | If you auto-provision, gate it on the *IdP-backed* staff domain only (never on OTP logins), write an audit record for the provisioning event, and create a real row — don't synthesize a virtual admin per request. ## Service Auth and Bypass: Non-Human Routes Webhooks, ingest endpoints, and machine callers can't complete a human login. They need to pass the edge *and* skip your session/JWT middleware — two separate allowances that must both be made deliberately: **At the edge**, add a separate Access application (or path-scoped app) for the machine path, e.g. `app.example.com/webhooks/*`, with one of: | Policy type | Mechanism | Use when | |-------------|-----------|----------| | **Service Auth** | Caller presents Access **service token** headers (`CF-Access-Client-Id` / `CF-Access-Client-Secret`); Access validates them and issues a JWT whose `common_name` is the token's Client ID (see [What's in the Token](#whats-in-the-token)) — verifiable at the origin with the same JWKS check | The caller is yours to configure — internal services, partner systems that can send custom headers | | **Bypass** | Access waves the route through entirely (optionally restricted by IP) | Third-party webhook senders you can't give headers to (Stripe, GitHub) — their signature scheme is then the only gate | **At the origin**, mount bearer-token routes **outside** the human-auth middleware — not as an `if` inside it: ``` /api/* -> Access JWT middleware -> user lookup -> scoped handlers /webhooks/* -> bearer-key check -> pinned-scope handlers (never sees JWT middleware) /ingest/* -> bearer-key check -> pinned-scope handlers /health -> unauthenticated (the only fully open route) ``` The bearer key is the real gate on these routes — treat it accordingly: - Long random secrets, stored hashed or in the platform secret store, rotatable. - Constant-time comparison. - Pin each key to a scope/tenant server-side (e.g. `tenantId:key` — a request whose body claims a different tenant than its key is rejected). - For third-party webhooks behind a Bypass policy, verify the sender's HMAC signature (Stripe-Signature etc.) — Bypass means the edge does nothing for you. Keeping machine routes outside `/api/*` (rather than exempting paths inside the middleware) makes the security model auditable: the route table *is* the policy. **Paths that never traverse the proxy at all** deserve the same audit: Worker cron triggers, queue consumers, and service-binding calls from other Workers arrive with **no Access header** — they invoke your code from inside the platform. Don't route internal invocations through the request-auth middleware (they'd 403), and don't let shared handlers assume an authenticated user exists. Give internal entry points their own explicit identity convention (a system principal with a fixed, minimal scope) so audit trails and scoping still hold. ## Sessions, Logout, and the SPA Problem Access issues two `CF_Authorization` cookies: a **global session token** on the team domain (so one login covers many apps) and a per-app **application token** on the protected hostname (the JWT your origin verifies). Session duration is configured per application; admins can revoke a user's sessions from the Zero Trust dashboard, and `https://<hostname>/cdn-cgi/access/logout` ends the session for that app — wire your app's "log out" link to it, since your origin has no session of its own to destroy. The classic operational trap is the **SPA whose Access session expires mid-use**: the page is already loaded, and a background `fetch()` to the API doesn't get a clean 401 — Access answers with a **302 redirect to the IdP login**, which the browser's CORS machinery turns into an opaque failure the SPA can't interpret. Two mitigations: - **Detect and reload.** Treat a redirected/opaque or HTML response from an API route as "session expired" and trigger a full-page navigation, letting Access run its login flow and land the user back in the app. - **Managed OAuth (newer Access feature).** When enabled, Access returns `401` + a `WWW-Authenticate` header pointing at RFC 8414 OAuth discovery metadata for non-browser clients, and issues **opaque** (non-JWT) access tokens via a standard authorization-code flow. This is the intended path for API clients and agents that can't follow interactive redirects — check current Cloudflare docs before building on it. Keep API responses JSON even for auth failures your *own* middleware generates (verified-JWT-but-no-user → JSON 403, never a redirect), so the only redirect source is Access itself. ## The Local-Dev Problem Only the real Access edge injects `Cf-Access-Jwt-Assertion`. `wrangler dev` / `vite dev` on localhost never sees the header, so with a fail-closed origin, local UI work gets a 403 wall. Two legitimate solutions, one trap: | Approach | How | Trade-off | |----------|-----|-----------| | **Tunnel behind Access** | `cloudflared` tunnel from a dev hostname (in the Zero Trust dashboard) to localhost; put an Access app + policy on the dev hostname | Real end-to-end auth, real header; needs dashboard setup and a login per session | | **Deliberate auth stub** | A dev-only middleware branch takes the identity from a local env var (e.g. `DEV_AUTH_EMAIL` in a gitignored `.dev.vars`) instead of a JWT | Fast; must be *structurally* incapable of shipping (see below) | | ~~Disable the check~~ | `if (env.SKIP_AUTH) return next()` | **Never.** A boolean that turns auth off is one bad deploy away from an open production origin | A stub that can't ship is **doubly gated** and changes nothing downstream: 1. Active only when the dev env var is set — and the var lives in a gitignored local file, never in deployed secrets/vars. 2. Active only when the request hostname is loopback (or another hostname no deployed instance can be reached on) — so even a leaked var is inert in production. 3. It substitutes the *identity input only*: the stubbed email still goes through the same user lookup, role binding, and scoping as a verified JWT. It can assume an existing user, never mint one (exclude it from auto-provisioning). 4. Pin it with a test asserting it is inert when the gates are absent. ## OTP vs IdP Policies | | IdP login (Google, Entra, SAML) | One-time PIN (email OTP) | |---|---|---| | Proves | Account in an org directory (+ the IdP's own MFA/device posture) | Control of a mailbox, at that moment | | Provisioning | None beyond the include rule (domain/group) | Someone must list the emails in the policy *and* usually in your user store | | Offboarding | Automatic — IdP account disabled → login dies | Manual — remove from policy and user store; the mailbox outlives the relationship | | Assurance | Higher (org-managed identity) | Lower (mailbox compromise = access) | | Fit | Staff, anyone in your directory | External counterparties too few/transient to federate | | Privileges | Can justify elevated roles, auto-provisioning | Least privilege; never auto-provision from OTP | Mixed audiences on one app is normal: an IdP Allow policy for staff plus an OTP Allow policy enumerating externals. Keep the *role ceiling* of OTP identities low in your app-level authorization regardless of what the edge admits. ## Common Gotchas | Gotcha | Why It's Dangerous | Fix | |--------|--------------------|-----| | Trusting `Cf-Access-Authenticated-User-Email` (or any plain identity header) without JWT verification | Headers are attacker-settable on any unproxied path | Verify `Cf-Access-Jwt-Assertion` cryptographically; ignore the convenience headers | | `workers_dev` left `true` (or origin IP reachable) | An unproxied origin exists — forged headers, no Access at all | `workers_dev = false`; firewall/tunnel non-Workers origins to the proxy only | | Validating signature but not `aud` | A valid token for *another* app on your team opens this one | Verify the per-application AUD tag, resolved per hostname | | Validating signature but not `iss` | A token from a different Access tenant could pass | Pin issuer to `https://<your-team-domain>` | | Fetching the JWKS on every request | Latency + hard availability dependency on the certs endpoint | Cache per process/isolate; refetch on unknown `kid` | | No `kid`-triggered refetch | Access key rotation → spurious 403 storm until cache expiry | Use jose's `createRemoteJWKSet` (does this) or replicate the behaviour | | Treating an Access-admitted email as an authorized user | Policies are coarse; ex-partners linger in PIN lists | App-level user lookup is mandatory; no row → 403 | | Bypass/Service-Auth path with a weak or unpinned bearer key | The edge is open there; the key is the only gate | Long random keys, hashed at rest, constant-time compare, tenant-pinned | | Webhook route inside the session middleware with an exemption flag | Exemption logic rots; one refactor away from exposed | Mount machine routes structurally outside the human-auth middleware | | `SKIP_AUTH`-style dev flag | Ships to production eventually | Doubly gated dev stub (env var in gitignored file + loopback-only hostname), pinned by a test | | Auto-provisioning users from OTP logins | Mailbox control alone mints an account | Auto-provision only from IdP-backed staff-domain emails, audited | | Case-sensitive email matching | Same person, two identities; lookup misses | Canonicalize (trim + lower-case) before every lookup and store | | Authorizing on the JWT's `custom`/groups claims | Access trims `custom` at ~1 KB, groups first — some users silently lose the claim | Roles in your own user store; full identity via `/cdn-cgi/access/get-identity` if you must read groups | | SPA treats every API failure as JSON | Expired Access session → 302 to IdP → opaque CORS failure, not a 401 | Detect redirected/opaque/HTML responses and full-page reload (or use Managed OAuth for API clients) | | Cron/queue/service-binding handlers reuse request-auth code | Internal invocations have no Access header — either 403s or, worse, an accidental unauthenticated path | Separate internal entry points with an explicit system identity | | App "logout" only clears app state | The Access session survives; next request logs straight back in | Send the browser to `/cdn-cgi/access/logout` on the protected hostname | ## See Also - `jwt-sessions.md` — JWT structure, claims, and verification fundamentals - `authorization.md` — RBAC/tenant scoping to layer on top of the verified identity - `better-auth.md` — when you own the login flow instead of delegating it to a proxy - **cloudflare-ops** skill — Workers runtime, wrangler config, secrets, deploy mechanics -
implementation.md 32.6 KB
# Implementation Patterns Practical implementation reference for password hashing, MFA, rate limiting, API keys, and account security flows. ## Password Hashing ### Algorithm Comparison | Algorithm | Type | Resistance | Recommendation | |-----------|------|------------|----------------| | **argon2id** | Memory-hard | GPU, ASIC, side-channel | Best choice for new systems | | **bcrypt** | CPU-hard | GPU (moderate) | Battle-tested, widely supported | | **scrypt** | Memory-hard | GPU, ASIC | Good but less library support | | **PBKDF2** | CPU-hard | GPU (weak) | FIPS compliant, last resort | ### argon2id (Recommended) OWASP recommended parameters: - Memory: 19 MiB (19456 KiB) - Iterations: 2 - Parallelism: 1 - Salt: 16 bytes (random) - Hash length: 32 bytes ```javascript // Node.js (argon2 package) import argon2 from 'argon2'; // Hash a password async function hashPassword(password) { return argon2.hash(password, { type: argon2.argon2id, memoryCost: 19456, // 19 MiB timeCost: 2, // 2 iterations parallelism: 1, saltLength: 16, hashLength: 32, }); // Returns: $argon2id$v=19$m=19456,t=2,p=1$salt$hash } // Verify a password async function verifyPassword(hash, password) { return argon2.verify(hash, password); } // Check if rehash needed (parameters changed) function needsRehash(hash) { return argon2.needsRehash(hash, { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1, }); } ``` ```python # Python (argon2-cffi) from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError ph = PasswordHasher( memory_cost=19456, # 19 MiB time_cost=2, parallelism=1, hash_len=32, salt_len=16, ) # Hash hashed = ph.hash("user_password") # $argon2id$v=19$m=19456,t=2,p=1$salt$hash # Verify try: ph.verify(hashed, "user_password") # Check if rehash needed (parameters updated) if ph.check_needs_rehash(hashed): new_hash = ph.hash("user_password") # Update stored hash except VerifyMismatchError: # Wrong password pass ``` ```go // Go (alexedwards/argon2id) import "github.com/alexedwards/argon2id" // Hash hash, err := argon2id.CreateHash("user_password", &argon2id.Params{ Memory: 19 * 1024, // 19 MiB Iterations: 2, Parallelism: 1, SaltLength: 16, KeyLength: 32, }) // Verify match, err := argon2id.ComparePasswordAndHash("user_password", hash) if match { // Password is correct } ``` ### bcrypt Use cost factor 12 or higher (each increment doubles computation time). ```javascript // Node.js (bcrypt) import bcrypt from 'bcrypt'; const COST_FACTOR = 12; // Hash const hash = await bcrypt.hash(password, COST_FACTOR); // Verify const match = await bcrypt.compare(password, hash); ``` ```python # Python (bcrypt) import bcrypt # Hash salt = bcrypt.gensalt(rounds=12) hashed = bcrypt.hashpw(password.encode(), salt) # Verify match = bcrypt.checkpw(password.encode(), hashed) ``` ```go // Go (golang.org/x/crypto/bcrypt) import "golang.org/x/crypto/bcrypt" // Hash hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) // Verify err = bcrypt.CompareHashAndPassword(hash, []byte(password)) if err == nil { // Password is correct } ``` **bcrypt limitation:** Input truncated to 72 bytes. For passwords that might exceed this, pre-hash with SHA-256: ```javascript import crypto from 'crypto'; import bcrypt from 'bcrypt'; function prehashPassword(password) { // SHA-256 produces 32 bytes (base64: 44 chars), well under 72 return crypto.createHash('sha256').update(password).digest('base64'); } const hash = await bcrypt.hash(prehashPassword(password), 12); const match = await bcrypt.compare(prehashPassword(password), hash); ``` ### Password Rehashing on Login When upgrading from a weaker algorithm (e.g., bcrypt to argon2id), rehash transparently on successful login: ```javascript async function login(email, password) { const user = await db.findUserByEmail(email); if (!user) return null; // Verify with current algorithm const valid = await verifyPassword(user.passwordHash, password); if (!valid) return null; // Check if rehash needed (algorithm or parameter upgrade) if (needsRehash(user.passwordHash)) { const newHash = await hashPassword(password); await db.updatePasswordHash(user.id, newHash); } return user; } ``` ## Rate Limiting Login Attempts ### Sliding Window Implementation ```javascript // Redis-based rate limiter import Redis from 'ioredis'; const redis = new Redis(process.env.REDIS_URL); class LoginRateLimiter { constructor(options = {}) { this.maxAttempts = options.maxAttempts || 10; this.windowMs = options.windowMs || 15 * 60 * 1000; // 15 minutes this.lockoutMs = options.lockoutMs || 30 * 60 * 1000; // 30 minutes } async checkLimit(identifier) { // identifier = email or IP address const key = `login_attempts:${identifier}`; const lockKey = `login_lockout:${identifier}`; // Check for lockout const locked = await redis.get(lockKey); if (locked) { const ttl = await redis.ttl(lockKey); return { allowed: false, retryAfter: ttl, reason: 'Account temporarily locked', }; } // Count recent attempts const now = Date.now(); const windowStart = now - this.windowMs; // Remove old entries await redis.zremrangebyscore(key, 0, windowStart); // Count current attempts const attempts = await redis.zcard(key); if (attempts >= this.maxAttempts) { // Lock the account await redis.set(lockKey, '1', 'PX', this.lockoutMs); return { allowed: false, retryAfter: Math.ceil(this.lockoutMs / 1000), reason: 'Too many login attempts', }; } return { allowed: true, remaining: this.maxAttempts - attempts - 1, }; } async recordAttempt(identifier) { const key = `login_attempts:${identifier}`; const now = Date.now(); await redis.zadd(key, now, `${now}`); await redis.pexpire(key, this.windowMs); } async resetAttempts(identifier) { // Call on successful login await redis.del(`login_attempts:${identifier}`); await redis.del(`login_lockout:${identifier}`); } } // Usage in login endpoint const limiter = new LoginRateLimiter(); app.post('/auth/login', async (req, res) => { const { email, password } = req.body; // Rate limit by both email and IP const emailCheck = await limiter.checkLimit(email); const ipCheck = await limiter.checkLimit(req.ip); if (!emailCheck.allowed || !ipCheck.allowed) { return res.status(429).json({ error: 'Too many attempts', retryAfter: Math.max(emailCheck.retryAfter || 0, ipCheck.retryAfter || 0), }); } const user = await authenticate(email, password); if (!user) { // Record failed attempt for both identifiers await limiter.recordAttempt(email); await limiter.recordAttempt(req.ip); // IMPORTANT: Use consistent timing to prevent enumeration return res.status(401).json({ error: 'Invalid credentials' }); } // Reset on successful login await limiter.resetAttempts(email); await limiter.resetAttempts(req.ip); // Create session/token const token = await createAccessToken(user); res.json({ token }); }); ``` ### Progressive Delays ```javascript // Add artificial delay based on attempt count async function loginWithDelay(email, password) { const attempts = await getRecentAttempts(email); // Progressive delay: 0, 0, 0, 1s, 2s, 4s, 8s, 16s (cap at 30s) if (attempts > 3) { const delay = Math.min(Math.pow(2, attempts - 3) * 1000, 30000); await new Promise((resolve) => setTimeout(resolve, delay)); } // IMPORTANT: Apply delay for both success and failure // to prevent timing-based enumeration return authenticate(email, password); } ``` ## MFA Implementation ### TOTP (Time-Based One-Time Password) Based on RFC 6238. Uses a shared secret and current time to generate 6-digit codes that change every 30 seconds. ```javascript // Node.js (otplib) import { authenticator } from 'otplib'; import QRCode from 'qrcode'; // Step 1: Generate secret for user function generateTOTPSecret(userEmail, issuer = 'MyApp') { const secret = authenticator.generateSecret(); // Base32 encoded // Build otpauth:// URI for QR code const otpauthUrl = authenticator.keyuri(userEmail, issuer, secret); return { secret, otpauthUrl }; } // Step 2: Generate QR code async function generateQRCode(otpauthUrl) { return QRCode.toDataURL(otpauthUrl); // Returns base64 PNG image for display } // Step 3: Verify first code (enrollment) function verifyTOTP(secret, token) { // Accept current window +/- 1 (90 second window) return authenticator.check(token, secret); } // Step 4: Generate backup codes function generateBackupCodes(count = 10) { const codes = []; for (let i = 0; i < count; i++) { // 8 character alphanumeric codes codes.push(crypto.randomBytes(4).toString('hex')); } return codes; } // Step 5: Hash backup codes before storing async function hashBackupCodes(codes) { return Promise.all( codes.map(async (code) => ({ hash: crypto.createHash('sha256').update(code).digest('hex'), used: false, })) ); } ``` ```python # Python (pyotp) import pyotp import qrcode import io import secrets def generate_totp_secret(user_email: str, issuer: str = "MyApp"): secret = pyotp.random_base32() totp = pyotp.TOTP(secret) provisioning_uri = totp.provisioning_uri( name=user_email, issuer_name=issuer, ) return secret, provisioning_uri def verify_totp(secret: str, token: str) -> bool: totp = pyotp.TOTP(secret) # valid_window=1 accepts current +/- 1 time step return totp.verify(token, valid_window=1) def generate_backup_codes(count: int = 10) -> list[str]: return [secrets.token_hex(4) for _ in range(count)] ``` ```go // Go (pquerna/otp) import ( "github.com/pquerna/otp/totp" ) func GenerateTOTPSecret(email, issuer string) (*otp.Key, error) { key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuer, AccountName: email, Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, }) return key, err // key.Secret() - base32 secret // key.URL() - otpauth:// URI } func VerifyTOTP(secret, token string) bool { valid, _ := totp.ValidateCustom(token, secret, time.Now(), totp.ValidateOpts{ Period: 30, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, Skew: 1, // Accept +/- 1 time step }) return valid } ``` ### TOTP Enrollment Flow ```javascript // POST /auth/mfa/setup - Start TOTP enrollment app.post('/auth/mfa/setup', requireAuth, async (req, res) => { const user = await getUser(req.auth.sub); if (user.mfaEnabled) { return res.status(400).json({ error: 'MFA already enabled' }); } const { secret, otpauthUrl } = generateTOTPSecret(user.email); const qrCode = await generateQRCode(otpauthUrl); // Store secret temporarily (not yet confirmed) await db.users.update(req.auth.sub, { pendingMfaSecret: secret }); res.json({ qrCode, // Base64 PNG secret, // Manual entry fallback otpauthUrl, // Direct URL for authenticator }); }); // POST /auth/mfa/verify - Confirm enrollment app.post('/auth/mfa/verify', requireAuth, async (req, res) => { const { token } = req.body; const user = await getUser(req.auth.sub); if (!user.pendingMfaSecret) { return res.status(400).json({ error: 'No pending MFA setup' }); } if (!verifyTOTP(user.pendingMfaSecret, token)) { return res.status(400).json({ error: 'Invalid code' }); } // Generate backup codes const backupCodes = generateBackupCodes(10); const hashedCodes = await hashBackupCodes(backupCodes); // Activate MFA await db.users.update(req.auth.sub, { mfaSecret: user.pendingMfaSecret, pendingMfaSecret: null, mfaEnabled: true, backupCodes: hashedCodes, }); // Show backup codes ONCE - user must save them res.json({ success: true, backupCodes, // Plaintext, shown only once message: 'Save these backup codes in a secure location', }); }); ``` ### WebAuthn / Passkeys Passkeys provide phishing-resistant authentication using public key cryptography backed by hardware (platform authenticator, security key, or synced passkey). ```javascript // Server (using @simplewebauthn/server) import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; const rpName = 'My Application'; const rpID = 'example.com'; const origin = 'https://example.com'; // --- Registration (creating a passkey) --- // Step 1: Generate options app.post('/auth/passkey/register/options', requireAuth, async (req, res) => { const user = await getUser(req.auth.sub); const existingCredentials = await db.credentials.findByUser(user.id); const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.email, userDisplayName: user.name, attestationType: 'none', excludeCredentials: existingCredentials.map((c) => ({ id: c.credentialId, type: 'public-key', })), authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred', }, }); // Store challenge for verification await db.challenges.upsert(user.id, options.challenge); res.json(options); }); // Step 2: Verify registration app.post('/auth/passkey/register/verify', requireAuth, async (req, res) => { const user = await getUser(req.auth.sub); const challenge = await db.challenges.get(user.id); const verification = await verifyRegistrationResponse({ response: req.body, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, }); if (verification.verified && verification.registrationInfo) { const { credentialID, credentialPublicKey, counter } = verification.registrationInfo; await db.credentials.create({ userId: user.id, credentialId: credentialID, publicKey: credentialPublicKey, counter, name: req.body.name || 'My passkey', createdAt: new Date(), }); } res.json({ verified: verification.verified }); }); // --- Authentication (using a passkey) --- // Step 1: Generate options app.post('/auth/passkey/login/options', async (req, res) => { const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', // For discoverable credentials (passkeys), no need to specify allowCredentials }); // Store challenge (keyed by session or response) await db.challenges.upsertBySession(req.sessionID, options.challenge); res.json(options); }); // Step 2: Verify authentication app.post('/auth/passkey/login/verify', async (req, res) => { const challenge = await db.challenges.getBySession(req.sessionID); const credential = await db.credentials.findByCredentialId(req.body.id); if (!credential) { return res.status(401).json({ error: 'Unknown credential' }); } const verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: { credentialID: credential.credentialId, credentialPublicKey: credential.publicKey, counter: credential.counter, }, }); if (verification.verified) { // Update counter to prevent replay attacks await db.credentials.updateCounter( credential.id, verification.authenticationInfo.newCounter ); // Create session const user = await getUser(credential.userId); const token = await createAccessToken(user); res.json({ token }); } else { res.status(401).json({ error: 'Verification failed' }); } }); ``` ### Backup Code Verification ```javascript async function verifyBackupCode(userId, code) { const user = await db.users.findOne(userId); const codeHash = crypto.createHash('sha256').update(code).digest('hex'); const matchingCode = user.backupCodes.find( (bc) => !bc.used && crypto.timingSafeEqual( Buffer.from(bc.hash), Buffer.from(codeHash) ) ); if (!matchingCode) return false; // Mark code as used matchingCode.used = true; matchingCode.usedAt = new Date(); await db.users.update(userId, { backupCodes: user.backupCodes }); // Warn if running low const remaining = user.backupCodes.filter((bc) => !bc.used).length; if (remaining <= 2) { await sendEmail(user.email, 'Low backup codes warning', `You have ${remaining} backup codes remaining. Consider generating new ones.` ); } return true; } ``` ## Secure Password Reset ### Flow ``` 1. User requests reset → generate token → send email 2. User clicks link → verify token → show reset form 3. User submits new password → validate token again → update password 4. Invalidate token → invalidate all sessions → notify user ``` ### Implementation ```javascript // Step 1: Request password reset app.post('/auth/forgot-password', async (req, res) => { const { email } = req.body; // Rate limit: max 3 reset requests per hour per email const rateOk = await checkRateLimit(`reset:${email}`, 3, 3600); if (!rateOk) { // Still return 200 to prevent enumeration return res.json({ message: 'If the email exists, a reset link was sent' }); } const user = await db.findUserByEmail(email); if (user) { // Generate cryptographically random token const token = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); await db.passwordResets.create({ userId: user.id, tokenHash, expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour used: false, }); // Delete any previous unused reset tokens for this user await db.passwordResets.deleteUnused(user.id, tokenHash); await sendEmail(user.email, 'Password Reset', { resetUrl: `https://app.example.com/reset-password?token=${token}`, expiresIn: '1 hour', }); } // ALWAYS return same response (prevent email enumeration) res.json({ message: 'If the email exists, a reset link was sent' }); }); // Step 3: Reset password app.post('/auth/reset-password', async (req, res) => { const { token, newPassword } = req.body; // Validate password strength if (newPassword.length < 8) { return res.status(400).json({ error: 'Password too short (minimum 8)' }); } // Check breached passwords (HaveIBeenPwned API) if (await isBreachedPassword(newPassword)) { return res.status(400).json({ error: 'This password has been exposed in a data breach' }); } const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const resetRecord = await db.passwordResets.findOne({ tokenHash, used: false, expiresAt: { $gt: new Date() }, }); if (!resetRecord) { return res.status(400).json({ error: 'Invalid or expired reset token' }); } // Update password const passwordHash = await hashPassword(newPassword); await db.users.update(resetRecord.userId, { passwordHash }); // Mark token as used await db.passwordResets.update(resetRecord.id, { used: true, usedAt: new Date() }); // Invalidate all existing sessions await db.sessions.deleteAllForUser(resetRecord.userId); // Increment token version to invalidate all JWTs await db.users.increment(resetRecord.userId, 'tokenVersion'); // Send notification email const user = await db.users.findOne(resetRecord.userId); await sendEmail(user.email, 'Password Changed', { message: 'Your password was changed. If you did not do this, contact support immediately.', }); res.json({ message: 'Password reset successfully' }); }); ``` ### Breached Password Check (HaveIBeenPwned) ```javascript // k-anonymity: only send first 5 chars of SHA-1 hash async function isBreachedPassword(password) { const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase(); const prefix = sha1.substring(0, 5); const suffix = sha1.substring(5); const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); const text = await response.text(); // Check if our suffix appears in the response return text.split('\n').some((line) => { const [hashSuffix] = line.split(':'); return hashSuffix.trim() === suffix; }); } ``` ## Email Verification ```javascript // Send verification email on signup app.post('/auth/register', async (req, res) => { const { email, password, name } = req.body; const passwordHash = await hashPassword(password); const user = await db.users.create({ email, passwordHash, name, emailVerified: false, }); const token = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); await db.emailVerifications.create({ userId: user.id, tokenHash, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours }); await sendEmail(email, 'Verify your email', { verifyUrl: `https://app.example.com/verify-email?token=${token}`, }); res.status(201).json({ message: 'Account created. Check your email to verify.' }); }); // Verify email app.get('/auth/verify-email', async (req, res) => { const { token } = req.query; const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const record = await db.emailVerifications.findOne({ tokenHash, expiresAt: { $gt: new Date() }, }); if (!record) { return res.status(400).json({ error: 'Invalid or expired verification link' }); } await db.users.update(record.userId, { emailVerified: true }); await db.emailVerifications.delete(record.id); res.json({ message: 'Email verified successfully' }); }); // Resend verification (rate limited) app.post('/auth/resend-verification', requireAuth, async (req, res) => { const rateOk = await checkRateLimit(`verify:${req.auth.sub}`, 3, 3600); if (!rateOk) { return res.status(429).json({ error: 'Too many requests. Try again later.' }); } // ... generate new token and send email res.json({ message: 'Verification email sent' }); }); ``` ## Magic Links Passwordless email authentication using one-time login links. ```javascript // Request magic link app.post('/auth/magic-link', async (req, res) => { const { email } = req.body; // Rate limit const rateOk = await checkRateLimit(`magic:${email}`, 5, 3600); if (!rateOk) { return res.json({ message: 'If the email exists, a login link was sent' }); } const user = await db.findUserByEmail(email); if (user) { const token = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); // Invalidate any existing magic link tokens await db.magicLinks.deleteForUser(user.id); await db.magicLinks.create({ userId: user.id, tokenHash, expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes used: false, }); await sendEmail(email, 'Your login link', { loginUrl: `https://app.example.com/auth/magic-link/verify?token=${token}`, expiresIn: '10 minutes', }); } // Same response regardless of email existence res.json({ message: 'If the email exists, a login link was sent' }); }); // Verify magic link app.get('/auth/magic-link/verify', async (req, res) => { const { token } = req.query; const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const record = await db.magicLinks.findOne({ tokenHash, used: false, expiresAt: { $gt: new Date() }, }); if (!record) { return res.status(400).json({ error: 'Invalid or expired link' }); } // Mark as used (single-use) await db.magicLinks.update(record.id, { used: true, usedAt: new Date() }); // Create session const user = await db.users.findOne(record.userId); const accessToken = await createAccessToken(user); res.json({ token: accessToken }); }); ``` ## API Key Management ### Key Generation ```javascript // Generate API key with identifiable prefix function generateApiKey(prefix = 'sk') { // Format: prefix_randompart // Example: sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 const randomPart = crypto.randomBytes(24).toString('base64url'); return `${prefix}_${randomPart}`; } // Store: hash the key, keep prefix for identification async function createApiKey(userId, name, permissions, expiresIn) { const key = generateApiKey('sk'); const prefix = key.substring(0, 8); // "sk_a1b2c" const keyHash = crypto.createHash('sha256').update(key).digest('hex'); await db.apiKeys.create({ userId, name, prefix, keyHash, permissions, // ['read:data', 'write:data'] expiresAt: expiresIn ? new Date(Date.now() + expiresIn) : null, createdAt: new Date(), lastUsedAt: null, revoked: false, }); // Return the full key ONCE - it cannot be recovered return { key, // Show this to the user once prefix, name, permissions, expiresAt: expiresIn ? new Date(Date.now() + expiresIn) : null, }; } ``` ### Key Verification ```javascript // Verify API key on request async function verifyApiKey(apiKey) { const prefix = apiKey.substring(0, 8); const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); const record = await db.apiKeys.findOne({ prefix, revoked: false, }); if (!record) return null; // Constant-time comparison if (!crypto.timingSafeEqual( Buffer.from(keyHash, 'hex'), Buffer.from(record.keyHash, 'hex') )) { return null; } // Check expiry if (record.expiresAt && record.expiresAt < new Date()) { return null; } // Update last used timestamp (async, don't block response) db.apiKeys.update(record.id, { lastUsedAt: new Date() }).catch(() => {}); return record; } ``` ### Key Rotation ```javascript // Rotate API key (create new, keep old active for grace period) app.post('/api/keys/:id/rotate', requireAuth, async (req, res) => { const oldKey = await db.apiKeys.findOne({ id: req.params.id, userId: req.auth.sub }); if (!oldKey) return res.status(404).json({ error: 'Key not found' }); // Create new key with same permissions const newKeyResult = await createApiKey( req.auth.sub, `${oldKey.name} (rotated)`, oldKey.permissions, oldKey.expiresAt ? oldKey.expiresAt - Date.now() : null ); // Mark old key to expire in 24 hours (grace period) await db.apiKeys.update(oldKey.id, { expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), rotatedTo: newKeyResult.prefix, }); res.json({ newKey: newKeyResult.key, // Show once oldKeyExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), message: 'Old key will remain active for 24 hours', }); }); ``` ## Session Management ### Concurrent Session Handling ```javascript // Limit concurrent sessions per user const MAX_SESSIONS = 5; async function createSession(userId, metadata) { const sessions = await db.sessions.findByUser(userId); if (sessions.length >= MAX_SESSIONS) { // Remove oldest session const oldest = sessions.sort((a, b) => a.createdAt - b.createdAt)[0]; await db.sessions.delete(oldest.id); } return db.sessions.create({ userId, createdAt: new Date(), lastActiveAt: new Date(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), ipAddress: metadata.ip, userAgent: metadata.userAgent, deviceInfo: parseUserAgent(metadata.userAgent), }); } ``` ### Session Listing and Revocation ```javascript // List active sessions app.get('/auth/sessions', requireAuth, async (req, res) => { const sessions = await db.sessions.findByUser(req.auth.sub); res.json(sessions.map((s) => ({ id: s.id, current: s.id === req.session.id, device: s.deviceInfo, ipAddress: s.ipAddress, lastActive: s.lastActiveAt, createdAt: s.createdAt, }))); }); // Revoke a specific session app.delete('/auth/sessions/:id', requireAuth, async (req, res) => { const session = await db.sessions.findOne({ id: req.params.id, userId: req.auth.sub, }); if (!session) return res.status(404).json({ error: 'Session not found' }); await db.sessions.delete(session.id); res.json({ message: 'Session revoked' }); }); // Revoke all sessions except current app.post('/auth/sessions/revoke-all', requireAuth, async (req, res) => { await db.sessions.deleteAllExcept(req.auth.sub, req.session.id); res.json({ message: 'All other sessions revoked' }); }); ``` ## Account Security ### Login Notifications ```javascript // Notify user of new login from unrecognized device/location async function checkLoginAnomaly(userId, loginMetadata) { const { ip, userAgent, geoLocation } = loginMetadata; const knownDevices = await db.knownDevices.findByUser(userId); const deviceFingerprint = crypto .createHash('sha256') .update(`${userAgent}`) .digest('hex'); const isKnown = knownDevices.some((d) => d.fingerprint === deviceFingerprint); if (!isKnown) { const user = await db.users.findOne(userId); // Register new device await db.knownDevices.create({ userId, fingerprint: deviceFingerprint, userAgent, firstSeen: new Date(), lastSeen: new Date(), }); // Send notification await sendEmail(user.email, 'New login detected', { device: parseUserAgent(userAgent), location: geoLocation, time: new Date().toISOString(), message: 'If this was not you, change your password immediately.', }); } } ``` ### Suspicious Activity Detection ```javascript // Detect and flag suspicious patterns class SecurityMonitor { async checkLogin(userId, metadata) { const flags = []; // 1. Impossible travel: login from two distant locations in short time const lastLogin = await db.loginHistory.findLast(userId); if (lastLogin) { const distance = geoDistance(lastLogin.location, metadata.location); const timeDiff = (Date.now() - lastLogin.timestamp) / 1000 / 3600; // hours const maxSpeed = distance / timeDiff; // km/h if (maxSpeed > 1000) { // Faster than commercial flight flags.push('impossible_travel'); } } // 2. Unusual time: login outside user's normal hours const loginHour = new Date().getHours(); const normalHours = await db.users.getNormalLoginHours(userId); if (normalHours && (loginHour < normalHours.start || loginHour > normalHours.end)) { flags.push('unusual_time'); } // 3. Multiple failed attempts before success const recentFailures = await db.loginAttempts.countRecent(userId, 3600, 'failure'); if (recentFailures >= 5) { flags.push('brute_force_attempt'); } // 4. Known bad IP (threat intelligence) if (await isKnownBadIP(metadata.ip)) { flags.push('suspicious_ip'); } // Log flags and potentially require step-up auth if (flags.length > 0) { await db.securityEvents.create({ userId, event: 'suspicious_login', flags, metadata, timestamp: new Date(), }); // Require MFA if not already provided if (flags.includes('impossible_travel') || flags.includes('suspicious_ip')) { return { requireMFA: true, flags }; } } return { requireMFA: false, flags }; } } ``` ## Timing-Safe Operations Critical for any comparison involving secrets (tokens, passwords, API keys). ```javascript // WRONG: Standard string comparison leaks timing information if (providedToken === storedToken) { ... } // VULNERABLE // CORRECT: Constant-time comparison import crypto from 'crypto'; function timingSafeCompare(a, b) { // Both inputs must be same length for timingSafeEqual if (a.length !== b.length) { // Still perform comparison to maintain constant time crypto.timingSafeEqual(Buffer.from(a), Buffer.from(a)); return false; } return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } ``` ```python # Python import hmac # Use hmac.compare_digest for constant-time comparison if hmac.compare_digest(provided_token, stored_token): # Valid pass ``` ```go // Go import "crypto/subtle" if subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1 { // Valid } ``` ## Security Headers for Auth Pages ```javascript // Helmet.js or manual headers for auth-related pages app.use((req, res, next) => { // Prevent clickjacking res.setHeader('X-Frame-Options', 'DENY'); // Prevent MIME sniffing res.setHeader('X-Content-Type-Options', 'nosniff'); // Enable HSTS res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); // CSP res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'"); // Prevent referrer leakage (important for reset tokens in URLs) res.setHeader('Referrer-Policy', 'no-referrer'); // Permissions policy res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); next(); }); ``` -
jwt-sessions.md 20.5 KB
# JWT and Session Management Deep-dive reference for JSON Web Tokens, session-based authentication, cookie security, and CSRF protection. ## JWT Structure A JWT consists of three Base64URL-encoded parts separated by dots. ### Header ```json { "alg": "RS256", "typ": "JWT", "kid": "key-2024-01" } ``` | Field | Purpose | |-------|---------| | `alg` | Signing algorithm (RS256, ES256, HS256) | | `typ` | Token type (always "JWT") | | `kid` | Key ID for key rotation (optional but recommended) | ### Payload (Claims) #### Registered Claims (RFC 7519) ```json { "iss": "https://auth.example.com", "sub": "user_abc123", "aud": "https://api.example.com", "exp": 1700001500, "nbf": 1700000600, "iat": 1700000600, "jti": "unique-token-id-xyz" } ``` | Claim | Required | Purpose | |-------|----------|---------| | `iss` | Recommended | Identifies the token issuer | | `sub` | Recommended | Identifies the subject (user ID) | | `aud` | Recommended | Intended recipient(s) of the token | | `exp` | Required | Expiration time (Unix timestamp) | | `nbf` | Optional | Token not valid before this time | | `iat` | Recommended | Time the token was issued | | `jti` | Optional | Unique identifier for the token (for revocation) | #### Custom Claims ```json { "role": "admin", "permissions": ["read", "write", "delete"], "org_id": "org_456", "tenant": "acme-corp" } ``` **Guidelines for custom claims:** - Namespace custom claims to avoid collisions: `https://example.com/role` - Keep payload small (< 1KB) -- JWTs are sent with every request - Never put sensitive data in claims (tokens are encoded, not encrypted) - Include only what the resource server needs for authorization decisions ### Signature ``` RSASHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), privateKey ) ``` The signature ensures the token has not been tampered with. Verification uses the public key (asymmetric) or shared secret (symmetric). ## Signing Algorithms ### RS256 (RSA + SHA-256) **Type:** Asymmetric (public/private key pair) **Key size:** 2048 bits minimum (4096 recommended) **Use when:** Multiple services verify tokens, auth server is separate from resource servers. ```javascript // Node.js (jose library) import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose'; // Sign (auth server - has private key) const privateKey = await importPKCS8(privateKeyPem, 'RS256'); const token = await new SignJWT({ sub: 'user_123', role: 'admin' }) .setProtectedHeader({ alg: 'RS256', kid: 'key-2024-01' }) .setIssuedAt() .setIssuer('https://auth.example.com') .setAudience('https://api.example.com') .setExpirationTime('15m') .sign(privateKey); // Verify (resource server - has public key only) const publicKey = await importSPKI(publicKeyPem, 'RS256'); const { payload } = await jwtVerify(token, publicKey, { issuer: 'https://auth.example.com', audience: 'https://api.example.com', }); ``` ```python # Python (PyJWT) import jwt from datetime import datetime, timedelta, timezone # Sign token = jwt.encode( { "sub": "user_123", "role": "admin", "iss": "https://auth.example.com", "aud": "https://api.example.com", "exp": datetime.now(timezone.utc) + timedelta(minutes=15), "iat": datetime.now(timezone.utc), }, private_key, algorithm="RS256", headers={"kid": "key-2024-01"}, ) # Verify payload = jwt.decode( token, public_key, algorithms=["RS256"], issuer="https://auth.example.com", audience="https://api.example.com", ) ``` ```go // Go (golang-jwt/jwt/v5) import ( "time" "github.com/golang-jwt/jwt/v5" ) // Sign claims := jwt.MapClaims{ "sub": "user_123", "role": "admin", "iss": "https://auth.example.com", "aud": "https://api.example.com", "exp": time.Now().Add(15 * time.Minute).Unix(), "iat": time.Now().Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token.Header["kid"] = "key-2024-01" signedToken, err := token.SignedString(privateKey) // Verify parsedToken, err := jwt.Parse(signedToken, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return publicKey, nil }, jwt.WithIssuer("https://auth.example.com"), jwt.WithAudience("https://api.example.com")) ``` ### ES256 (ECDSA + SHA-256) **Type:** Asymmetric (public/private key pair) **Curve:** P-256 **Use when:** Same as RS256 but smaller tokens and faster signing. Preferred for new systems. ```javascript // Node.js (jose) import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose'; const privateKey = await importPKCS8(ecPrivateKeyPem, 'ES256'); const token = await new SignJWT({ sub: 'user_123' }) .setProtectedHeader({ alg: 'ES256' }) .setExpirationTime('15m') .sign(privateKey); ``` **ES256 vs RS256:** - ES256 signatures: 64 bytes vs RS256: 256 bytes - ES256 key generation: faster - ES256 signing: faster - ES256 verification: slightly slower - Both are equally secure for JWT purposes ### HS256 (HMAC + SHA-256) **Type:** Symmetric (shared secret) **Use when:** Single service creates and verifies tokens. Simple internal use. ```javascript // Node.js (jose) import { SignJWT, jwtVerify } from 'jose'; const secret = new TextEncoder().encode(process.env.JWT_SECRET); // Secret must be at least 256 bits (32 bytes) for HS256 const token = await new SignJWT({ sub: 'user_123' }) .setProtectedHeader({ alg: 'HS256' }) .setExpirationTime('15m') .sign(secret); const { payload } = await jwtVerify(token, secret); ``` **Warning:** With HS256, anyone who can verify tokens can also create them. Never use HS256 when the verifier should not be able to issue tokens. ### Algorithm Selection Matrix | Factor | HS256 | RS256 | ES256 | |--------|-------|-------|-------| | Key type | Shared secret | RSA key pair | EC key pair | | Token size | Smallest | Largest | Medium | | Sign speed | Fast | Slow | Fast | | Verify speed | Fast | Fast | Medium | | Key distribution | Secret must be shared | Only public key shared | Only public key shared | | Best for | Single service | Distributed, legacy | Distributed, modern | ## Access + Refresh Token Pattern ### Flow 1. User authenticates (login with credentials, OAuth2, etc.) 2. Auth server issues access token (short-lived) and refresh token (long-lived) 3. Client uses access token for API requests via `Authorization: Bearer <token>` 4. When access token expires, client sends refresh token to get new tokens 5. Auth server validates refresh token, issues new access + refresh tokens 6. Old refresh token is invalidated (rotation) ### Token Lifetimes | Token | Lifetime | Storage | |-------|----------|---------| | Access token | 5-15 minutes | Memory (SPA), httpOnly cookie (BFF) | | Refresh token | 7-30 days | httpOnly cookie, secure storage (mobile) | ### Refresh Token Rotation ```javascript // Auth server: refresh endpoint app.post('/auth/refresh', async (req, res) => { const { refreshToken } = req.cookies; // 1. Look up the refresh token const storedToken = await db.refreshTokens.findOne({ token: hash(refreshToken), }); if (!storedToken) { // Token not found - might be reuse of revoked token // Revoke entire token family as precaution await db.refreshTokens.deleteMany({ family: storedToken?.family }); return res.status(401).json({ error: 'Invalid refresh token' }); } if (storedToken.revoked) { // Reuse detected! Revoke entire family await db.refreshTokens.deleteMany({ family: storedToken.family }); return res.status(401).json({ error: 'Token reuse detected' }); } if (storedToken.expiresAt < new Date()) { return res.status(401).json({ error: 'Refresh token expired' }); } // 2. Revoke the old refresh token await db.refreshTokens.updateOne( { token: hash(refreshToken) }, { revoked: true } ); // 3. Issue new tokens const newAccessToken = await createAccessToken(storedToken.userId); const newRefreshToken = crypto.randomBytes(32).toString('hex'); // 4. Store new refresh token in same family await db.refreshTokens.insertOne({ token: hash(newRefreshToken), userId: storedToken.userId, family: storedToken.family, // Same family for reuse detection expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), revoked: false, }); // 5. Return new tokens res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000, path: '/auth/refresh', // Only sent to refresh endpoint }); res.json({ accessToken: newAccessToken }); }); ``` ### Token Family Detection Token families track lineage of refresh tokens. If a revoked refresh token is reused (indicating theft), all tokens in the family are invalidated. ``` Login → RT1 (family: F1) RT1 → RT2 (family: F1, RT1 revoked) RT2 → RT3 (family: F1, RT2 revoked) If attacker uses stolen RT1: RT1 is revoked → ALERT → revoke all in family F1 User must re-authenticate ``` ## Token Revocation Strategies ### Strategy 1: Short Expiry + No Revocation - Access tokens expire in 5-15 minutes - No revocation mechanism needed - Revoke refresh token to prevent renewal - **Trade-off:** Cannot immediately invalidate access tokens ### Strategy 2: Blocklist (Redis) ```javascript // Add to blocklist on logout/revocation await redis.set(`blocklist:${jti}`, '1', 'EX', tokenRemainingTTL); // Check on every request const isRevoked = await redis.get(`blocklist:${jti}`); if (isRevoked) return res.status(401).json({ error: 'Token revoked' }); ``` - Entries auto-expire when the token would have expired - **Trade-off:** Requires Redis, adds latency to every request ### Strategy 3: Version-Based Revocation ```javascript // User record has a tokenVersion // JWT includes tokenVersion claim // On password change/logout-all: increment tokenVersion // On verification: compare JWT version with stored version const user = await db.users.findOne({ id: payload.sub }); if (payload.tokenVersion !== user.tokenVersion) { return res.status(401).json({ error: 'Token revoked' }); } ``` - Revokes all tokens for a user at once - **Trade-off:** Requires DB lookup per request (but can cache) ## Session-Based Authentication ### Server-Side Sessions ```javascript // Express + express-session + connect-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 }), name: '__Host-session', // Cookie name with secure prefix secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { secure: true, // HTTPS only httpOnly: true, // No JS access sameSite: 'lax', // CSRF protection maxAge: 24 * 60 * 60 * 1000, // 24 hours path: '/', }, rolling: true, // Reset expiry on each request })); ``` ```python # FastAPI + Redis sessions from fastapi import FastAPI, Request, Response from uuid import uuid4 import redis.asyncio as redis import json r = redis.from_url("redis://localhost:6379") async def create_session(response: Response, user_id: str, data: dict): session_id = str(uuid4()) session_data = {"user_id": user_id, **data} await r.setex(f"session:{session_id}", 86400, json.dumps(session_data)) response.set_cookie( key="__Host-session", value=session_id, httponly=True, secure=True, samesite="lax", max_age=86400, path="/", ) return session_id async def get_session(request: Request) -> dict | None: session_id = request.cookies.get("__Host-session") if not session_id: return None data = await r.get(f"session:{session_id}") if data: # Reset TTL (sliding window) await r.expire(f"session:{session_id}", 86400) return json.loads(data) return None ``` ### Session Storage Backends | Backend | Scalability | Persistence | Latency | Use When | |---------|-------------|-------------|---------|----------| | Memory | Single server | None (lost on restart) | Fastest | Development only | | Redis | Horizontal | Optional (AOF/RDB) | ~1ms | Production default | | PostgreSQL | Horizontal | Full | ~5ms | Already using Postgres, need durability | | MongoDB | Horizontal | Full | ~3ms | Already using MongoDB | ### Session Fixation Prevention Always regenerate the session ID after authentication state changes: ```javascript // Express app.post('/login', async (req, res) => { const user = await authenticate(req.body.email, req.body.password); if (!user) return res.status(401).json({ error: 'Invalid credentials' }); // CRITICAL: Regenerate session ID to prevent fixation 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.save((err) => { if (err) return res.status(500).json({ error: 'Session error' }); res.json({ user: { id: user.id, email: user.email } }); }); }); }); ``` ## Cookie Security ### Cookie Attributes Deep Dive #### SameSite | Value | Behavior | CSRF Protection | Use Case | |-------|----------|-----------------|----------| | `Strict` | Cookie never sent cross-site | Strongest | Internal tools, admin panels | | `Lax` | Sent on top-level navigation (GET) | Good (default) | General-purpose sessions | | `None` | Sent on all cross-site requests | None (requires `Secure`) | Embedded widgets, cross-origin APIs | **Lax vs Strict:** Lax allows the session cookie to be sent when a user clicks a link to your site from an external page. Strict does not, so users would appear logged out after clicking a link from an email or social media. #### Secure Prefix Cookies ``` // __Host- prefix (strictest, recommended) Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/ // Requirements for __Host-: // - Must have Secure flag // - Must NOT have Domain attribute // - Must have Path=/ // - Only sent to exact host (no subdomains) // __Secure- prefix (less strict) Set-Cookie: __Secure-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/ // Requirements for __Secure-: // - Must have Secure flag // - Can have Domain attribute ``` Use `__Host-` prefix for session cookies. It prevents a subdomain takeover from overwriting your session cookie. ### Cookie vs Authorization Header | Aspect | Cookie | Authorization Header | |--------|--------|---------------------| | Automatic sending | Yes (browser sends automatically) | No (must attach manually) | | CSRF risk | Yes (unless SameSite) | No | | XSS theft risk | No (if HttpOnly) | Yes (if in accessible storage) | | Cross-origin | Configurable (SameSite, CORS) | Simple (just add header) | | Best for | Server-rendered apps, BFF | Pure APIs, mobile apps | ## CSRF Protection ### Synchronizer Token Pattern ```javascript // Generate CSRF token and store in session import crypto from 'crypto'; app.use((req, res, next) => { if (!req.session.csrfToken) { req.session.csrfToken = crypto.randomBytes(32).toString('hex'); } res.locals.csrfToken = req.session.csrfToken; next(); }); // Validate on state-changing requests app.use((req, res, next) => { if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { const token = req.headers['x-csrf-token'] || req.body._csrf; if (!token || token !== req.session.csrfToken) { return res.status(403).json({ error: 'Invalid CSRF token' }); } } next(); }); ``` ### Double-Submit Cookie Pattern ```javascript // Set CSRF token as a separate cookie (NOT httpOnly, so JS can read it) res.cookie('csrf-token', csrfToken, { secure: true, sameSite: 'strict', // httpOnly: false -- intentionally readable by JS path: '/', }); // Client reads cookie value and sends in header // fetch('/api/data', { // method: 'POST', // headers: { 'X-CSRF-Token': getCookie('csrf-token') }, // }); // Server validates: cookie value === header value ``` ### SameSite as Defense-in-Depth `SameSite=Lax` prevents most CSRF attacks because the cookie is not sent on cross-site POST requests. However, it does not protect against: - Subdomain attacks - GET-based state changes (which you should not have) - Browser bugs **Recommendation:** Use `SameSite=Lax` AND a CSRF token for defense-in-depth. ## Stateless vs Stateful Authentication | Aspect | Stateless (JWT) | Stateful (Sessions) | |--------|-----------------|---------------------| | **Server storage** | None (token is self-contained) | Session store (Redis, DB) | | **Scalability** | Easy (any server can verify) | Requires shared session store | | **Revocation** | Hard (need blocklist) | Easy (delete session) | | **Token size** | Larger (contains claims) | Smaller (just session ID) | | **Offline verification** | Yes (with public key) | No (must query session store) | | **Information leakage** | Claims visible (base64) | Server-side only | | **Performance** | No DB lookup for verification | DB/cache lookup per request | | **Logout** | Complex (blocklist or wait for expiry) | Simple (delete session) | | **Best for** | Microservices, APIs, mobile | Monoliths, server-rendered apps | ### Hybrid Approach Many production systems use both: ``` User login → Session created (server-side) → JWT issued for API calls → Session manages refresh tokens → JWT used for stateless API authorization ``` ## Token Storage for SPAs ### Option 1: BFF Pattern (Recommended) ``` Browser ←→ BFF (Backend-for-Frontend) ←→ API │ │ │ session cookie │ JWT in Authorization header │ (httpOnly) │ (server-to-server) ``` The BFF holds tokens server-side and proxies API calls. The browser only has a session cookie. ### Option 2: HttpOnly Cookie Access token stored in httpOnly cookie. Requires CSRF protection. Works well for same-origin APIs. ### Option 3: In-Memory (JavaScript Variable) Access token stored in a JavaScript variable. Lost on page refresh (must re-authenticate via refresh token in httpOnly cookie). Safest browser storage for tokens but impacts UX. ### What NOT to Do | Storage | Problem | |---------|---------| | localStorage | Accessible via XSS, persists across tabs | | sessionStorage | Accessible via XSS | | Non-httpOnly cookie | Accessible via XSS | | URL parameters | Logged by servers, proxies, browser history | ## Key Rotation ### Why Rotate Keys - Limit exposure if a key is compromised - Compliance requirements - Cryptographic best practice ### Rotation Process ``` 1. Generate new key pair (kid: "key-2025-01") 2. Add new key to JWKS endpoint 3. Start signing new tokens with new key 4. Old tokens still verify (old key still in JWKS) 5. After max token lifetime, remove old key from JWKS ``` ### JWKS (JSON Web Key Set) Endpoint ```json // GET /.well-known/jwks.json { "keys": [ { "kty": "RSA", "kid": "key-2025-01", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" }, { "kty": "RSA", "kid": "key-2024-01", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" } ] } ``` ```javascript // Verify JWT with JWKS (jose library) import { createRemoteJWKSet, jwtVerify } from 'jose'; const JWKS = createRemoteJWKSet( new URL('https://auth.example.com/.well-known/jwks.json') ); const { payload } = await jwtVerify(token, JWKS, { issuer: 'https://auth.example.com', audience: 'https://api.example.com', }); ``` ## JWT Validation Checklist Every JWT verification should check: - [ ] **Signature** is valid - [ ] **Algorithm** matches expected (prevent `alg: none` attack) - [ ] **Expiration** (`exp`) has not passed - [ ] **Not Before** (`nbf`) has passed (if present) - [ ] **Issuer** (`iss`) matches expected value - [ ] **Audience** (`aud`) matches your service - [ ] **Token type** is correct (access vs refresh) - [ ] **Key ID** (`kid`) maps to a known key ### Common JWT Attacks | Attack | Description | Prevention | |--------|-------------|------------| | `alg: none` | Attacker removes signature | Always validate alg against allowlist | | Key confusion (RS256→HS256) | Attacker signs with public key as HMAC secret | Explicitly specify expected algorithm | | Token substitution | Access token used as refresh (or vice versa) | Include token type in claims | | JWK injection | Attacker includes key in JWT header | Only trust keys from your JWKS endpoint | | Expired token replay | Attacker replays old token | Always validate `exp` claim | -
oauth2-oidc.md 31.4 KB
# OAuth2 and OpenID Connect Comprehensive reference for OAuth2 grant types, OIDC, provider integration, and social login. ## OAuth2 Core Concepts ### Roles | Role | Description | Example | |------|-------------|---------| | **Resource Owner** | The user who owns the data | End user | | **Client** | The application requesting access | Your web/mobile app | | **Authorization Server** | Issues tokens after authentication | Auth0, Keycloak, your auth service | | **Resource Server** | Hosts the protected API | Your API server | ### Key Terms | Term | Description | |------|-------------| | **Scope** | Permission level requested (e.g., `read:users`, `write:posts`) | | **Grant Type** | The flow used to obtain tokens | | **Authorization Code** | Temporary code exchanged for tokens | | **Access Token** | Token used to call the API | | **Refresh Token** | Token used to get new access tokens | | **Redirect URI** | Where the authorization server sends the user back | | **State** | CSRF protection parameter (random, unguessable) | | **PKCE** | Proof Key for Code Exchange (prevents code interception) | ## Authorization Code + PKCE The recommended flow for web applications, mobile apps, and SPAs. PKCE (Proof Key for Code Exchange) protects against authorization code interception. ### Flow ``` ┌──────┐ ┌───────────────┐ ┌──────────────┐ │Client│ │ Authorization │ │ Resource │ │ │ │ Server │ │ Server │ └──┬───┘ └───────┬───────┘ └──────┬───────┘ │ │ │ │ 1. Generate code_verifier (random) │ │ code_challenge = SHA256(code_verifier) │ │ │ │ │ 2. Redirect to /authorize │ │ ?response_type=code │ │ &client_id=xxx │ │ &redirect_uri=https://app/callback │ │ &scope=openid profile email │ │ &state=random_csrf_value │ │ &code_challenge=xxx │ │ &code_challenge_method=S256 │ │──────────────>│ │ │ │ │ │ 3. User authenticates and consents │ │ │ │ │ 4. Redirect to callback │ │ ?code=authorization_code │ │ &state=random_csrf_value │ │<──────────────│ │ │ │ │ 5. POST /token │ │ grant_type=authorization_code │ │ &code=authorization_code │ │ &redirect_uri=https://app/callback │ │ &client_id=xxx │ │ &code_verifier=original_random_value │ │──────────────>│ │ │ │ │ │ 6. Response: access_token, refresh_token, │ │ id_token (if OIDC) │ │<──────────────│ │ │ │ │ 7. GET /api/resource │ │ Authorization: Bearer access_token │ │────────────────────────────────────────────────>│ │ │ │ 8. Response: protected resource │ │<────────────────────────────────────────────────│ ``` ### Implementation: Node.js ```javascript import crypto from 'crypto'; // Step 1: Generate PKCE values function generatePKCE() { const verifier = crypto.randomBytes(32).toString('base64url'); const challenge = crypto .createHash('sha256') .update(verifier) .digest('base64url'); return { verifier, challenge }; } // Step 2: Build authorization URL function getAuthorizationUrl(config) { const { verifier, challenge } = generatePKCE(); const state = crypto.randomBytes(16).toString('hex'); // Store verifier and state in session // req.session.pkceVerifier = verifier; // req.session.oauthState = state; const params = new URLSearchParams({ response_type: 'code', client_id: config.clientId, redirect_uri: config.redirectUri, scope: 'openid profile email', state, code_challenge: challenge, code_challenge_method: 'S256', }); return `${config.authorizationEndpoint}?${params}`; } // Step 5: Exchange code for tokens async function exchangeCode(code, verifier, config) { const response = await fetch(config.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: config.redirectUri, client_id: config.clientId, client_secret: config.clientSecret, // Confidential clients only code_verifier: verifier, }), }); if (!response.ok) { throw new Error(`Token exchange failed: ${response.status}`); } return response.json(); // Returns: { access_token, refresh_token, id_token, token_type, expires_in } } ``` ### Implementation: Python ```python import hashlib import secrets import base64 from urllib.parse import urlencode import httpx def generate_pkce(): verifier = secrets.token_urlsafe(32) challenge = base64.urlsafe_b64encode( hashlib.sha256(verifier.encode()).digest() ).rstrip(b"=").decode() return verifier, challenge def get_authorization_url(config: dict) -> tuple[str, str, str]: verifier, challenge = generate_pkce() state = secrets.token_hex(16) params = urlencode({ "response_type": "code", "client_id": config["client_id"], "redirect_uri": config["redirect_uri"], "scope": "openid profile email", "state": state, "code_challenge": challenge, "code_challenge_method": "S256", }) url = f"{config['authorization_endpoint']}?{params}" return url, verifier, state async def exchange_code(code: str, verifier: str, config: dict) -> dict: async with httpx.AsyncClient() as client: response = await client.post( config["token_endpoint"], data={ "grant_type": "authorization_code", "code": code, "redirect_uri": config["redirect_uri"], "client_id": config["client_id"], "client_secret": config["client_secret"], "code_verifier": verifier, }, ) response.raise_for_status() return response.json() ``` ### Redirect URI Validation **Critical security requirement:** The authorization server must validate redirect URIs exactly. | Rule | Why | |------|-----| | Exact match required | Prevents open redirect attacks | | No wildcards in production | Attacker could register matching subdomain | | HTTPS required | Prevent code interception on HTTP | | No fragments (#) | Fragment not sent to server | | Pre-register all URIs | Only allow known, trusted redirect targets | ### State Parameter The `state` parameter prevents CSRF attacks on the OAuth2 flow: ```javascript // Before redirect: generate and store const state = crypto.randomBytes(16).toString('hex'); req.session.oauthState = state; // In callback: validate if (req.query.state !== req.session.oauthState) { throw new Error('State mismatch - possible CSRF attack'); } delete req.session.oauthState; ``` ## Client Credentials Grant Server-to-server authentication with no user context. ``` ┌──────────┐ ┌───────────────┐ │ Service │ │ Authorization │ │ Client │ │ Server │ └─────┬─────┘ └───────┬───────┘ │ │ │ POST /token │ │ grant_type=client_credentials │ │ &client_id=xxx │ │ &client_secret=yyy │ │ &scope=read:data │ │─────────────────────────────────>│ │ │ │ { access_token, expires_in } │ │<─────────────────────────────────│ ``` ```javascript // Node.js implementation async function getClientCredentialsToken(config) { const response = await fetch(config.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${Buffer.from( `${config.clientId}:${config.clientSecret}` ).toString('base64')}`, }, body: new URLSearchParams({ grant_type: 'client_credentials', scope: config.scope, }), }); const data = await response.json(); // Cache the token until near expiry // tokenCache.set(cacheKey, data.access_token, data.expires_in - 60); return data.access_token; } ``` ```python # Python implementation async def get_client_credentials_token(config: dict) -> str: async with httpx.AsyncClient() as client: response = await client.post( config["token_endpoint"], auth=(config["client_id"], config["client_secret"]), data={ "grant_type": "client_credentials", "scope": config["scope"], }, ) response.raise_for_status() data = response.json() return data["access_token"] ``` **Best practices:** - Cache tokens until near expiry (subtract 60 seconds from `expires_in`) - Use mutual TLS (mTLS) for additional security in high-trust environments - Rotate client secrets periodically ## Device Code Grant For CLI tools, smart TVs, and devices without a browser or with limited input. ``` ┌──────────┐ ┌───────────────┐ ┌──────────┐ │ Device │ │ Authorization │ │ User's │ │ (CLI/TV) │ │ Server │ │ Browser │ └─────┬─────┘ └───────┬───────┘ └────┬─────┘ │ │ │ │ POST /device/code │ │ │ client_id=xxx │ │ │ scope=profile │ │ │───────────────────────>│ │ │ │ │ │ { device_code, │ │ │ user_code: "ABCD-1234", │ │ verification_uri, │ │ │ interval: 5 } │ │ │<───────────────────────│ │ │ │ │ │ Display to user: │ │ │ "Visit https://auth.example.com/device" │ │ "Enter code: ABCD-1234"│ │ │ │ │ │ │ User visits URL │ │ │ and enters code │ │ │<──────────────────────│ │ │ │ │ │ User authenticates │ │ │ and authorizes │ │ │<──────────────────────│ │ │ │ │ Poll: POST /token │ │ │ grant_type=urn:ietf: │ │ │ params:oauth: │ │ │ grant-type:device_code │ │ device_code=xxx │ │ │───────────────────────>│ │ │ │ │ │ { access_token } │ │ │<───────────────────────│ │ ``` ```javascript // CLI implementation async function deviceCodeFlow(config) { // 1. Request device code const codeResponse = await fetch(`${config.authServer}/device/code`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: config.clientId, scope: 'openid profile', }), }); const { device_code, user_code, verification_uri, interval } = await codeResponse.json(); // 2. Display to user console.log(`Visit: ${verification_uri}`); console.log(`Enter code: ${user_code}`); // 3. Poll for completion while (true) { await new Promise((r) => setTimeout(r, interval * 1000)); const tokenResponse = await fetch(`${config.authServer}/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: config.clientId, }), }); const data = await tokenResponse.json(); if (data.error === 'authorization_pending') continue; if (data.error === 'slow_down') { interval += 5; continue; } if (data.error) throw new Error(data.error_description); return data; // { access_token, refresh_token, ... } } } ``` ## Token Exchange (RFC 8693) Allows a service to exchange one token for another, maintaining user context across microservices. ```javascript // Service A has user's token, needs to call Service B async function exchangeToken(userToken, targetAudience, config) { const response = await fetch(config.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: userToken, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', audience: targetAudience, // Service B's identifier scope: 'read:data', }), }); return response.json(); // Returns token with `act` claim showing delegation chain: // { "sub": "user_123", "act": { "sub": "service_a" } } } ``` ## OpenID Connect (OIDC) OIDC is an identity layer on top of OAuth2. While OAuth2 handles authorization (access to resources), OIDC handles authentication (who the user is). ### What OIDC Adds to OAuth2 | OAuth2 Only | OIDC Adds | |-------------|-----------| | Access token (opaque) | ID token (JWT with user info) | | Resource access | User identity | | Scopes for permissions | Standard identity scopes | | No user info standard | UserInfo endpoint | | No discovery | `.well-known/openid-configuration` | ### ID Token The ID token is a JWT containing user identity information. ```json { "iss": "https://auth.example.com", "sub": "user_abc123", "aud": "client_id_xyz", "exp": 1700001500, "iat": 1700000600, "nonce": "random_nonce_value", "auth_time": 1700000500, "name": "Alice Smith", "email": "alice@example.com", "email_verified": true, "picture": "https://example.com/alice.jpg" } ``` ### Standard OIDC Scopes | Scope | Claims Returned | |-------|----------------| | `openid` | `sub` (required scope for OIDC) | | `profile` | `name`, `family_name`, `given_name`, `picture`, `locale` | | `email` | `email`, `email_verified` | | `address` | `address` (structured object) | | `phone` | `phone_number`, `phone_number_verified` | ### Discovery Document ``` GET https://auth.example.com/.well-known/openid-configuration ``` ```json { "issuer": "https://auth.example.com", "authorization_endpoint": "https://auth.example.com/authorize", "token_endpoint": "https://auth.example.com/token", "userinfo_endpoint": "https://auth.example.com/userinfo", "jwks_uri": "https://auth.example.com/.well-known/jwks.json", "scopes_supported": ["openid", "profile", "email"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "client_credentials"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "ES256"], "code_challenge_methods_supported": ["S256"] } ``` ### UserInfo Endpoint ```javascript // Fetch additional user info const userInfo = await fetch('https://auth.example.com/userinfo', { headers: { Authorization: `Bearer ${accessToken}` }, }).then((r) => r.json()); // Response: // { // "sub": "user_abc123", // "name": "Alice Smith", // "email": "alice@example.com", // "email_verified": true, // "picture": "https://example.com/alice.jpg" // } ``` ## Provider Integration ### Auth0 ```javascript // Next.js with Auth0 SDK // npm install @auth0/nextjs-auth0 // app/api/auth/[auth0]/route.ts import { handleAuth } from '@auth0/nextjs-auth0'; export const GET = handleAuth(); // app/layout.tsx import { UserProvider } from '@auth0/nextjs-auth0/client'; export default function RootLayout({ children }) { return <UserProvider>{children}</UserProvider>; } // Protected page import { withPageAuthRequired, getSession } from '@auth0/nextjs-auth0'; export default withPageAuthRequired(async function Dashboard() { const session = await getSession(); return <div>Welcome {session.user.name}</div>; }); // API route protection import { withApiAuthRequired, getSession } from '@auth0/nextjs-auth0'; export const GET = withApiAuthRequired(async (req) => { const session = await getSession(); return Response.json({ user: session.user }); }); ``` ### Clerk ```javascript // Next.js with Clerk // npm install @clerk/nextjs // middleware.ts import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']); export default clerkMiddleware(async (auth, request) => { if (isProtectedRoute(request)) { await auth.protect(); } }); // app/layout.tsx import { ClerkProvider } from '@clerk/nextjs'; export default function RootLayout({ children }) { return <ClerkProvider>{children}</ClerkProvider>; } // Components import { SignIn, SignUp, UserButton } from '@clerk/nextjs'; // <SignIn /> - full sign-in component // <UserButton /> - user avatar with dropdown ``` ### Supabase Auth ```javascript // Supabase Auth with Row Level Security import { createClient } from '@supabase/supabase-js'; const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); // Sign up const { data, error } = await supabase.auth.signUp({ email: 'user@example.com', password: 'secure-password', }); // Sign in const { data, error } = await supabase.auth.signInWithPassword({ email: 'user@example.com', password: 'secure-password', }); // OAuth (Google) const { data, error } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: 'https://app.example.com/callback' }, }); // Get current session const { data: { session } } = await supabase.auth.getSession(); // RLS policy (in PostgreSQL) // CREATE POLICY "Users can read own data" // ON profiles FOR SELECT // USING (auth.uid() = user_id); ``` ### AWS Cognito ```javascript // AWS Cognito with Amplify import { Amplify } from 'aws-amplify'; import { signIn, signUp, getCurrentUser } from 'aws-amplify/auth'; Amplify.configure({ Auth: { Cognito: { userPoolId: 'us-east-1_xxxxx', userPoolClientId: 'xxxxx', loginWith: { oauth: { domain: 'auth.example.com', scopes: ['openid', 'profile', 'email'], redirectSignIn: ['https://app.example.com/callback'], redirectSignOut: ['https://app.example.com/'], responseType: 'code', }, }, }, }, }); const { isSignedIn } = await signIn({ username: 'user@example.com', password: 'secure-password', }); ``` ### Keycloak ```javascript // Keycloak with keycloak-js import Keycloak from 'keycloak-js'; const keycloak = new Keycloak({ url: 'https://keycloak.example.com', realm: 'my-realm', clientId: 'my-app', }); await keycloak.init({ onLoad: 'check-sso', pkceMethod: 'S256', }); if (keycloak.authenticated) { const token = keycloak.token; const userInfo = await keycloak.loadUserInfo(); } // Token refresh keycloak.onTokenExpired = () => { keycloak.updateToken(30).catch(() => keycloak.login()); }; ``` ## Social Login ### Google ```javascript // Google OAuth2 specifics const googleConfig = { authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token', scopes: 'openid email profile', // Quirks: // - Use `prompt=consent` to force consent screen (get refresh token) // - Use `access_type=offline` for refresh tokens // - Google ID tokens include `hd` (hosted domain) for Google Workspace }; ``` ### GitHub ```javascript // GitHub OAuth2 specifics const githubConfig = { authorizationEndpoint: 'https://github.com/login/oauth/authorize', tokenEndpoint: 'https://github.com/login/oauth/access_token', userEndpoint: 'https://api.github.com/user', emailEndpoint: 'https://api.github.com/user/emails', // Quirks: // - No OIDC support (no ID token) // - Must fetch user info separately // - Email may be private; use /user/emails endpoint // - Token endpoint returns form-encoded by default // (set Accept: application/json header) // - No refresh tokens (tokens don't expire unless revoked) }; ``` ### Apple ```javascript // Apple Sign In specifics const appleConfig = { authorizationEndpoint: 'https://appleid.apple.com/auth/authorize', tokenEndpoint: 'https://appleid.apple.com/auth/token', // Quirks: // - Client secret is a JWT signed with your Apple private key // - User info (name, email) only returned on FIRST sign-in // (must store it immediately) // - Users can hide email (relay address) // - Must validate ID token, Apple doesn't have UserInfo endpoint // - response_mode=form_post for web }; // Generate Apple client secret (JWT) import { SignJWT, importPKCS8 } from 'jose'; async function generateAppleClientSecret(config) { const privateKey = await importPKCS8(config.privateKey, 'ES256'); return new SignJWT({}) .setProtectedHeader({ alg: 'ES256', kid: config.keyId }) .setIssuer(config.teamId) .setSubject(config.clientId) .setAudience('https://appleid.apple.com') .setIssuedAt() .setExpirationTime('180d') .sign(privateKey); } ``` ## Scope Design ### Naming Conventions ``` # Resource-based (recommended) read:users write:users delete:users admin:users # Action-based users.read users.write users.delete # Hierarchical (coarse to fine) users # Full access to users users:read # Read-only access users:profile # Access to profile only ``` ### Scope Design Principles | Principle | Description | |-----------|-------------| | Least privilege | Request only needed scopes | | Granularity balance | Too fine = user confusion, too coarse = over-permission | | Hierarchical | Broader scope implies narrower ones | | Descriptive | Scope name should be self-explanatory | | Documented | Each scope has a user-facing description | ### Consent Management ```javascript // Scope validation middleware function requireScopes(...requiredScopes) { return (req, res, next) => { const tokenScopes = req.auth.scope?.split(' ') || []; const hasAll = requiredScopes.every((s) => tokenScopes.includes(s)); if (!hasAll) { return res.status(403).json({ error: 'insufficient_scope', required: requiredScopes, granted: tokenScopes, }); } next(); }; } // Usage app.get('/api/users', requireScopes('read:users'), getUsers); app.post('/api/users', requireScopes('write:users'), createUser); app.delete('/api/users/:id', requireScopes('delete:users'), deleteUser); ``` ## Token Lifecycle ### Token Endpoint Responses ```json // Successful token response { "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 900, "refresh_token": "dGhpcyBpcyBh...", "id_token": "eyJhbGciOi...", "scope": "openid profile email" } // Error response { "error": "invalid_grant", "error_description": "The authorization code has expired" } ``` ### Token Introspection (RFC 7662) Allows a resource server to check if a token is still valid (useful for opaque tokens). ```javascript // Resource server checks token validity async function introspectToken(token, config) { const response = await fetch(config.introspectionEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${Buffer.from( `${config.clientId}:${config.clientSecret}` ).toString('base64')}`, }, body: new URLSearchParams({ token, token_type_hint: 'access_token', }), }); const data = await response.json(); // { active: true, sub: "user_123", scope: "read:users", exp: 1700001500 } // { active: false } -- token is invalid/expired/revoked return data; } ``` ### Token Revocation (RFC 7009) ```javascript // Revoke a token (on logout) async function revokeToken(token, tokenType, config) { await fetch(config.revocationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${Buffer.from( `${config.clientId}:${config.clientSecret}` ).toString('base64')}`, }, body: new URLSearchParams({ token, token_type_hint: tokenType, // 'access_token' or 'refresh_token' }), }); // Always returns 200 (even if token was already invalid) } ``` ## Implementation Libraries ### Auth.js (NextAuth.js) ```javascript // app/api/auth/[...nextauth]/route.ts import NextAuth from 'next-auth'; import Google from 'next-auth/providers/google'; import GitHub from 'next-auth/providers/github'; import Credentials from 'next-auth/providers/credentials'; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ Google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), GitHub({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), Credentials({ credentials: { email: { label: 'Email' }, password: { label: 'Password', type: 'password' }, }, authorize: async (credentials) => { const user = await verifyCredentials( credentials.email, credentials.password ); return user || null; }, }), ], callbacks: { async jwt({ token, user, account }) { if (user) { token.role = user.role; } return token; }, async session({ session, token }) { session.user.role = token.role; return session; }, }, pages: { signIn: '/login', error: '/auth/error', }, }); ``` ### Passport.js (Express) ```javascript import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; passport.use( new GoogleStrategy( { clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: '/auth/google/callback', }, async (accessToken, refreshToken, profile, done) => { const user = await db.users.upsert({ where: { googleId: profile.id }, create: { googleId: profile.id, email: profile.emails[0].value, name: profile.displayName, }, update: { name: profile.displayName }, }); done(null, user); } ) ); // Routes app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'], })); app.get('/auth/google/callback', passport.authenticate('google', { failureRedirect: '/login' }), (req, res) => res.redirect('/dashboard') ); ``` ### Python: Authlib / python-social-auth ```python # FastAPI with Authlib from authlib.integrations.starlette_client import OAuth from starlette.config import Config oauth = OAuth() oauth.register( name="google", server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", client_id=config("GOOGLE_CLIENT_ID"), client_secret=config("GOOGLE_CLIENT_SECRET"), client_kwargs={"scope": "openid email profile"}, ) @app.get("/auth/google") async def google_login(request: Request): redirect_uri = request.url_for("google_callback") return await oauth.google.authorize_redirect(request, redirect_uri) @app.get("/auth/google/callback") async def google_callback(request: Request): token = await oauth.google.authorize_access_token(request) userinfo = token.get("userinfo") # Create or update user, establish session return RedirectResponse(url="/dashboard") ``` ### Go: golang.org/x/oauth2 ```go import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) var googleOAuthConfig = &oauth2.Config{ ClientID: os.Getenv("GOOGLE_CLIENT_ID"), ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), RedirectURL: "https://app.example.com/auth/google/callback", Scopes: []string{"openid", "profile", "email"}, Endpoint: google.Endpoint, } func handleGoogleLogin(w http.ResponseWriter, r *http.Request) { state := generateRandomState() // Store in session url := googleOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) http.Redirect(w, r, url, http.StatusTemporaryRedirect) } func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { // Validate state parameter code := r.URL.Query().Get("code") token, err := googleOAuthConfig.Exchange(r.Context(), code) if err != nil { http.Error(w, "Token exchange failed", http.StatusInternalServerError) return } // Use token to get user info client := googleOAuthConfig.Client(r.Context(), token) resp, _ := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") // Parse response, create/update user, establish session } ``` ## Deprecated: Implicit Grant The OAuth2 Implicit grant (`response_type=token`) is **deprecated** and should not be used for new applications. **Why it was deprecated:** - Access token exposed in URL fragment (browser history, referer headers) - No refresh tokens (user must re-authenticate) - No mechanism to verify the token was intended for your client - Vulnerable to token injection attacks **Migration:** Use Authorization Code + PKCE instead, with a BFF for SPAs.
-
-
scripts
-
.gitkeep 0 B · in bundle
-
-
SKILL.md 17.3 KB
--- name: auth-ops description: "Authentication and authorization patterns - JWT, OAuth2, sessions, RBAC, ABAC, passkeys, MFA, identity-aware proxies, and Better Auth. Use for: authentication, jwt, oauth2, session, login, rbac, abac, passkey, mfa, totp, api key, token, cookie, csrf, bearer token, refresh token, oidc, cloudflare access, zero trust, Cf-Access-Jwt-Assertion, AUD tag, service auth, better auth." when_to_use: "Use when implementing authentication or authorization - e.g. 'add JWT login with refresh tokens', 'set up OAuth2 + PKCE', 'RBAC vs ABAC', 'add passkeys or MFA', 'put an app behind Cloudflare Access', 'set up Better Auth'. Covers sessions, cookies, token flows, and access-control models." license: MIT allowed-tools: "Read Write Bash" metadata: author: claude-mods related-skills: security-ops, api-design-ops, postgres-ops, cloudflare-ops --- # Auth Operations Comprehensive authentication and authorization patterns for secure application development across languages and frameworks. ## Authentication Method Decision Tree Use this tree to select the right authentication strategy for your use case. ``` What are you building? │ ├─ Traditional web application (server-rendered)? │ └─ Session-based authentication │ ├─ Server stores session data (Redis/DB) │ ├─ Session ID in httpOnly cookie │ └─ Best for: monoliths, SSR apps, admin panels │ ├─ API consumed by multiple clients? │ └─ JWT (JSON Web Tokens) │ ├─ Stateless, self-contained tokens │ ├─ Access token (short-lived) + refresh token (long-lived) │ └─ Best for: microservices, mobile apps, SPAs via BFF │ ├─ Service-to-service communication? │ └─ API keys or Client Credentials (OAuth2) │ ├─ API keys: simple, scoped, rotatable │ ├─ Client Credentials: OAuth2 standard, token-based │ └─ Best for: internal services, third-party integrations │ ├─ Third-party login (Google, GitHub, etc.)? │ └─ OAuth2 / OpenID Connect │ ├─ Authorization Code + PKCE for web/mobile │ ├─ Delegate identity to trusted providers │ └─ Best for: consumer apps, social login │ ├─ Passwordless authentication? │ └─ Passkeys (WebAuthn) or Magic Links │ ├─ Passkeys: phishing-resistant, biometric/hardware │ ├─ Magic links: email-based, time-limited │ └─ Best for: high-security, modern UX │ └─ Internal tool / staff app with an existing IdP? └─ Identity-aware proxy (Cloudflare Access) ├─ Authn enforced at the edge, before your origin ├─ Origin verifies the proxy's signed JWT (never a bare header) └─ Best for: admin panels, partner portals, not consumer signup ``` ## JWT Quick Reference ### Structure ``` Header.Payload.Signature Header: { "alg": "RS256", "typ": "JWT" } Payload: { "iss": "auth.example.com", "sub": "user_123", ... } Signature: RSASHA256(base64(header) + "." + base64(payload), privateKey) ``` ### Common Claims | Claim | Name | Purpose | Example | |-------|------|---------|---------| | `iss` | Issuer | Who issued the token | `"auth.example.com"` | | `sub` | Subject | Who the token represents | `"user_123"` | | `exp` | Expiration | When the token expires | `1700000000` (Unix timestamp) | | `iat` | Issued At | When the token was created | `1699999100` | | `aud` | Audience | Intended recipient(s) | `"api.example.com"` | | `jti` | JWT ID | Unique token identifier | `"a1b2c3d4"` (for revocation) | | `nbf` | Not Before | Token not valid before this time | `1699999100` | ### Signing Algorithms | Algorithm | Type | Key | Use When | |-----------|------|-----|----------| | **RS256** | Asymmetric (RSA) | Public/private key pair | Distributed systems, multiple verifiers | | **ES256** | Asymmetric (ECDSA) | Public/private key pair | Same as RS256, smaller keys/signatures | | **HS256** | Symmetric (HMAC) | Shared secret | Single service, simple setups | **Rule of thumb:** Use asymmetric (RS256/ES256) when the token issuer and verifier are different services. Use HS256 only when a single service both creates and verifies tokens. ### Access + Refresh Token Pattern ``` ┌──────────┐ ┌──────────┐ │ Client │─── login ────────>│ Auth │ │ │<── access (15m) ──│ Server │ │ │<── refresh (7d) ──│ │ │ │ └──────────┘ │ │─── API call ─────>┌──────────┐ │ │ (access token) │ Resource │ │ │<── response ──────│ Server │ │ │ └──────────┘ │ │─── access expired │ │ │ │─── refresh ──────>│ Auth │ │ │<── new access ────│ Server │ │ │<── new refresh ───│ (rotate)│ └──────────┘ └──────────┘ ``` - **Access token:** Short-lived (5-15 minutes), used for API calls - **Refresh token:** Long-lived (7-30 days), used to get new access tokens - **Rotation:** Issue a new refresh token with each use, invalidate the old one - **Family detection:** Track refresh token lineage; if a revoked token is reused, invalidate the entire family ## OAuth2 Flow Decision Tree ``` What type of client? │ ├─ Web app with backend (Next.js, Rails, Django)? │ └─ Authorization Code + PKCE │ ├─ Redirect user to authorization server │ ├─ Receive code at callback URL │ ├─ Exchange code for tokens server-side │ └─ PKCE prevents code interception attacks │ ├─ SPA (React, Vue) without backend? │ └─ Authorization Code + PKCE (via BFF) │ ├─ Use a Backend-for-Frontend to handle tokens │ ├─ Never store tokens in browser-accessible storage │ └─ BFF proxies API calls with token attached │ ├─ Mobile app (iOS, Android)? │ └─ Authorization Code + PKCE │ ├─ Use custom URI scheme or universal links for redirect │ ├─ PKCE is mandatory (public client) │ └─ Store tokens in secure enclave/keystore │ ├─ Server-to-server (no user)? │ └─ Client Credentials │ ├─ Authenticate with client_id + client_secret │ ├─ No user context, service-level access │ └─ Token cached until expiry │ ├─ CLI tool or smart TV? │ └─ Device Code │ ├─ Display code and URL to user │ ├─ User authenticates on another device │ ├─ CLI/TV polls for completion │ └─ Good UX for input-constrained devices │ └─ Microservice acting on behalf of a user? └─ Token Exchange (RFC 8693) ├─ Exchange user's token for a scoped downstream token ├─ Maintains user context across services └─ Use `act` claim for delegation chain ``` ## Authorization Model Decision Tree ``` How complex are your access control needs? │ ├─ Simple: just "can user X do action Y"? │ └─ Permission-based (direct) │ ├─ user_permissions table │ ├─ Simple to implement, hard to scale │ └─ Good for: small apps, prototypes │ ├─ Users grouped into roles with fixed permissions? │ └─ RBAC (Role-Based Access Control) │ ├─ Roles: admin, editor, viewer │ ├─ Each role has a set of permissions │ ├─ Users assigned one or more roles │ └─ Good for: most apps, admin panels, team tools │ ├─ Decisions depend on attributes (time, location, resource owner)? │ └─ ABAC (Attribute-Based Access Control) │ ├─ Policies evaluate subject + resource + environment attributes │ ├─ "Allow if user.department == resource.department AND time < 17:00" │ ├─ Flexible but complex │ └─ Good for: enterprise, compliance-heavy, context-dependent access │ └─ Access based on relationships (owner, parent, shared with)? └─ ReBAC (Relationship-Based Access Control) ├─ Google Zanzibar model ├─ Tuples: user:alice#viewer@document:report ├─ Supports inheritance: folder viewer → document viewer ├─ Tools: OpenFGA, SpiceDB, Ory Keto └─ Good for: file sharing, nested resources, social features ``` ## Session Management Quick Reference ### Cookie Security Settings | Setting | Value | Purpose | |---------|-------|---------| | `SameSite` | `Strict` | Cookie sent only for same-site requests (best CSRF protection) | | `SameSite` | `Lax` | Cookie sent for top-level navigations (good default) | | `SameSite` | `None` | Cookie sent for cross-site requests (requires `Secure`) | | `Secure` | `true` | Cookie only sent over HTTPS | | `HttpOnly` | `true` | Cookie not accessible via JavaScript (prevents XSS theft) | | `__Host-` prefix | N/A | Requires Secure, no Domain, Path=/ (strictest) | | `__Secure-` prefix | N/A | Requires Secure flag | | `Max-Age` | seconds | Cookie lifetime (prefer over `Expires`) | | `Path` | `/` | Scope cookie to path (usually `/`) | ### Recommended Cookie Configuration ``` Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Max-Age=86400; Path=/ ``` ### Session Expiry Strategies | Strategy | Typical Value | Notes | |----------|---------------|-------| | **Idle timeout** | 15-30 minutes | Reset on each request | | **Absolute timeout** | 8-24 hours | Force re-authentication | | **Sliding window** | 30 min idle, 8h max | Best balance | | **Remember me** | 30 days | Extended session, reduced privileges | ## Password Handling Quick Reference ### Hashing Algorithms | Algorithm | Verdict | Notes | |-----------|---------|-------| | **argon2id** | BEST | Memory-hard, resists GPU attacks, recommended by OWASP | | **bcrypt** | GOOD | Battle-tested, cost factor 12+, 72-byte input limit | | **scrypt** | GOOD | Memory-hard, less common library support | | **PBKDF2** | ACCEPTABLE | FIPS compliant, use 600k+ iterations with SHA-256 | | **SHA-256/512** | BAD | Too fast, no salt built-in, easily brute-forced | | **MD5** | NEVER | Broken, rainbow tables widely available | ### Password Rules (NIST 800-63B) | Rule | Guidance | |------|----------| | Minimum length | 8 characters (12+ recommended) | | Maximum length | At least 64 characters | | Complexity rules | Do NOT require special chars/uppercase/numbers | | Breached password check | Check against known breached passwords (HaveIBeenPwned API) | | Password hints | Do NOT allow | | Forced rotation | Do NOT force periodic changes (only on breach) | | Paste into password field | ALLOW (supports password managers) | ### Rate Limiting Login Attempts | Attempt | Response | |---------|----------| | 1-5 | Normal login | | 6-10 | CAPTCHA required | | 11-20 | Progressive delays (2s, 4s, 8s...) | | 20+ | Temporary account lockout (15-30 min) | **Important:** Use consistent response times for both success and failure to prevent timing-based username enumeration. ## MFA Quick Reference ### Methods Ranked by Security | Method | Security | UX | Notes | |--------|----------|----|-------| | **WebAuthn/Passkeys** | Highest | Good | Phishing-resistant, hardware-backed | | **TOTP (Authenticator)** | High | Medium | App-based (Google/Microsoft Authenticator) | | **Push notifications** | High | Good | Requires mobile app | | **Email OTP** | Medium | Medium | Depends on email security | | **SMS OTP** | Low | Easy | SIM swap vulnerable, use as fallback only | ### TOTP Implementation Checklist - [ ] Generate 160-bit secret (base32 encoded) - [ ] Build otpauth:// URI with issuer and account - [ ] Display QR code for authenticator scanning - [ ] Require verification of first code before enabling - [ ] Accept current window +/- 1 (30-second steps) - [ ] Generate 8-10 single-use backup codes - [ ] Hash backup codes before storing - [ ] Allow recovery via verified identity ### Passkey/WebAuthn Checklist - [ ] Generate cryptographic challenge on server - [ ] Set relying party ID (your domain) - [ ] Store credential public key and ID - [ ] Verify signature on authentication - [ ] Support multiple credentials per user - [ ] Handle platform vs cross-platform authenticators - [ ] Provide fallback auth method ## Identity-Aware Proxy Quick Reference When authn is delegated to a proxy edge (Cloudflare Access, Google IAP, oauth2-proxy), two invariants carry the whole model: 1. **Verify the assertion.** The proxy's identity header is a signed JWT — verify signature + issuer + per-application audience against the proxy's JWKS on every request. Never trust the plain email convenience headers. 2. **Close every path around the proxy.** The header is only meaningful if the proxy is the *only* way to reach the origin (`workers_dev = false`, firewalled origin, or tunnel). An open origin makes any header forgeable. ``` Proxy edge (authn) ──JWT header──> Origin verifies JWT ──> app user lookup ──> role/scope binding │ │ 403 on any failure │ 403 if no row (server-side) └ IdP / OTP login, sessions └ cached JWKS, └ proxy admits ≠ app authorizes rate limits, bot defense refetch on unknown kid ``` Machine routes (webhooks, ingest) get Service-Auth/Bypass at the edge + bearer keys at the origin, mounted outside the human-auth middleware. Full treatment: `references/cloudflare-access.md`. ## Common Gotchas | Gotcha | Why It's Dangerous | Fix | |--------|--------------------|-----| | JWT stored in localStorage | XSS can steal tokens, no expiry enforcement by browser | Use httpOnly cookies or BFF pattern | | Missing PKCE in OAuth2 | Authorization code interception attacks possible | Always use PKCE, even for confidential clients | | Role explosion in RBAC | Hundreds of roles become unmanageable | Move to ABAC or ReBAC for complex scenarios | | String comparison for tokens | Timing attacks reveal token value character by character | Use constant-time comparison (`crypto.timingSafeEqual`) | | No token revocation strategy | Cannot invalidate compromised JWTs before expiry | Short expiry + refresh tokens, or maintain a blocklist | | CORS with `credentials: true` | `Access-Control-Allow-Origin: *` does not work with credentials | Specify exact origin, set `Access-Control-Allow-Credentials: true` | | `SameSite=None` without `Secure` | Browser silently rejects the cookie | Always pair `SameSite=None` with `Secure` flag | | Refresh token reuse without detection | Stolen refresh tokens grant indefinite access | Rotate refresh tokens, detect reuse (token families) | | Using OAuth2 Implicit grant | Tokens exposed in URL fragment, no refresh tokens | Use Authorization Code + PKCE instead (Implicit is deprecated) | | Password in URL or logs | URLs are logged by proxies, browsers, and servers | Always send credentials in request body or headers | | Missing CSRF protection with cookies | Cookie-based auth is vulnerable to cross-site request forgery | Use SameSite cookies + CSRF tokens for state-changing ops | | Long-lived access tokens (hours/days) | Large attack window if token is compromised | Keep access tokens to 5-15 minutes, use refresh tokens | | Storing API keys in plaintext | Database breach exposes all keys | Hash stored keys (SHA-256 of key), store prefix for lookup | | Not validating JWT `aud` claim | Token meant for Service A accepted by Service B | Always validate `aud` matches your service identifier | | Session fixation | Attacker sets session ID before login, then hijacks it | Regenerate session ID after authentication | | Hardcoded secrets in code | Secrets leak via source control | Use environment variables or secret managers (Vault, AWS SSM) | | Trusting an identity-aware proxy's plain email header | Headers are attacker-settable on any unproxied path | Verify the proxy's signed JWT (sig + issuer + audience); close every path around the proxy | | Auth-library middleware as the only session check | Framework middleware can be bypassed (Next.js CVE-2025-29927 class) | Re-check the session in the data-access layer / route handlers | ## Reference Files | File | Contents | Lines | |------|----------|-------| | `references/jwt-sessions.md` | JWT structure, signing, sessions, cookies, CSRF, storage | ~650 | | `references/oauth2-oidc.md` | OAuth2 flows, OIDC, provider integration, social login | ~700 | | `references/authorization.md` | RBAC, ABAC, ReBAC, RLS, multi-tenant, audit logging | ~600 | | `references/implementation.md` | Password hashing, MFA, rate limiting, API keys, reset flows | ~550 | | `references/cloudflare-access.md` | Identity-aware proxies via Cloudflare Access: app/policy anatomy, token claims, JWT verification, closed-origin precondition, service auth, sessions/logout/SPA, local dev | ~330 | | `references/better-auth.md` | Better Auth library: server/client setup, adapters, session model, social login, plugin catalog (passkey/2FA/org/SSO), Hono integration, migration | ~240 | ## See Also - **security-ops** - Broader security patterns: OWASP, headers, input validation, encryption - **api-design-ops** - API design including authentication endpoints, rate limiting - **postgres-ops** - Row-level security (RLS) policies for database authorization - **cloudflare-ops** - Workers runtime, wrangler config, secrets, deploy mechanics behind an Access-fronted origin
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.