
Securing Authentication
- 76 installs
- 426 repo stars
- Updated December 11, 2025
- ancoleman/ai-design-components
Securing-authentication is a Claude skill that implements authentication, authorization and API security using OAuth 2.1/OIDC, JWT, Passkeys/WebAuthn and RBAC/ABAC/ReBAC.
About
This skill implements authentication, authorization and API security. Developers use it when building login and SSO systems, protecting APIs, or adding fine-grained access control. It covers OAuth 2.1/OIDC, JWT patterns, Passkeys/WebAuthn, password hashing, and RBAC/ABAC/ReBAC with managed and self-hosted options.
- OAuth 2.1 mandatory requirements and JWT best practices
- Passkeys/WebAuthn and Argon2id password hashing
- RBAC/ABAC/ReBAC with OPA, Casbin and SpiceDB
Securing Authentication by the numbers
- 76 all-time installs (skills.sh)
- Ranked #1,138 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
securing-authentication capabilities & compatibility
- Capabilities
- oauth flow · jwt validation · passkeys webauthn · rbac authorization · api security
- Works with
- github
- Use cases
- security audit · api development
- Pricing
- Free
What securing-authentication says it does
Authentication, authorization, and API security implementation. Use when building user systems, protecting APIs, or implementing access control.
PKCE is now mandatory for ALL OAuth flows, not just public clients.
npx skills add https://github.com/ancoleman/ai-design-components --skill securing-authenticationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 76 |
|---|---|
| repo stars | ★ 426 |
| Last updated | December 11, 2025 |
| Repository | ancoleman/ai-design-components ↗ |
What it does
Implement authentication, authorization and API security with OAuth 2.1, JWT, Passkeys and RBAC/ABAC/ReBAC.
Who is it for?
Building user auth, SSO and fine-grained authorization for APIs and apps.
Skip if: Static sites or projects without user accounts or protected APIs.
When should I use this skill?
Building user systems, protecting APIs, or implementing access control.
What you get
Standards-compliant OAuth 2.1 auth with secure JWT handling and appropriate authorization model.
- OAuth 2.1 / OIDC flows
- JWT validation and rotation
- RBAC/ABAC/ReBAC authorization
By the numbers
- Access token lifetime 5-15 minutes
- Argon2id 64 MB memory cost, 3 iterations, 4 threads
Files
Authentication & Security
Implement modern authentication, authorization, and API security across Python, Rust, Go, and TypeScript.
When to Use This Skill
Use this skill when:
- Building user authentication systems (login, signup, SSO)
- Implementing authorization (roles, permissions, access control)
- Securing APIs (JWT validation, rate limiting)
- Adding passwordless auth (Passkeys/WebAuthn)
- Migrating from password-based to modern auth
- Integrating enterprise SSO (SAML, OIDC)
- Implementing fine-grained permissions (RBAC, ABAC, ReBAC)
OAuth 2.1 Mandatory Requirements (2025 Standard)
┌─────────────────────────────────────────────────────────────┐
│ OAuth 2.1 MANDATORY REQUIREMENTS │
│ (RFC 9798 - 2025) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ REQUIRED (Breaking Changes from OAuth 2.0) │
│ ├─ PKCE (Proof Key for Code Exchange) MANDATORY │
│ │ └─ S256 method (SHA-256), minimum entropy 43 chars │
│ ├─ Exact redirect URI matching │
│ │ └─ No wildcard matching, no substring matching │
│ ├─ Authorization code flow ONLY for public clients │
│ │ └─ All other flows require confidential client │
│ └─ TLS 1.2+ required for all endpoints │
│ │
│ ❌ REMOVED (No Longer Supported) │
│ ├─ Implicit grant (security vulnerabilities) │
│ ├─ Resource Owner Password Credentials grant │
│ │ └─ Use OAuth 2.0 Device Flow (RFC 8628) instead │
│ └─ Bearer token in query parameters │
│ └─ Must use Authorization header or POST body │
│ │
└─────────────────────────────────────────────────────────────┘Critical: PKCE is now mandatory for ALL OAuth flows, not just public clients.
JWT Best Practices
Signing Algorithms (Priority Order)
1. EdDSA with Ed25519 (Recommended)
- Fastest performance
- Smallest signature size
- Modern cryptography
2. ES256 (ECDSA with P-256)
- Good performance
- Industry standard
- Wide compatibility
3. RS256 (RSA)
- Legacy compatibility
- Larger signatures
- Slower performance
NEVER allow `alg: none` or algorithm switching attacks.
Token Lifetimes (Concrete Values)
- Access token: 5-15 minutes
- Refresh token: 1-7 days with rotation
- ID token: Same as access token (5-15 minutes)
Refresh token rotation: Each refresh generates new access AND refresh tokens, invalidating the old refresh token.
Token Storage
- Access token: Memory only (never localStorage)
- Refresh token: HTTP-only cookie + SameSite=Strict
- CSRF token: Separate non-HTTP-only cookie
- Never log tokens: Redact in application logs
JWT Claims (Required)
{
"iss": "https://auth.example.com",
"sub": "user-id-123",
"aud": "api.example.com",
"exp": 1234567890,
"iat": 1234567890,
"jti": "unique-token-id",
"scope": "read:profile write:data"
}Password Hashing with Argon2id
OWASP 2025 Parameters
Algorithm: Argon2id
Memory cost (m): 64 MB (65536 KiB)
Time cost (t): 3 iterations
Parallelism (p): 4 threads
Salt length: 16 bytes (128 bits)
Target hash time: 150-250msImplementation
For concrete implementations, see references/password-hashing.md.
Key Points:
- Argon2id is hybrid: data-independent timing + memory-hard
- Tune memory cost to achieve 150-250ms on YOUR hardware
- Use timing-safe comparison for verification
- Migrate from bcrypt gradually (verify with old, rehash with new)
Passkeys / WebAuthn
Passkeys provide phishing-resistant, passwordless authentication using FIDO2/WebAuthn.
When to Use Passkeys
- User-facing applications prioritizing security
- Reducing password-related support burden
- Mobile-first applications (biometric auth)
- Applications requiring MFA without SMS
Cross-Device Passkey Sync
- iCloud Keychain: Apple ecosystem (iOS 16+, macOS 13+)
- Google Password Manager: Android, Chrome
- 1Password, Bitwarden: Third-party password managers
For implementation guide, see references/passkeys-webauthn.md.
Authorization Models
┌─────────────────────────────────────────────────────────────┐
│ Authorization Model Selection │
├─────────────────────────────────────────────────────────────┤
│ │
│ Simple Roles (<20 roles) │
│ └─ RBAC with Casbin (embedded, any language) │
│ Example: Admin, User, Guest │
│ │
│ Complex Attribute Rules │
│ └─ ABAC with OPA or Cerbos │
│ Example: "Allow if user.clearance >= doc.level │
│ AND user.dept == doc.dept" │
│ │
│ Relationship-Based (Multi-Tenant, Collaborative) │
│ └─ ReBAC with SpiceDB (Zanzibar model) │
│ Example: "Can edit if member of doc's workspace │
│ AND workspace.plan includes feature" │
│ Use cases: Notion-like, GitHub-like permissions │
│ │
│ Kubernetes / Infrastructure Policies │
│ └─ OPA (Gatekeeper for admission control) │
│ Example: Enforce pod security policies │
│ │
└─────────────────────────────────────────────────────────────┘For detailed comparison, see references/authorization-patterns.md.
Library Selection by Language
TypeScript
| Use Case | Library | Context7 ID | Trust | Notes |
|---|---|---|---|---|
| Auth Framework | Auth.js v5 | /websites/authjs_dev | 87.4 | Multi-framework (Next, Svelte, Solid) |
| JWT | jose 5.x | - | - | EdDSA, ES256, RS256 support |
| Passkeys | @simplewebauthn/server 11.x | - | - | FIDO2 server |
| Validation | Zod 3.x | /colinhacks/zod | 90.4 | Schema validation |
| Policy Engine | Casbin.js 1.x | - | - | RBAC/ABAC embedded |
Python
| Use Case | Library | Notes |
|---|---|---|
| Auth Framework | Authlib 1.3+ | OAuth/OIDC client + server |
| JWT | joserfc 1.x | Modern, maintained |
| Passkeys | py_webauthn 2.x | WebAuthn server |
| Password Hashing | argon2-cffi 24.x | OWASP parameters |
| Validation | Pydantic 2.x | FastAPI integration |
| Policy Engine | PyCasbin 1.x | RBAC/ABAC embedded |
Rust
| Use Case | Library | Notes |
|---|---|---|
| JWT | jsonwebtoken 10.x | EdDSA, ES256, RS256 |
| OAuth Client | oauth2 5.x | OAuth 2.1 flows |
| Passkeys | webauthn-rs 0.5.x | WebAuthn + attestation |
| Password Hashing | argon2 0.5.x | Native Argon2id |
| Policy Engine | Casbin-RS 2.x | RBAC/ABAC embedded |
Go
| Use Case | Library | Notes |
|---|---|---|
| JWT | golang-jwt v5 | Community-maintained |
| OAuth Client | go-oidc v3 | OIDC client only |
| Passkeys | go-webauthn 0.11.x | Duo-maintained |
| Password Hashing | golang.org/x/crypto/argon2 | Standard library |
| Policy Engine | Casbin v2 | Original implementation |
Managed Auth Services
| Service | Best For | Key Features |
|---|---|---|
| Clerk | Rapid development, startups | Prebuilt UI, Next.js SDK |
| Auth0 | Enterprise, established | 25+ social providers, SSO |
| WorkOS AuthKit | B2B SaaS, enterprise SSO | SAML/SCIM, admin portal |
| Supabase Auth | Postgres users | Built on Postgres, RLS |
For detailed comparison, see references/managed-auth-comparison.md.
Self-Hosted Solutions
| Solution | Language | Use Case |
|---|---|---|
| Keycloak | Java | Enterprise, on-prem |
| Ory | Go | Cloud-native, microservices |
| Authentik | Python | Modern, developer-friendly |
For setup guides, see references/self-hosted-auth.md.
API Security Best Practices
Rate Limiting
// Tiered rate limiting (per IP + per user)
const rateLimits = {
anonymous: '10 requests/minute',
authenticated: '100 requests/minute',
premium: '1000 requests/minute',
}Use sliding window algorithm (not fixed window) with Redis.
CORS Configuration
// Restrictive CORS (production)
const corsOptions = {
origin: ['https://app.example.com'],
credentials: true,
maxAge: 86400, // 24 hours
allowedHeaders: ['Content-Type', 'Authorization'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
}
// NEVER use origin: '*' with credentials: trueSecurity Headers
const securityHeaders = {
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'geolocation=(), microphone=(), camera=()',
'Content-Security-Policy': "default-src 'self'; script-src 'self'",
}For complete API security guide, see references/api-security.md.
Frontend Integration Patterns
Protected Routes (Next.js)
// middleware.ts
import { withAuth } from 'next-auth/middleware'
export default withAuth({
callbacks: {
authorized: ({ token, req }) => {
if (req.nextUrl.pathname.startsWith('/dashboard')) {
return !!token
}
if (req.nextUrl.pathname.startsWith('/admin')) {
return token?.role === 'admin'
}
return true
},
},
})
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}Role-Based UI Rendering
import { useSession } from 'next-auth/react'
export function AdminPanel() {
const { data: session } = useSession()
if (session?.user?.role !== 'admin') {
return null
}
return <div>Admin Controls</div>
}Common Workflows
1. OAuth 2.1 Integration
1. Generate PKCE challenge 2. Redirect to authorization endpoint 3. Handle callback with authorization code 4. Exchange code for tokens (with code_verifier) 5. Store tokens securely 6. Implement refresh token rotation
See references/oauth21-guide.md for complete implementation.
2. JWT Implementation
1. Generate signing keys using scripts/generate_jwt_keys.py 2. Configure token lifetimes (5-15 min access, 1-7 day refresh) 3. Implement token validation middleware 4. Set up refresh token rotation 5. Configure token storage (memory for access, HTTP-only cookie for refresh)
See references/jwt-best-practices.md for detailed patterns.
3. Passkeys Setup
1. Register credential during signup/settings 2. Generate challenge for registration 3. Verify attestation 4. Store credential ID and public key 5. Implement authentication flow with assertion
See examples/passkeys-demo/ for runnable implementation.
4. Authorization Engine Setup
1. Choose engine (Casbin for simple RBAC, SpiceDB for ReBAC) 2. Define schema/policies 3. Implement check functions 4. Integrate with route handlers 5. Add audit logging
See references/authorization-patterns.md for detailed comparison.
Integration with Other Skills
Forms Skill
- Login/register forms with validation
- Error states for auth failures
- Password strength indicators
- Email validation
API Patterns Skill
- JWT middleware integration
- Error response formats (401, 403)
- OpenAPI security schemas
- CORS configuration
Dashboards Skill
- Role-based widget visibility
- User profile display
- Permission-based data filtering
- Audit trail visualization
Observability Skill
- Auth event logging (login, logout, permission denied)
- Failed login tracking
- Token refresh monitoring
- Security incident alerting
Scripts
Generate JWT Keys
python scripts/generate_jwt_keys.py --algorithm EdDSAGenerates EdDSA or ES256 key pairs for JWT signing.
Validate OAuth 2.1 Configuration
python scripts/validate_oauth_config.py --config oauth.jsonValidates OAuth 2.1 compliance (PKCE enabled, exact redirect URIs, etc.).
Examples
Auth.js + Next.js
Complete implementation with OAuth providers, credentials, and session management.
Location: examples/authjs-nextjs/
Keycloak + FastAPI
Self-hosted Keycloak with FastAPI integration via OIDC.
Location: examples/keycloak-fastapi/
Passkeys Demo
Runnable passkeys implementation with @simplewebauthn.
Location: examples/passkeys-demo/
Reference Documentation
references/oauth21-guide.md- OAuth 2.1 implementation guidereferences/jwt-best-practices.md- JWT generation, validation, storagereferences/passkeys-webauthn.md- Passkeys/WebAuthn implementationreferences/authorization-patterns.md- RBAC, ABAC, ReBAC comparisonreferences/password-hashing.md- Argon2id parameters, migration
Auth.js + Next.js Example
Complete authentication implementation using Auth.js v5 with Next.js 15.
Features
- OAuth 2.1 providers (Google, GitHub)
- Credentials authentication (email + password)
- JWT sessions with EdDSA signing
- Protected routes via middleware
- Role-based access control
- Refresh token rotation
Setup
npm install next@15 next-auth@beta jose zod @node-rs/argon2Environment Variables
Create .env.local:
# Auth.js
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-secret-key-generate-with-openssl-rand-base64-32
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# GitHub OAuth
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/auth_demoFile Structure
authjs-nextjs/
├── app/
│ ├── api/
│ │ └── auth/
│ │ └── [...nextauth]/
│ │ └── route.ts # Auth.js configuration
│ ├── login/
│ │ └── page.tsx # Login page
│ ├── dashboard/
│ │ └── page.tsx # Protected dashboard
│ └── layout.tsx # Root layout
├── lib/
│ ├── auth.ts # Auth.js setup
│ ├── db.ts # Database client
│ └── password.ts # Password hashing
├── middleware.ts # Route protection
└── types/
└── next-auth.d.ts # TypeScript typesImplementation
1. Auth.js Configuration
app/api/auth/[...nextauth]/route.ts:
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import GitHubProvider from 'next-auth/providers/github'
import CredentialsProvider from 'next-auth/providers/credentials'
import { z } from 'zod'
import { hash, verify } from '@node-rs/argon2'
import { db } from '@/lib/db'
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
// OAuth 2.1 Providers (PKCE automatic)
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
prompt: 'consent',
access_type: 'offline',
response_type: 'code',
},
},
}),
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// Credentials Provider
CredentialsProvider({
id: 'credentials',
name: 'Email and Password',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const result = LoginSchema.safeParse(credentials)
if (!result.success) {
throw new Error('Invalid credentials')
}
const { email, password } = result.data
// Find user
const user = await db.user.findUnique({ where: { email } })
if (!user?.passwordHash) {
throw new Error('Invalid credentials')
}
// Verify password (Argon2id, timing-safe)
const isValid = await verify(user.passwordHash, password)
if (!isValid) {
throw new Error('Invalid credentials')
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
},
}),
],
session: {
strategy: 'jwt',
maxAge: 7 * 24 * 60 * 60, // 7 days
},
callbacks: {
async jwt({ token, user, account, trigger }) {
// Initial sign in
if (user) {
token.id = user.id
token.role = user.role
}
// OAuth tokens
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
token.accessTokenExpires = account.expires_at
}
// Refresh access token if expired
if (trigger === 'update' && token.accessTokenExpires) {
if (Date.now() < token.accessTokenExpires * 1000) {
return token
}
return refreshAccessToken(token)
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
},
async authorized({ auth, request }) {
const { pathname } = request.nextUrl
// Public routes
if (pathname === '/login' || pathname === '/') {
return true
}
// Protected routes
if (pathname.startsWith('/dashboard')) {
return !!auth?.user
}
// Admin routes
if (pathname.startsWith('/admin')) {
return auth?.user?.role === 'admin'
}
return true
},
},
pages: {
signIn: '/login',
error: '/login',
},
})
async function refreshAccessToken(token: any) {
try {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}),
})
const refreshedTokens = await response.json()
if (!response.ok) {
throw refreshedTokens
}
return {
...token,
accessToken: refreshedTokens.access_token,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
}
} catch (error) {
console.error('Error refreshing access token', error)
return { ...token, error: 'RefreshAccessTokenError' }
}
}
export const { GET, POST } = handlers2. Login Page
app/login/page.tsx:
'use client'
import { signIn } from 'next-auth/react'
import { useState } from 'react'
import { z } from 'zod'
const LoginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be 8+ characters'),
})
export default function LoginPage() {
const [error, setError] = useState('')
async function handleCredentialsLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const result = LoginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
})
if (!result.success) {
setError(result.error.errors[0].message)
return
}
const response = await signIn('credentials', {
email: result.data.email,
password: result.data.password,
redirect: false,
})
if (response?.error) {
setError('Invalid credentials')
} else {
window.location.href = '/dashboard'
}
}
async function handleOAuthLogin(provider: 'google' | 'github') {
await signIn(provider, { callbackUrl: '/dashboard' })
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md w-full space-y-8">
<h2 className="text-3xl font-bold text-center">Sign In</h2>
{/* OAuth Providers */}
<div className="space-y-3">
<button
onClick={() => handleOAuthLogin('google')}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded-lg hover:bg-gray-50"
>
Continue with Google
</button>
<button
onClick={() => handleOAuthLogin('github')}
className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded-lg hover:bg-gray-50"
>
Continue with GitHub
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
{/* Credentials Login */}
<form onSubmit={handleCredentialsLogin} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
>
Sign In
</button>
</form>
</div>
</div>
)
}3. Protected Dashboard
app/dashboard/page.tsx:
import { auth } from '@/app/api/auth/[...nextauth]/route'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<div className="min-h-screen p-8">
<h1 className="text-3xl font-bold mb-4">Dashboard</h1>
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Session Information</h2>
<dl className="space-y-2">
<div>
<dt className="font-medium text-gray-700">Email:</dt>
<dd className="text-gray-900">{session.user?.email}</dd>
</div>
<div>
<dt className="font-medium text-gray-700">Name:</dt>
<dd className="text-gray-900">{session.user?.name}</dd>
</div>
<div>
<dt className="font-medium text-gray-700">Role:</dt>
<dd className="text-gray-900">{session.user?.role}</dd>
</div>
</dl>
</div>
</div>
)
}4. Middleware (Route Protection)
middleware.ts:
import { auth } from '@/app/api/auth/[...nextauth]/route'
export default auth((req) => {
const { pathname } = req.nextUrl
// Redirect authenticated users away from login
if (pathname === '/login' && req.auth) {
return Response.redirect(new URL('/dashboard', req.url))
}
// Protect dashboard routes
if (pathname.startsWith('/dashboard') && !req.auth) {
return Response.redirect(new URL('/login', req.url))
}
// Protect admin routes
if (pathname.startsWith('/admin') && req.auth?.user?.role !== 'admin') {
return Response.redirect(new URL('/dashboard', req.url))
}
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}Database Schema (Prisma)
prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String?
role String @default("user")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
}Running the Example
1. Install dependencies:
npm install2. Set up database:
npx prisma migrate dev3. Run development server:
npm run dev4. Test:
- Visit http://localhost:3000/login
- Sign in with Google/GitHub or credentials
- Access /dashboard (protected route)
Security Features
- OAuth 2.1 with PKCE (automatic in Auth.js)
- Argon2id password hashing (OWASP 2025 parameters)
- JWT sessions with EdDSA signing
- HTTP-only cookies for session storage
- CSRF protection (built-in)
- Role-based access control
- Refresh token rotation
Production Checklist
- [ ] Set strong NEXTAUTH_SECRET
- [ ] Configure OAuth redirect URIs in providers
- [ ] Enable HTTPS
- [ ] Set up rate limiting on login endpoint
- [ ] Implement password reset flow
- [ ] Add email verification
- [ ] Set up monitoring and alerting
- [ ] Configure session timeout
- [ ] Add audit logging
skill: "securing-authentication"
version: "1.0"
domain: "backend"
base_outputs:
- path: "src/auth/config.{ts,py,rs,go}"
must_contain:
- "OAuth 2.1"
- "PKCE"
- "token lifetime"
- "refresh token rotation"
description: "Core authentication configuration with OAuth 2.1 compliance, PKCE enabled, token lifetimes (5-15min access, 1-7day refresh), and refresh token rotation"
- path: "src/middleware/auth.{ts,py,rs,go}"
must_contain:
- "JWT validation"
- "Bearer token"
- "Authorization header"
description: "Authentication middleware for JWT validation and bearer token extraction from Authorization headers"
- path: "src/middleware/security-headers.{ts,py,rs,go}"
must_contain:
- "Strict-Transport-Security"
- "X-Frame-Options"
- "X-Content-Type-Options"
- "Content-Security-Policy"
description: "Security headers middleware with HSTS, frame options, content type options, and CSP"
- path: "src/utils/jwt.{ts,py,rs,go}"
must_contain:
- "EdDSA"
- "ES256"
- "token generation"
- "token validation"
- "claims validation"
description: "JWT utilities for token generation/validation using EdDSA (preferred) or ES256, with required claims (iss, sub, aud, exp, iat, jti)"
conditional_outputs:
maturity:
starter:
- path: "src/auth/handlers/login.{ts,py,rs,go}"
must_contain:
- "password verification"
- "token generation"
- "rate limiting"
description: "Basic login handler with password verification, JWT token generation, and rate limiting"
- path: "src/auth/handlers/register.{ts,py,rs,go}"
must_contain:
- "Argon2id"
- "password hashing"
- "validation"
description: "User registration with Argon2id password hashing (64MB memory, 3 iterations, 4 threads)"
- path: "config/cors.{ts,py,rs,go,json}"
must_contain:
- "origin"
- "credentials"
- "allowedHeaders"
description: "CORS configuration with restrictive origins, credentials handling, and allowed headers"
intermediate:
- path: "src/auth/oauth/pkce.{ts,py,rs,go}"
must_contain:
- "code_verifier"
- "code_challenge"
- "S256"
description: "PKCE implementation with S256 challenge method and minimum 43-character entropy"
- path: "src/auth/oauth/callback.{ts,py,rs,go}"
must_contain:
- "authorization code"
- "code exchange"
- "exact redirect URI"
description: "OAuth 2.1 callback handler with authorization code exchange and exact redirect URI matching"
- path: "src/middleware/rate-limit.{ts,py,rs,go}"
must_contain:
- "sliding window"
- "per IP"
- "per user"
- "Redis"
description: "Rate limiting middleware using sliding window algorithm with tiered limits (10/min anonymous, 100/min authenticated)"
- path: "src/auth/rbac/policies.{json,yaml,conf}"
must_contain:
- "roles"
- "permissions"
- "resources"
description: "RBAC policy definitions with roles, permissions, and resource mappings"
- path: "src/auth/rbac/enforcer.{ts,py,rs,go}"
must_contain:
- "Casbin"
- "policy enforcement"
- "permission check"
description: "Authorization enforcement using Casbin for RBAC/ABAC policy checks"
advanced:
- path: "src/auth/passkeys/registration.{ts,py,rs,go}"
must_contain:
- "WebAuthn"
- "challenge generation"
- "attestation verification"
- "credential storage"
description: "Passkey registration flow with WebAuthn challenge generation and attestation verification"
- path: "src/auth/passkeys/authentication.{ts,py,rs,go}"
must_contain:
- "WebAuthn"
- "assertion verification"
- "credential lookup"
description: "Passkey authentication with WebAuthn assertion verification and credential validation"
- path: "src/auth/mfa/totp.{ts,py,rs,go}"
must_contain:
- "TOTP"
- "secret generation"
- "token verification"
- "backup codes"
description: "Multi-factor authentication with TOTP implementation and backup code generation"
- path: "src/auth/session/store.{ts,py,rs,go}"
must_contain:
- "session storage"
- "Redis"
- "session expiration"
- "concurrent session handling"
description: "Session store implementation with Redis backend, expiration handling, and concurrent session management"
- path: "src/audit/auth-events.{ts,py,rs,go}"
must_contain:
- "login events"
- "permission denied"
- "failed authentication"
- "audit logging"
description: "Authentication audit logging for security events (login, logout, failed auth, permission denied)"
auth:
jwt:
- path: "keys/jwt/{private,public}.pem"
must_contain:
- "BEGIN"
- "KEY"
description: "JWT signing keys (EdDSA or ES256 key pairs generated via scripts/generate_jwt_keys.py)"
- path: "src/auth/jwt/middleware.{ts,py,rs,go}"
must_contain:
- "token extraction"
- "signature verification"
- "claims validation"
- "exp check"
description: "JWT middleware with token extraction, signature verification, and claims validation (iss, sub, aud, exp, iat, jti)"
- path: "src/auth/jwt/refresh.{ts,py,rs,go}"
must_contain:
- "refresh token rotation"
- "invalidate old token"
- "generate new tokens"
description: "Refresh token rotation handler that generates new access and refresh tokens while invalidating old refresh token"
oauth:
- path: "src/auth/oauth/providers/{google,github,azure}.{ts,py,rs,go}"
must_contain:
- "client_id"
- "authorization_endpoint"
- "token_endpoint"
- "PKCE"
description: "OAuth 2.1 provider configurations with PKCE-enabled authorization and token endpoints"
- path: "src/auth/oauth/token-storage.{ts,py,rs,go}"
must_contain:
- "HTTP-only cookie"
- "SameSite=Strict"
- "secure flag"
description: "OAuth token storage using HTTP-only cookies with SameSite=Strict and secure flags"
session:
- path: "src/auth/session/manager.{ts,py,rs,go}"
must_contain:
- "session creation"
- "session validation"
- "session destruction"
- "CSRF protection"
description: "Session manager with creation, validation, destruction, and CSRF protection"
- path: "src/auth/session/cookie-config.{ts,py,rs,go,json}"
must_contain:
- "httpOnly"
- "secure"
- "sameSite"
- "maxAge"
description: "Session cookie configuration with httpOnly, secure, sameSite=Strict, and appropriate maxAge"
backend_framework:
nextjs:
- path: "middleware.ts"
must_contain:
- "withAuth"
- "authorized callback"
- "matcher"
description: "Next.js middleware for protected routes with role-based authorization"
- path: "app/api/auth/[...nextauth]/route.ts"
must_contain:
- "NextAuth"
- "providers"
- "callbacks"
- "session"
description: "Next.js Auth.js route handler with provider configuration and session callbacks"
- path: "lib/auth.ts"
must_contain:
- "authOptions"
- "jwt callback"
- "session callback"
description: "Auth.js configuration with JWT and session callbacks for Next.js"
fastapi:
- path: "app/dependencies/auth.py"
must_contain:
- "Depends"
- "get_current_user"
- "HTTPBearer"
description: "FastAPI authentication dependencies with JWT bearer token validation"
- path: "app/routers/auth.py"
must_contain:
- "APIRouter"
- "/login"
- "/register"
- "/refresh"
description: "FastAPI authentication routes for login, registration, and token refresh"
- path: "app/middleware/security.py"
must_contain:
- "CORSMiddleware"
- "TrustedHostMiddleware"
- "security headers"
description: "FastAPI security middleware with CORS and security headers"
express:
- path: "src/middleware/passport.{ts,js}"
must_contain:
- "passport"
- "strategy"
- "JWT"
description: "Express Passport.js configuration with JWT strategy"
- path: "src/routes/auth.{ts,js}"
must_contain:
- "Router"
- "POST /login"
- "POST /register"
- "POST /refresh"
description: "Express authentication routes for login, registration, and token refresh"
axum:
- path: "src/handlers/auth.rs"
must_contain:
- "async fn login"
- "async fn register"
- "Json"
description: "Axum authentication handlers for login and registration"
- path: "src/middleware/jwt_auth.rs"
must_contain:
- "middleware::from_fn"
- "Bearer"
- "jsonwebtoken"
description: "Axum JWT authentication middleware using tower middleware"
scaffolding:
- command: "python scripts/generate_jwt_keys.py --algorithm EdDSA --output keys/jwt/"
creates:
- "keys/jwt/private.pem"
- "keys/jwt/public.pem"
description: "Generate EdDSA key pair for JWT signing (preferred over ES256/RS256 for performance)"
- command: "python scripts/validate_oauth_config.py --config config/oauth.{json,yaml}"
validates:
- "PKCE enabled"
- "exact redirect URI matching"
- "no implicit grant"
- "TLS 1.2+ required"
description: "Validate OAuth 2.1 compliance (mandatory PKCE, exact redirect URIs, removed implicit grant)"
- template: "examples/authjs-nextjs/"
creates:
- "app/api/auth/[...nextauth]/route.ts"
- "middleware.ts"
- "lib/auth.ts"
description: "Auth.js + Next.js starter with OAuth providers, credentials, and session management"
- template: "examples/keycloak-fastapi/"
creates:
- "app/dependencies/auth.py"
- "app/routers/auth.py"
- "docker-compose.yml"
description: "Keycloak + FastAPI integration with OIDC and Docker setup"
- template: "examples/passkeys-demo/"
creates:
- "src/auth/passkeys/registration.{ts,py}"
- "src/auth/passkeys/authentication.{ts,py}"
description: "Runnable passkey implementation with @simplewebauthn/server"
- directory: "src/auth/"
subdirectories:
- "handlers/"
- "middleware/"
- "oauth/"
- "jwt/"
- "passkeys/"
- "rbac/"
- "session/"
description: "Standard authentication directory structure"
- directory: "keys/"
subdirectories:
- "jwt/"
- "passkeys/"
description: "Cryptographic key storage (add to .gitignore)"
- file: ".env.example"
must_contain:
- "JWT_SECRET"
- "JWT_ALGORITHM"
- "ACCESS_TOKEN_LIFETIME"
- "REFRESH_TOKEN_LIFETIME"
- "OAUTH_CLIENT_ID"
- "OAUTH_CLIENT_SECRET"
description: "Environment variables template for authentication configuration"
metadata:
primary_blueprints:
- "api-first"
- "security"
contributes_to:
- "Authentication"
- "Authorization"
- "API Security"
- "User Management"
library_requirements:
typescript:
- "Auth.js v5 (next-auth)"
- "jose 5.x (JWT)"
- "@simplewebauthn/server 11.x (Passkeys)"
- "zod 3.x (validation)"
- "casbin 1.x (RBAC/ABAC)"
python:
- "authlib 1.3+ (OAuth/OIDC)"
- "joserfc 1.x (JWT)"
- "py_webauthn 2.x (Passkeys)"
- "argon2-cffi 24.x (password hashing)"
- "pydantic 2.x (validation)"
- "pycasbin 1.x (RBAC/ABAC)"
rust:
- "jsonwebtoken 10.x (JWT)"
- "oauth2 5.x (OAuth client)"
- "webauthn-rs 0.5.x (Passkeys)"
- "argon2 0.5.x (password hashing)"
- "casbin-rs 2.x (RBAC/ABAC)"
go:
- "golang-jwt/jwt v5 (JWT)"
- "coreos/go-oidc v3 (OIDC)"
- "go-webauthn/webauthn 0.11.x (Passkeys)"
- "golang.org/x/crypto/argon2 (password hashing)"
- "casbin/casbin v2 (RBAC/ABAC)"
security_standards:
- "OAuth 2.1 (RFC 9798)"
- "PKCE (RFC 7636) - MANDATORY"
- "OIDC Core 1.0"
- "WebAuthn Level 2"
- "OWASP ASVS 4.0"
- "NIST SP 800-63B"
critical_requirements:
- "PKCE mandatory for ALL OAuth flows"
- "Exact redirect URI matching (no wildcards)"
- "TLS 1.2+ required"
- "No implicit grant flow"
- "No resource owner password credentials"
- "EdDSA or ES256 for JWT signing (not RS256)"
- "Argon2id for password hashing (64MB, 3 iterations, 4 threads)"
- "Access token: 5-15 minutes"
- "Refresh token: 1-7 days with rotation"
- "Tokens in memory (access) or HTTP-only cookies (refresh)"
- "Never log tokens"
- "Rate limiting with sliding window"
- "Security headers on all responses"
integration_points:
- skill: "building-apis"
provides: "JWT middleware, error responses (401/403), OpenAPI security schemas"
- skill: "implementing-forms"
provides: "Login/register forms, validation, password strength indicators"
- skill: "building-dashboards"
provides: "Role-based widget visibility, permission-based filtering"
- skill: "implementing-observability"
provides: "Auth event logging, failed login tracking, security alerting"
API Security Best Practices
Comprehensive guide to securing REST, GraphQL, and gRPC APIs in production environments.
Table of Contents
- Core Security Principles
- Authentication Patterns
- JWT (JSON Web Tokens)
- API Keys
- OAuth 2.1 Client Credentials Flow
- Rate Limiting
- Strategy 1: Fixed Window
- Strategy 2: Sliding Window (Recommended)
- Strategy 3: Token Bucket (Advanced)
- Tiered Rate Limiting
- Input Validation
- Request Validation (TypeScript + Zod)
- SQL Injection Prevention
- XSS Prevention
- CORS Configuration
- Restrictive CORS (Recommended)
- Security Headers
- Helmet.js Configuration (Express/Node.js)
- FastAPI Security Headers
- GraphQL Security
- Query Depth Limiting
- Query Complexity Analysis
- Disable Introspection in Production
- File Upload Security
- Validation and Sanitization
- API Versioning
- URI Versioning (Simple)
- Header Versioning (REST Best Practice)
- Logging and Monitoring
- Security Event Logging
- Sensitive Data Redaction
- Secrets Management
- Environment Variables (Basic)
- HashiCorp Vault (Production)
- Security Checklist
- Pre-Deployment
- Ongoing
- Common Vulnerabilities
- Mass Assignment
- Insecure Direct Object References (IDOR)
- Timing Attacks
- Resources
Core Security Principles
1. Defense in Depth - Multiple layers of security 2. Least Privilege - Grant minimum required permissions 3. Fail Securely - Default to deny on errors 4. Don't Trust Input - Validate and sanitize everything 5. Keep Security Simple - Complex systems have more vulnerabilities
---
Authentication Patterns
JWT (JSON Web Tokens)
Best practices:
- Use EdDSA (Ed25519) or ES256 for signing (avoid RS256 unless required)
- Set short expiration times (5-15 minutes for access tokens)
- Implement refresh token rotation
- Include
aud(audience) claim for token binding - Validate all claims on every request
Secure JWT validation:
import { jwtVerify, importSPKI } from 'jose';
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY, 'EdDSA');
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, publicKey, {
issuer: 'https://auth.example.com',
audience: 'api.example.com',
algorithms: ['EdDSA'],
maxTokenAge: '15m', // Reject tokens older than 15 minutes
});
// Check for revocation (Redis/database check)
const isRevoked = await checkTokenRevocation(payload.jti);
if (isRevoked) throw new Error('Token revoked');
return payload;
} catch (error) {
throw new UnauthorizedError('Invalid token');
}
}Python (FastAPI):
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from joserfc import jwt
from joserfc.jwk import RSAKey
security = HTTPBearer()
public_key = RSAKey.import_key(open('public_key.pem').read())
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
try:
payload = jwt.decode(
credentials.credentials,
public_key,
claims_options={
"iss": {"essential": True, "value": "https://auth.example.com"},
"aud": {"essential": True, "value": "api.example.com"},
"exp": {"essential": True},
}
)
return payload
except Exception as e:
raise HTTPException(status_code=401, detail="Invalid token")API Keys
When to use:
- Server-to-server communication
- Third-party integrations
- Long-lived tokens for automation
Implementation:
// Hashed storage (never store plain-text API keys)
import crypto from 'crypto';
function hashApiKey(apiKey: string): string {
return crypto.createHash('sha256').update(apiKey).digest('hex');
}
// Generate API key
function generateApiKey(): { key: string; hash: string } {
const key = `sk_${crypto.randomBytes(32).toString('hex')}`;
const hash = hashApiKey(key);
return { key, hash }; // Store hash in database, return key once to user
}
// Validate API key
async function validateApiKey(providedKey: string): Promise<boolean> {
const hash = hashApiKey(providedKey);
const storedApiKey = await db.apiKeys.findOne({ hash });
if (!storedApiKey) return false;
if (storedApiKey.revokedAt) return false;
if (storedApiKey.expiresAt && storedApiKey.expiresAt < new Date()) return false;
// Update last_used_at
await db.apiKeys.update({ hash }, { lastUsedAt: new Date() });
return true;
}OAuth 2.1 Client Credentials Flow
For machine-to-machine (M2M) authentication:
async function getAccessToken() {
const response = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: 'read:data write:data',
}),
});
const { access_token, expires_in } = await response.json();
// Cache token until expiration
cache.set('m2m_token', access_token, expires_in - 60);
return access_token;
}---
Rate Limiting
Strategy 1: Fixed Window
Simple but has boundary issues (burst at window reset).
import Redis from 'ioredis';
const redis = new Redis();
async function fixedWindowRateLimit(userId: string, limit: number, windowSeconds: number): Promise<boolean> {
const key = `rate:${userId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return count <= limit;
}
// Usage: Allow 100 requests per 60 seconds
const allowed = await fixedWindowRateLimit('user_123', 100, 60);
if (!allowed) {
throw new Error('Rate limit exceeded');
}Strategy 2: Sliding Window (Recommended)
More accurate, prevents burst attacks.
async function slidingWindowRateLimit(userId: string, limit: number, windowSeconds: number): Promise<boolean> {
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
const key = `rate:${userId}`;
// Remove old entries
await redis.zremrangebyscore(key, 0, windowStart);
// Count requests in window
const count = await redis.zcard(key);
if (count >= limit) {
return false;
}
// Add current request
await redis.zadd(key, now, `${now}`);
await redis.expire(key, windowSeconds);
return true;
}Strategy 3: Token Bucket (Advanced)
Allows bursts while maintaining average rate.
async function tokenBucketRateLimit(userId: string, capacity: number, refillRate: number): Promise<boolean> {
const key = `bucket:${userId}`;
const now = Date.now();
const bucket = await redis.hgetall(key);
let tokens = parseFloat(bucket.tokens) || capacity;
let lastRefill = parseInt(bucket.lastRefill) || now;
// Refill tokens based on time elapsed
const elapsed = (now - lastRefill) / 1000;
tokens = Math.min(capacity, tokens + elapsed * refillRate);
if (tokens < 1) {
await redis.hset(key, { tokens: tokens.toString(), lastRefill: now.toString() });
await redis.expire(key, 3600);
return false;
}
// Consume one token
tokens -= 1;
await redis.hset(key, { tokens: tokens.toString(), lastRefill: now.toString() });
await redis.expire(key, 3600);
return true;
}
// Usage: 10 token capacity, refill 1 token/second
const allowed = await tokenBucketRateLimit('user_123', 10, 1);Tiered Rate Limiting
Different limits for different user types:
const RATE_LIMITS = {
anonymous: { limit: 10, window: 60 }, // 10 req/min
authenticated: { limit: 100, window: 60 }, // 100 req/min
premium: { limit: 1000, window: 60 }, // 1000 req/min
internal: { limit: 10000, window: 60 }, // 10K req/min (internal services)
};
async function getRateLimit(user: User | null): Promise<{ limit: number; window: number }> {
if (!user) return RATE_LIMITS.anonymous;
if (user.tier === 'premium') return RATE_LIMITS.premium;
if (user.isInternal) return RATE_LIMITS.internal;
return RATE_LIMITS.authenticated;
}---
Input Validation
Request Validation (TypeScript + Zod)
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
age: z.number().int().min(18).max(120),
role: z.enum(['user', 'admin']).default('user'),
metadata: z.record(z.string()).optional(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
app.post('/users', async (req, res) => {
try {
const validatedData = CreateUserSchema.parse(req.body);
// Safe to use validatedData
const user = await createUser(validatedData);
res.json(user);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ errors: error.errors });
}
throw error;
}
});SQL Injection Prevention
Always use parameterized queries:
// ✅ SAFE (parameterized)
const user = await db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
// ❌ UNSAFE (string concatenation)
const user = await db.query(
`SELECT * FROM users WHERE email = '${email}'`
);XSS Prevention
import DOMPurify from 'isomorphic-dompurify';
// Sanitize user input before storing
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p'],
ALLOWED_ATTR: ['href'],
});
}
// Always escape output
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}---
CORS Configuration
Restrictive CORS (Recommended)
const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true, // Allow cookies
maxAge: 86400, // Cache preflight for 24 hours
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Total-Count', 'X-Page-Count'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
};
app.use(cors(corsOptions));NEVER use:
// ❌ DANGEROUS (allows all origins with credentials)
app.use(cors({
origin: '*',
credentials: true, // This combination is a security vulnerability
}));---
Security Headers
Helmet.js Configuration (Express/Node.js)
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Avoid 'unsafe-inline' in production
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
fontSrc: ["'self'", 'https:', 'data:'],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true,
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
permissionsPolicy: {
features: {
geolocation: ["'none'"],
microphone: ["'none'"],
camera: ["'none'"],
payment: ["'none'"],
},
},
}));FastAPI Security Headers
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.cors import CORSMiddleware
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["example.com", "*.example.com"]
)
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
return response---
GraphQL Security
Query Depth Limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)], // Max 7 levels deep
});Query Complexity Analysis
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const complexityLimit = createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query cost:', cost),
formatErrorMessage: (cost) => `Query too complex: ${cost} (max 1000)`,
});
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [complexityLimit],
});Disable Introspection in Production
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
plugins: [
process.env.NODE_ENV === 'production'
? ApolloServerPluginLandingPageDisabled()
: ApolloServerPluginLandingPageLocalDefault(),
],
});---
File Upload Security
Validation and Sanitization
import multer from 'multer';
import path from 'path';
import crypto from 'crypto';
const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const storage = multer.diskStorage({
destination: '/tmp/uploads',
filename: (req, file, cb) => {
// Generate random filename (prevent directory traversal)
const randomName = crypto.randomBytes(16).toString('hex');
const ext = path.extname(file.originalname);
cb(null, `${randomName}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
return cb(new Error('Invalid file type'));
}
cb(null, true);
},
});
app.post('/upload', upload.single('file'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Verify MIME type (don't trust client-provided MIME type)
const { fileTypeFromFile } = await import('file-type');
const type = await fileTypeFromFile(req.file.path);
if (!type || !ALLOWED_MIME_TYPES.includes(type.mime)) {
fs.unlinkSync(req.file.path); // Delete invalid file
return res.status(400).json({ error: 'Invalid file content' });
}
// Process file...
res.json({ filename: req.file.filename });
});---
API Versioning
URI Versioning (Simple)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);Header Versioning (REST Best Practice)
app.use((req, res, next) => {
const version = req.headers['api-version'] || '1';
req.apiVersion = version;
next();
});
app.get('/users', (req, res) => {
if (req.apiVersion === '2') {
return getUsersV2(req, res);
}
return getUsersV1(req, res);
});---
Logging and Monitoring
Security Event Logging
enum SecurityEvent {
LOGIN_SUCCESS = 'login_success',
LOGIN_FAILED = 'login_failed',
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
INVALID_TOKEN = 'invalid_token',
PERMISSION_DENIED = 'permission_denied',
}
function logSecurityEvent(event: SecurityEvent, metadata: Record<string, any>) {
logger.warn({
type: 'security_event',
event,
timestamp: new Date().toISOString(),
ip: metadata.ip,
userId: metadata.userId,
endpoint: metadata.endpoint,
userAgent: metadata.userAgent,
});
// Send to security monitoring (Datadog, Sentry, etc.)
if ([SecurityEvent.LOGIN_FAILED, SecurityEvent.RATE_LIMIT_EXCEEDED].includes(event)) {
alertSecurityTeam(event, metadata);
}
}Sensitive Data Redaction
function redactSensitiveData(obj: any): any {
const sensitiveKeys = ['password', 'ssn', 'creditCard', 'apiKey', 'secret'];
return JSON.parse(JSON.stringify(obj, (key, value) => {
if (sensitiveKeys.includes(key)) {
return '[REDACTED]';
}
return value;
}));
}
logger.info('User created', redactSensitiveData(userData));---
Secrets Management
Environment Variables (Basic)
# .env
DATABASE_URL=postgresql://user:pass@localhost/db
JWT_SECRET=...
API_KEY=...import dotenv from 'dotenv';
dotenv.config();
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET not configured');
}HashiCorp Vault (Production)
import Vault from 'node-vault';
const vault = Vault({
endpoint: 'https://vault.example.com',
token: process.env.VAULT_TOKEN,
});
const { data } = await vault.read('secret/data/api');
const JWT_SECRET = data.data.JWT_SECRET;---
Security Checklist
Pre-Deployment
- [ ] All secrets in environment variables/vault (not hardcoded)
- [ ] HTTPS enforced (TLS 1.3+)
- [ ] Security headers configured (HSTS, CSP, etc.)
- [ ] CORS configured restrictively
- [ ] Rate limiting enabled
- [ ] Input validation on all endpoints
- [ ] SQL injection prevention (parameterized queries)
- [ ] XSS prevention (sanitize output)
- [ ] Authentication required on protected endpoints
- [ ] Authorization checks on every request
- [ ] Sensitive data redacted from logs
- [ ] Error messages don't leak information
- [ ] File uploads validated (MIME type, size)
- [ ] API versioning strategy implemented
- [ ] Security logging and monitoring active
Ongoing
- [ ] Regular dependency updates (npm audit, Snyk)
- [ ] Penetration testing (quarterly)
- [ ] Security headers scan (securityheaders.com)
- [ ] SSL/TLS configuration test (ssllabs.com)
- [ ] Review access logs for anomalies
- [ ] Rotate secrets/API keys (annually)
---
Common Vulnerabilities
Mass Assignment
Problem: Allowing users to modify restricted fields.
// ❌ VULNERABLE
app.put('/users/:id', async (req, res) => {
await db.users.update(req.params.id, req.body); // User could set isAdmin: true
});
// ✅ SAFE
const UpdateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
// isAdmin NOT allowed
});
app.put('/users/:id', async (req, res) => {
const data = UpdateUserSchema.parse(req.body);
await db.users.update(req.params.id, data);
});Insecure Direct Object References (IDOR)
Problem: Users accessing resources they shouldn't.
// ❌ VULNERABLE
app.get('/documents/:id', async (req, res) => {
const doc = await db.documents.findById(req.params.id);
res.json(doc); // No ownership check!
});
// ✅ SAFE
app.get('/documents/:id', requireAuth, async (req, res) => {
const doc = await db.documents.findById(req.params.id);
if (!doc) return res.status(404).json({ error: 'Not found' });
if (doc.userId !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
res.json(doc);
});Timing Attacks
Problem: Timing differences reveal information.
// ❌ VULNERABLE (early return on failure)
if (user.password !== providedPassword) {
return false;
}
// ✅ SAFE (constant-time comparison)
import crypto from 'crypto';
function safeCompare(a: string, b: string): boolean {
return crypto.timingSafeEqual(
Buffer.from(a),
Buffer.from(b)
);
}---
Resources
- OWASP API Security Top 10: https://owasp.org/API-Security/
- OWASP Cheat Sheet Series: https://cheatsheetseries.owasp.org/
- Mozilla Observatory: https://observatory.mozilla.org/
- Security Headers: https://securityheaders.com/
- SSL Labs: https://www.ssllabs.com/ssltest/
Authorization Patterns: RBAC, ABAC, and ReBAC
Authorization determines what authenticated users can access and modify.
Table of Contents
- Authorization Model Comparison
- RBAC (Role-Based Access Control)
- Structure
- Example Schema
- Implementation with Casbin
- When to Use RBAC
- RBAC Limitations
- ABAC (Attribute-Based Access Control)
- Structure
- Example Policies (OPA Rego)
- Implementation with OPA (Open Policy Agent)
- When to Use ABAC
- ABAC Limitations
- ReBAC (Relationship-Based Access Control)
- Structure
- Example Schema (SpiceDB)
- Relationships
- Permission Checks
- Implementation with SpiceDB
- When to Use ReBAC
- ReBAC Limitations
- Authorization Engine Selection
- Casbin (RBAC/ABAC)
- OPA (Open Policy Agent)
- Cerbos
- SpiceDB (ReBAC)
- Implementation Patterns
- Middleware Pattern (Express)
- Decorator Pattern (FastAPI)
- Row-Level Security (Database)
- Audit Logging
- OPA Decision Logs
- Custom Audit Logging
- Testing Authorization
- Unit Tests
- Integration Tests
- Performance Optimization
- Caching
- Batch Checks
- Common Pitfalls
- Pitfall 1: Authorization in Frontend Only
- Pitfall 2: Hardcoded Permissions
- Pitfall 3: No Audit Trail
Authorization Model Comparison
| Model | Complexity | Use Case | Example |
|---|---|---|---|
| ACL | Low | Simple file systems | User A can read file.txt |
| RBAC | Medium | Enterprise applications | Admins can delete users |
| ABAC | High | Complex policies | Allow if clearance >= classification |
| ReBAC | Very High | Multi-tenant, collaborative | Can edit if member of workspace |
RBAC (Role-Based Access Control)
Assign permissions to roles, then assign roles to users.
Structure
Users → Roles → Permissions → ResourcesExample Schema
Roles:
- Admin: [users:create, users:delete, posts:delete]
- Editor: [posts:create, posts:update, posts:delete]
- Viewer: [posts:read]
Users:
- alice: [Admin]
- bob: [Editor]
- charlie: [Viewer]Implementation with Casbin
Policy File (policy.csv):
p, admin, users, create
p, admin, users, delete
p, admin, posts, delete
p, editor, posts, create
p, editor, posts, update
p, editor, posts, delete
p, viewer, posts, read
g, alice, admin
g, bob, editor
g, charlie, viewerModel File (model.conf):
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.actTypeScript:
import { newEnforcer } from 'casbin'
const enforcer = await newEnforcer('model.conf', 'policy.csv')
// Check permission
const allowed = await enforcer.enforce('alice', 'users', 'delete')
console.log(allowed) // true (alice is admin)
const denied = await enforcer.enforce('bob', 'users', 'delete')
console.log(denied) // false (bob is editor)Python:
import casbin
enforcer = casbin.Enforcer('model.conf', 'policy.csv')
# Check permission
allowed = enforcer.enforce('alice', 'users', 'delete')
print(allowed) # True
# Add role
enforcer.add_role_for_user('dave', 'editor')
# Add permission
enforcer.add_permission_for_user('editor', 'comments', 'moderate')When to Use RBAC
- Simple role hierarchies (< 20 roles)
- Clear permission boundaries (admin vs user)
- Infrequent role changes
- Static permissions (not context-dependent)
RBAC Limitations
- Role explosion: Complex organizations need many roles
- No context: Can't express "owner can delete their own posts"
- Hard to audit: "Who can access resource X?" requires traversing graph
ABAC (Attribute-Based Access Control)
Policies based on attributes of user, resource, and environment.
Structure
Policy: Allow if
user.department == resource.department AND
user.clearance >= resource.classification AND
time.hour >= 9 AND time.hour < 17Example Policies (OPA Rego)
package authz
# Allow if user is admin
allow {
input.user.role == "admin"
}
# Allow if user owns the resource
allow {
input.user.id == input.resource.owner_id
}
# Allow if user's clearance >= resource's classification
allow {
input.user.clearance >= input.resource.classification
input.user.department == input.resource.department
}
# Allow if within business hours
allow {
hour := time.clock(input.time)[0]
hour >= 9
hour < 17
}Query OPA:
curl -X POST http://localhost:8181/v1/data/authz/allow \
-d '{
"input": {
"user": {"id": "alice", "role": "editor", "clearance": 3, "department": "eng"},
"resource": {"id": "doc-123", "owner_id": "bob", "classification": 2, "department": "eng"},
"action": "read",
"time": "2025-12-02T14:00:00Z"
}
}'Response:
{
"result": true
}Implementation with OPA (Open Policy Agent)
TypeScript:
import axios from 'axios'
async function checkPermission(
user: { id: string; role: string; clearance: number },
resource: { id: string; classification: number },
action: string
): Promise<boolean> {
const response = await axios.post('http://localhost:8181/v1/data/authz/allow', {
input: { user, resource, action },
})
return response.data.result === true
}
// Usage
const allowed = await checkPermission(
{ id: 'alice', role: 'editor', clearance: 3 },
{ id: 'doc-123', classification: 2 },
'read'
)Python:
import requests
def check_permission(user, resource, action):
response = requests.post(
'http://localhost:8181/v1/data/authz/allow',
json={'input': {'user': user, 'resource': resource, 'action': action}}
)
return response.json().get('result') == True
# Usage
allowed = check_permission(
{'id': 'alice', 'role': 'editor', 'clearance': 3},
{'id': 'doc-123', 'classification': 2},
'read'
)When to Use ABAC
- Complex conditional rules
- Context-dependent permissions (time, location, device)
- Fine-grained access control
- Compliance requirements (HIPAA, SOC 2)
ABAC Limitations
- Policy complexity: Hard to write and maintain complex policies
- Performance: Evaluating many attributes can be slow
- Debugging: Hard to understand why access was denied
ReBAC (Relationship-Based Access Control)
Permissions based on relationships between entities (Google Zanzibar model).
Structure
User → Relationship → Group → Relationship → ResourceExample Schema (SpiceDB)
// Define user type
definition user {}
// Define workspace with members and admins
definition workspace {
relation member: user
relation admin: user
permission view = member + admin
permission edit = admin
}
// Define document within workspace
definition document {
relation workspace: workspace
relation writer: user
permission view = workspace->view + writer
permission edit = workspace->edit + writer
}Relationships
// alice is admin of workspace:acme
workspace:acme#admin@user:alice
// bob is member of workspace:acme
workspace:acme#member@user:bob
// doc:123 is in workspace:acme
document:doc-123#workspace@workspace:acme
// charlie is writer of doc:123
document:doc-123#writer@user:charliePermission Checks
# Can alice edit doc:123?
spicedb check user:alice edit document:doc-123
# → true (alice is admin of workspace, doc is in workspace)
# Can bob edit doc:123?
spicedb check user:bob edit document:doc-123
# → false (bob is member, not admin)
# Can charlie edit doc:123?
spicedb check user:charlie edit document:doc-123
# → true (charlie is writer of doc)Implementation with SpiceDB
TypeScript:
import { v1 } from '@authzed/authzed-node'
const client = v1.NewClient(
'grpcs://localhost:50051',
v1.ClientSecurity.TLS_INSECURE
)
// Check permission
async function checkPermission(
userId: string,
permission: string,
resourceType: string,
resourceId: string
): Promise<boolean> {
const response = await client.checkPermission({
resource: { objectType: resourceType, objectId: resourceId },
permission,
subject: { object: { objectType: 'user', objectId: userId } },
})
return response.permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION
}
// Usage
const canEdit = await checkPermission('alice', 'edit', 'document', 'doc-123')Python:
from authzed.api.v1 import Client, CheckPermissionRequest, SubjectReference, ObjectReference
client = Client('grpcs://localhost:50051', 'token')
def check_permission(user_id, permission, resource_type, resource_id):
request = CheckPermissionRequest(
resource=ObjectReference(object_type=resource_type, object_id=resource_id),
permission=permission,
subject=SubjectReference(object=ObjectReference(object_type='user', object_id=user_id)),
)
response = client.permissions_service.check_permission(request)
return response.permissionship == 2 # HAS_PERMISSION
# Usage
can_edit = check_permission('alice', 'edit', 'document', 'doc-123')When to Use ReBAC
- Multi-tenant applications (Notion, Google Docs, GitHub)
- Collaborative tools (shared workspaces)
- Hierarchical organizations (teams, departments)
- Complex permission inheritance
ReBAC Limitations
- Complexity: Requires understanding graph traversal
- Setup overhead: Need dedicated service (SpiceDB)
- Performance: Deep graph traversal can be slow
- Debugging: Hard to visualize permission paths
Authorization Engine Selection
Casbin (RBAC/ABAC)
Languages: Go, Python, Rust, JavaScript, Java, PHP
Best For:
- Embedded application logic
- Simple to medium complexity
- Multi-language projects
Pros:
- Easy to integrate (library, not service)
- Supports multiple models (RBAC, ABAC, ACL)
- Fast (in-process)
Cons:
- Limited to single-process (no distributed)
- No relationship graphs
Example:
import { newEnforcer } from 'casbin'
const enforcer = await newEnforcer('model.conf', 'policy.csv')
const allowed = await enforcer.enforce('alice', 'data', 'read')OPA (Open Policy Agent)
Language: Rego (policy language)
Best For:
- Kubernetes admission control
- Infrastructure policies
- Complex attribute-based rules
Pros:
- Powerful policy language (Rego)
- Decoupled from application
- Decision logging (audit trail)
- CNCF graduated project
Cons:
- Learning curve (Rego syntax)
- Requires separate service
- Not relationship-aware
Example:
allow {
input.user.role == "admin"
}Cerbos
Language: Go
Best For:
- API-first authorization
- Policy-as-code (Git-backed)
- gRPC/REST APIs
Pros:
- Developer-friendly (YAML policies)
- Git workflow (version control, PR reviews)
- Audit logs built-in
Cons:
- Requires service deployment
- Not relationship-aware
Example (YAML):
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "document"
rules:
- actions: ["read"]
effect: EFFECT_ALLOW
roles: ["viewer", "editor", "admin"]SpiceDB (ReBAC)
Language: Go
Best For:
- Multi-tenant applications
- Collaborative tools
- Google Zanzibar-style permissions
Pros:
- Relationship graphs (powerful)
- Sub-second performance
- Distributed consistency
Cons:
- Complex setup (requires dedicated service)
- Steep learning curve
- Overkill for simple RBAC
Example:
spicedb check user:alice edit document:doc-123Implementation Patterns
Middleware Pattern (Express)
import { enforcer } from './casbin'
export function authorize(resource: string, action: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.id
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' })
}
const allowed = await enforcer.enforce(userId, resource, action)
if (!allowed) {
return res.status(403).json({ error: 'Forbidden' })
}
next()
}
}
// Usage
app.delete('/api/users/:id', authorize('users', 'delete'), deleteUser)Decorator Pattern (FastAPI)
from functools import wraps
from fastapi import HTTPException
def authorize(resource: str, action: str):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
user_id = kwargs.get('user_id')
if not user_id:
raise HTTPException(401, "Unauthorized")
allowed = enforcer.enforce(user_id, resource, action)
if not allowed:
raise HTTPException(403, "Forbidden")
return await func(*args, **kwargs)
return wrapper
return decorator
# Usage
@app.delete("/api/users/{user_id}")
@authorize("users", "delete")
async def delete_user(user_id: str):
passRow-Level Security (Database)
Postgres Row-Level Security (RLS) with policies:
-- Enable RLS on table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Policy: Users can read documents in their workspace
CREATE POLICY documents_read_policy ON documents
FOR SELECT
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = current_setting('app.user_id')::uuid
)
);
-- Policy: Admins can update any document
CREATE POLICY documents_update_policy ON documents
FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM users
WHERE id = current_setting('app.user_id')::uuid
AND role = 'admin'
)
);Set user context:
await db.query('SET app.user_id = $1', [userId])
const docs = await db.query('SELECT * FROM documents')
// Only returns documents user can readAudit Logging
Track authorization decisions for compliance.
OPA Decision Logs
# Enable decision logging
opa run --server --set decision_logs.console=trueDecision log entry:
{
"input": {
"user": {"id": "alice", "role": "editor"},
"resource": {"id": "doc-123"},
"action": "delete"
},
"result": false,
"timestamp": "2025-12-02T14:30:00Z"
}Custom Audit Logging
async function checkPermissionWithAudit(
userId: string,
resource: string,
action: string
): Promise<boolean> {
const allowed = await enforcer.enforce(userId, resource, action)
// Log decision
await db.auditLogs.create({
userId,
resource,
action,
allowed,
timestamp: new Date(),
ip: req.ip,
userAgent: req.headers['user-agent'],
})
return allowed
}Testing Authorization
Unit Tests
import { expect, test } from 'vitest'
import { enforcer } from './casbin'
test('admin can delete users', async () => {
const allowed = await enforcer.enforce('admin', 'users', 'delete')
expect(allowed).toBe(true)
})
test('editor cannot delete users', async () => {
const allowed = await enforcer.enforce('editor', 'users', 'delete')
expect(allowed).toBe(false)
})Integration Tests
test('DELETE /api/users/:id requires admin role', async () => {
// Alice is editor
const response = await request(app)
.delete('/api/users/123')
.set('Authorization', `Bearer ${aliceToken}`)
expect(response.status).toBe(403)
expect(response.body.error).toBe('Forbidden')
})Performance Optimization
Caching
const permissionCache = new Map<string, boolean>()
async function checkPermissionCached(
userId: string,
resource: string,
action: string
): Promise<boolean> {
const cacheKey = `${userId}:${resource}:${action}`
if (permissionCache.has(cacheKey)) {
return permissionCache.get(cacheKey)!
}
const allowed = await enforcer.enforce(userId, resource, action)
permissionCache.set(cacheKey, allowed)
// Expire after 5 minutes
setTimeout(() => permissionCache.delete(cacheKey), 5 * 60 * 1000)
return allowed
}Batch Checks
async function checkPermissionsBatch(
userId: string,
permissions: Array<{ resource: string; action: string }>
): Promise<Map<string, boolean>> {
const results = new Map<string, boolean>()
await Promise.all(
permissions.map(async ({ resource, action }) => {
const allowed = await enforcer.enforce(userId, resource, action)
results.set(`${resource}:${action}`, allowed)
})
)
return results
}Common Pitfalls
Pitfall 1: Authorization in Frontend Only
Bad:
// Only in frontend
if (user.role === 'admin') {
return <DeleteButton />
}Good:
// Frontend
if (user.role === 'admin') {
return <DeleteButton />
}
// Backend (always validate)
app.delete('/api/users/:id', authorize('users', 'delete'), deleteUser)Pitfall 2: Hardcoded Permissions
Bad:
if (user.role === 'admin') {
// Allow
}Good:
const allowed = await enforcer.enforce(user.id, resource, action)
if (allowed) {
// Allow
}Pitfall 3: No Audit Trail
Bad:
return await enforcer.enforce(user.id, resource, action)Good:
const allowed = await enforcer.enforce(user.id, resource, action)
await logAuthorizationDecision(user.id, resource, action, allowed)
return allowedJWT Best Practices
JSON Web Tokens (JWT) for stateless authentication and authorization.
Table of Contents
- JWT Structure
- Header
- Payload (Claims)
- Signature
- Signing Algorithms (Priority Order)
- 1. EdDSA with Ed25519 (Recommended)
- 2. ES256 (ECDSA with P-256)
- 3. RS256 (RSA with SHA-256)
- Never Use
- Token Lifetimes
- Access Token: 5-15 Minutes
- Refresh Token: 1-7 Days with Rotation
- ID Token: Same as Access Token
- Required Claims
- Standard Claims
- Custom Claims
- Token Storage
- Access Token: Memory Only
- Refresh Token: HTTP-Only Cookie
- CSRF Token: Separate Cookie
- Token Validation
- Validation Checklist
- Express Middleware Example
- Key Generation
- Key Storage
- Token Refresh Flow
- Client-Side (TypeScript)
- Server-Side (Next.js API Route)
- Token Revocation
- Using jti (JWT ID)
- Revocation Check (Redis)
- Logout Flow
- Common Attacks and Mitigations
- Algorithm Confusion Attack
- Token Replay Attack
- XSS Token Theft
- CSRF Attack on Refresh Endpoint
- Performance Optimization
- Token Size
- Verification Caching
- Testing JWTs
- Manual Verification
- Unit Tests
JWT Structure
header.payload.signatureHeader
{
"alg": "EdDSA",
"typ": "JWT"
}Payload (Claims)
{
"iss": "https://auth.example.com",
"sub": "user-id-123",
"aud": "api.example.com",
"exp": 1735689600,
"iat": 1735686000,
"jti": "unique-token-id-abc123",
"scope": "read:profile write:data"
}Signature
HMAC or asymmetric signature (EdDSA, ES256, RS256).
Signing Algorithms (Priority Order)
1. EdDSA with Ed25519 (Recommended)
Why:
- Fastest performance (10x faster than RSA)
- Smallest signatures (64 bytes)
- Modern cryptography (no known weaknesses)
- Deterministic signatures (same input = same signature)
When to Use:
- New projects (2025+)
- High-performance requirements
- Microservices (small token size)
TypeScript (jose):
import { SignJWT, jwtVerify, generateKeyPair } from 'jose'
// Generate key pair (do this once, store securely)
const { publicKey, privateKey } = await generateKeyPair('EdDSA')
// Sign JWT
const jwt = await new SignJWT({ userId: '123', role: 'admin' })
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuedAt()
.setIssuer('https://auth.example.com')
.setAudience('api.example.com')
.setExpirationTime('15m')
.sign(privateKey)
// Verify JWT
const { payload } = await jwtVerify(jwt, publicKey, {
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})Python (joserfc):
from joserfc import jwt
from joserfc.jwk import OKPKey
# Generate key pair
private_key = OKPKey.generate_key('Ed25519')
public_key = private_key.as_public()
# Sign JWT
claims = {
'iss': 'https://auth.example.com',
'sub': 'user-123',
'aud': 'api.example.com',
'exp': int(time.time()) + 900, # 15 minutes
}
token = jwt.encode({'alg': 'EdDSA'}, claims, private_key)
# Verify JWT
claims = jwt.decode(token, public_key)Rust (jsonwebtoken):
use jsonwebtoken::{encode, decode, Header, Validation, Algorithm, EncodingKey, DecodingKey};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
iss: String,
aud: String,
exp: usize,
}
// Generate key pair with `openssl genpkey -algorithm ED25519`
let encoding_key = EncodingKey::from_ed_pem(private_key_pem)?;
let decoding_key = DecodingKey::from_ed_pem(public_key_pem)?;
// Sign JWT
let claims = Claims {
sub: "user-123".to_string(),
iss: "https://auth.example.com".to_string(),
aud: "api.example.com".to_string(),
exp: (chrono::Utc::now() + chrono::Duration::minutes(15)).timestamp() as usize,
};
let token = encode(&Header::new(Algorithm::EdDSA), &claims, &encoding_key)?;
// Verify JWT
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_audience(&["api.example.com"]);
validation.set_issuer(&["https://auth.example.com"]);
let token_data = decode::<Claims>(&token, &decoding_key, &validation)?;2. ES256 (ECDSA with P-256)
Why:
- Industry standard (widely supported)
- Good performance (faster than RSA)
- Smaller keys than RSA (256-bit)
- FIPS 186-4 approved
When to Use:
- Compatibility requirements
- Government/regulated industries
- Legacy systems upgrade from RS256
TypeScript (jose):
const { publicKey, privateKey } = await generateKeyPair('ES256')
const jwt = await new SignJWT({ userId: '123' })
.setProtectedHeader({ alg: 'ES256' })
.setExpirationTime('15m')
.sign(privateKey)3. RS256 (RSA with SHA-256)
Why:
- Maximum compatibility
- Well-understood algorithm
- Key rotation easier (public key distribution)
When to Use:
- Legacy system compatibility
- OpenID Connect providers (common default)
- When EdDSA/ES256 not available
Performance Note: 10x slower than EdDSA, larger signatures (256 bytes).
Never Use
- HS256 (HMAC): Symmetric key, hard to rotate, key must be on all services
- RS384, RS512: No benefit over RS256, larger signatures
- none: Algorithm bypass attack, always validate algorithm
Token Lifetimes
Access Token: 5-15 Minutes
Reasoning:
- Limits damage if token stolen
- Forces refresh, enabling revocation
- Balances UX (not too many refreshes) with security
Implementation:
.setExpirationTime('15m') // 15 minutesRefresh Token: 1-7 Days with Rotation
Reasoning:
- Long enough to avoid re-login annoyance
- Short enough to limit exposure
- Rotation prevents replay attacks
Implementation:
.setExpirationTime('7d') // 7 daysRotation Pattern: 1. Client sends refresh token 2. Server validates refresh token 3. Server issues NEW access token + NEW refresh token 4. Server invalidates OLD refresh token 5. Client stores new refresh token
ID Token: Same as Access Token
ID tokens (OIDC) verify user identity, not API access. Use same lifetime as access token.
Required Claims
Standard Claims
| Claim | Name | Required | Example |
|---|---|---|---|
iss | Issuer | Yes | https://auth.example.com |
sub | Subject | Yes | user-123 |
aud | Audience | Yes | api.example.com |
exp | Expiration | Yes | 1735689600 (Unix timestamp) |
iat | Issued At | Yes | 1735686000 (Unix timestamp) |
jti | JWT ID | Recommended | abc123 (unique ID for revocation) |
Custom Claims
{
"scope": "read:profile write:data",
"role": "admin",
"tenant_id": "org-456",
"permissions": ["users:create", "posts:delete"]
}Avoid: Sensitive data (passwords, credit cards, SSNs). Tokens are base64-encoded, not encrypted.
Token Storage
Access Token: Memory Only
// Store in React state or module variable
let accessToken: string | null = null
export function setAccessToken(token: string) {
accessToken = token
}
export function getAccessToken() {
return accessToken
}Why:
- XSS can't steal from memory if token is cleared on page reload
- Forces refresh on page load (good for short-lived tokens)
Never:
- localStorage (persists across tabs, vulnerable to XSS)
- sessionStorage (still vulnerable to XSS)
- Cookies without HttpOnly flag
Refresh Token: HTTP-Only Cookie
// Set cookie (server-side)
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60, // 7 days
path: '/api/auth/refresh', // Only sent to refresh endpoint
})Why:
- HttpOnly prevents XSS theft
- SameSite=Strict prevents CSRF
- Secure flag requires HTTPS
- Path restriction limits exposure
CSRF Token: Separate Cookie
For additional CSRF protection with refresh tokens:
// Set CSRF token (non-HttpOnly)
response.cookies.set('csrf_token', csrfToken, {
httpOnly: false, // Accessible via JavaScript
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60,
})
// Client sends CSRF token in header
fetch('/api/auth/refresh', {
headers: {
'X-CSRF-Token': getCookie('csrf_token'),
},
credentials: 'include', // Send cookies
})
// Server validates
if (request.headers.get('X-CSRF-Token') !== request.cookies.get('csrf_token')) {
throw new Error('CSRF token mismatch')
}Token Validation
Validation Checklist
const { payload } = await jwtVerify(token, publicKey, {
// 1. Check algorithm
algorithms: ['EdDSA'], // Never allow "none"
// 2. Check issuer
issuer: 'https://auth.example.com',
// 3. Check audience
audience: 'api.example.com',
// 4. Check expiration (automatic)
// Fails if current time > exp
// 5. Clock skew tolerance (optional)
clockTolerance: 60, // 60 seconds
})
// 6. Check custom claims
if (payload.role !== 'admin') {
throw new Error('Insufficient permissions')
}
// 7. Check revocation (if using jti)
const isRevoked = await redis.get(`revoked:${payload.jti}`)
if (isRevoked) {
throw new Error('Token revoked')
}Express Middleware Example
import { Request, Response, NextFunction } from 'express'
import { jwtVerify } from 'jose'
export async function authenticateToken(
req: Request,
res: Response,
next: NextFunction
) {
const authHeader = req.headers.authorization
const token = authHeader?.split(' ')[1] // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'No token provided' })
}
try {
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['EdDSA'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})
req.user = payload // Attach to request
next()
} catch (error) {
return res.status(403).json({ error: 'Invalid token' })
}
}
// Usage
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({ user: req.user })
})Key Generation
Use scripts/generate_jwt_keys.py to generate keys:
# EdDSA (Recommended)
python scripts/generate_jwt_keys.py --algorithm EdDSA
# ES256
python scripts/generate_jwt_keys.py --algorithm ES256
# RS256 (legacy)
python scripts/generate_jwt_keys.py --algorithm RS256Output:
private_key.pem # Keep secure, never commit to git
public_key.pem # Can be shared, used for verificationKey Storage
Development:
- Store in
.envfile (add to.gitignore) - Use multiline strings or file paths
Production:
- AWS Secrets Manager
- Google Secret Manager
- HashiCorp Vault
- Environment variables (encrypted at rest)
Never:
- Hardcode in source code
- Commit to version control
- Share via insecure channels
Token Refresh Flow
Client-Side (TypeScript)
let accessToken: string | null = null
async function refreshAccessToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send refresh token cookie
headers: {
'X-CSRF-Token': getCookie('csrf_token'),
},
})
if (!response.ok) {
// Refresh token expired or invalid
window.location.href = '/login'
return null
}
const { access_token } = await response.json()
accessToken = access_token
return access_token
}
async function fetchWithAuth(url: string, options: RequestInit = {}) {
// Try request with current access token
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
})
// If 401, refresh and retry once
if (response.status === 401) {
const newAccessToken = await refreshAccessToken()
if (!newAccessToken) return response
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newAccessToken}`,
},
})
}
return response
}Server-Side (Next.js API Route)
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify, SignJWT } from 'jose'
export async function POST(request: NextRequest) {
const refreshToken = request.cookies.get('refresh_token')?.value
const csrfToken = request.headers.get('X-CSRF-Token')
// Validate CSRF token
if (csrfToken !== request.cookies.get('csrf_token')?.value) {
return NextResponse.json({ error: 'CSRF token mismatch' }, { status: 403 })
}
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
}
try {
// Verify refresh token
const { payload } = await jwtVerify(refreshToken, publicKey, {
algorithms: ['EdDSA'],
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})
// Generate new access token
const newAccessToken = await new SignJWT({
userId: payload.sub,
role: payload.role,
})
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuedAt()
.setIssuer('https://auth.example.com')
.setAudience('api.example.com')
.setExpirationTime('15m')
.sign(privateKey)
// Generate new refresh token (rotation)
const newRefreshToken = await new SignJWT({
userId: payload.sub,
role: payload.role,
})
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuedAt()
.setIssuer('https://auth.example.com')
.setAudience('api.example.com')
.setExpirationTime('7d')
.sign(privateKey)
// Set new refresh token cookie
const response = NextResponse.json({ access_token: newAccessToken })
response.cookies.set('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60,
path: '/api/auth/refresh',
})
// Revoke old refresh token (if using jti)
if (payload.jti) {
await redis.set(`revoked:${payload.jti}`, '1', {
ex: 7 * 24 * 60 * 60, // Expire after original token lifetime
})
}
return response
} catch (error) {
return NextResponse.json({ error: 'Invalid refresh token' }, { status: 403 })
}
}Token Revocation
Using jti (JWT ID)
Add unique jti claim to enable revocation:
const token = await new SignJWT({ userId: '123' })
.setProtectedHeader({ alg: 'EdDSA' })
.setJti(randomUUID()) // Unique ID
.setExpirationTime('15m')
.sign(privateKey)Revocation Check (Redis)
async function isTokenRevoked(jti: string): Promise<boolean> {
const revoked = await redis.get(`revoked:${jti}`)
return revoked === '1'
}
// Revoke token
async function revokeToken(jti: string, exp: number) {
const ttl = exp - Math.floor(Date.now() / 1000)
await redis.set(`revoked:${jti}`, '1', { ex: ttl })
}Logout Flow
// Server-side logout
export async function POST(request: NextRequest) {
const accessToken = request.headers.get('Authorization')?.split(' ')[1]
const refreshToken = request.cookies.get('refresh_token')?.value
if (accessToken) {
const { payload } = await jwtVerify(accessToken, publicKey)
if (payload.jti) {
await revokeToken(payload.jti, payload.exp!)
}
}
if (refreshToken) {
const { payload } = await jwtVerify(refreshToken, publicKey)
if (payload.jti) {
await revokeToken(payload.jti, payload.exp!)
}
}
const response = NextResponse.json({ success: true })
response.cookies.delete('refresh_token')
response.cookies.delete('csrf_token')
return response
}Common Attacks and Mitigations
Algorithm Confusion Attack
Attack: Change alg header to none or HS256 when expecting RS256.
Mitigation:
// Explicitly specify allowed algorithms
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['EdDSA'], // Never allow "none"
})Token Replay Attack
Attack: Reuse stolen token before expiration.
Mitigation:
- Short access token lifetime (5-15 min)
- Refresh token rotation
- Token revocation via
jti
XSS Token Theft
Attack: JavaScript steals token from localStorage.
Mitigation:
- Store access token in memory only
- Store refresh token in HttpOnly cookie
- Implement Content Security Policy (CSP)
CSRF Attack on Refresh Endpoint
Attack: Malicious site triggers refresh endpoint.
Mitigation:
- Use SameSite=Strict cookies
- Require CSRF token in header
- Validate Origin/Referer headers
Performance Optimization
Token Size
- EdDSA: ~200 bytes (smallest)
- ES256: ~250 bytes
- RS256: ~400 bytes (largest)
Optimization:
- Use EdDSA for smallest tokens
- Minimize custom claims
- Use claim abbreviations (
uidinstead ofuserId)
Verification Caching
Cache public keys for signature verification:
const keyCache = new Map<string, CryptoKey>()
async function getPublicKey(kid: string): Promise<CryptoKey> {
if (keyCache.has(kid)) {
return keyCache.get(kid)!
}
const key = await fetchPublicKey(kid)
keyCache.set(kid, key)
return key
}Testing JWTs
Manual Verification
Use jwt.io debugger (paste token, verify signature).
Unit Tests
import { expect, test } from 'vitest'
test('generates valid JWT', async () => {
const token = await generateAccessToken({ userId: '123' })
const { payload } = await jwtVerify(token, publicKey)
expect(payload.sub).toBe('123')
expect(payload.iss).toBe('https://auth.example.com')
expect(payload.exp).toBeGreaterThan(Date.now() / 1000)
})
test('rejects expired JWT', async () => {
const expiredToken = await new SignJWT({ userId: '123' })
.setProtectedHeader({ alg: 'EdDSA' })
.setExpirationTime('-1m') // 1 minute ago
.sign(privateKey)
await expect(jwtVerify(expiredToken, publicKey)).rejects.toThrow()
})Managed Authentication Services Comparison
Comparison of production-ready managed authentication providers for rapid deployment without infrastructure management.
Table of Contents
- Quick Selection Matrix
- Detailed Comparison
- Clerk
- Auth0
- WorkOS AuthKit
- Supabase Auth
- AWS Cognito
- Firebase Auth
- Feature Matrix
- Decision Framework
- Choose Clerk if:
- Choose Auth0 if:
- Choose WorkOS AuthKit if:
- Choose Supabase Auth if:
- Choose AWS Cognito if:
- Choose Firebase Auth if:
- Migration Considerations
- Cost Optimization Strategies
- Support and SLA
- Additional Resources
Quick Selection Matrix
| Service | Best For | Pricing Model | Free Tier | Enterprise Features |
|---|---|---|---|---|
| Clerk | Startups, Next.js apps | MAU-based | 10K MAU | ✓ SSO, ✓ MFA |
| Auth0 | Enterprise, established | MAU-based | 7.5K MAU | ✓✓ SSO, ✓✓ SAML |
| WorkOS AuthKit | B2B SaaS, enterprise SSO | Per-connection | 1M MAU | ✓✓✓ SCIM, Admin portal |
| Supabase Auth | PostgreSQL users | Infrastructure-based | Generous | ✓ RLS, Database-native |
| AWS Cognito | AWS ecosystem | MAU-based | 50K MAU | ✓ AWS integration |
| Firebase Auth | Mobile-first, Google Cloud | Infrastructure-based | Generous | ✓ Multi-platform SDKs |
Detailed Comparison
Clerk
Best for: Rapid development, startups, Next.js/React applications
Strengths:
- Prebuilt, customizable UI components (sign-in, user profile, organization management)
- Excellent Next.js integration (middleware, server components)
- Webhooks for user lifecycle events
- Built-in user management dashboard
- Organizations and multi-tenancy out-of-the-box
Limitations:
- Newer provider (less enterprise track record)
- Limited social providers compared to Auth0
- Higher cost at scale (MAU-based)
Pricing (2025):
- Free: 10,000 MAU
- Pro: $25/month + $0.02/MAU above 10K
- Enterprise: Custom pricing
Integration Example:
import { ClerkProvider, SignedIn, SignedOut, UserButton, SignInButton } from '@clerk/nextjs';
export default function App({ children }) {
return (
<ClerkProvider>
<SignedOut>
<SignInButton mode="modal" />
</SignedOut>
<SignedIn>
<UserButton />
{children}
</SignedIn>
</ClerkProvider>
);
}---
Auth0
Best for: Enterprise applications, established companies, complex auth requirements
Strengths:
- 25+ social identity providers
- Battle-tested (acquired by Okta)
- Comprehensive SAML/OIDC enterprise SSO
- Advanced security features (bot detection, breached password detection)
- Extensive customization via Actions (serverless functions)
- Strong compliance (SOC 2, HIPAA, PCI DSS)
Limitations:
- Higher learning curve
- More expensive at scale
- Heavier SDK footprint
Pricing (2025):
- Free: 7,500 MAU
- Essentials: $35/month + $0.05/MAU
- Professional: $240/month + $0.13/MAU
- Enterprise: Custom pricing
Integration Example:
import { Auth0Provider, useAuth0 } from '@auth0/auth0-react';
function App() {
return (
<Auth0Provider
domain="your-tenant.auth0.com"
clientId="your-client-id"
redirectUri={window.location.origin}
>
<Profile />
</Auth0Provider>
);
}
function Profile() {
const { user, isAuthenticated, loginWithRedirect, logout } = useAuth0();
return isAuthenticated ? <div>Welcome {user.name}</div> : <button onClick={loginWithRedirect}>Login</button>;
}---
WorkOS AuthKit
Best for: B2B SaaS applications needing enterprise SSO
Strengths:
- Purpose-built for B2B (not B2C)
- Enterprise SSO (SAML, OIDC) without complexity
- Directory Sync (SCIM) for user provisioning
- Admin Portal for enterprise customers
- Transparent, per-connection pricing
Limitations:
- Not suitable for consumer apps
- Fewer social providers
- Less UI customization
Pricing (2025):
- AuthKit: Free up to 1M MAU
- Enterprise SSO: $125/connection/month
- Directory Sync: $250/connection/month
Integration Example:
import { WorkOS } from '@workos-inc/node';
const workos = new WorkOS(process.env.WORKOS_API_KEY);
// Initiate SSO login
const authorizationUrl = workos.sso.getAuthorizationURL({
organization: 'org_12345',
redirectURI: 'https://yourapp.com/callback',
clientID: process.env.WORKOS_CLIENT_ID,
});---
Supabase Auth
Best for: Applications already using PostgreSQL, open-source preference
Strengths:
- Built on PostgreSQL (native Row Level Security)
- Open-source (self-host option)
- Free tier includes everything
- Simple API, minimal SDK
- Integrated with Supabase ecosystem (database, storage, realtime)
Limitations:
- Fewer enterprise SSO options
- Less mature than Auth0/Cognito
- Limited customization UI
Pricing (2025):
- Free: 50,000 MAU
- Pro: $25/month (unlimited MAU, pay for infrastructure)
- Enterprise: Custom pricing
Integration Example:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient('https://your-project.supabase.co', 'your-anon-key');
// Sign in with email
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'password',
});
// Row Level Security automatically enforces access control
const { data: posts } = await supabase
.from('posts')
.select('*')
.eq('user_id', data.user.id); // RLS ensures user sees only their posts---
AWS Cognito
Best for: AWS-native applications, serverless architectures
Strengths:
- Deep AWS integration (Lambda, API Gateway, AppSync)
- Generous free tier (50K MAU)
- Scalable to millions of users
- User pools + identity pools (federated identities)
- MFA, adaptive authentication
Limitations:
- Complex configuration
- AWS-centric (vendor lock-in)
- Less developer-friendly than Clerk
Pricing (2025):
- Free: 50,000 MAU
- $0.00550/MAU beyond free tier
Integration Example:
import { Amplify, Auth } from 'aws-amplify';
Amplify.configure({
Auth: {
region: 'us-east-1',
userPoolId: 'us-east-1_ABC123',
userPoolWebClientId: 'abc123def456',
},
});
const user = await Auth.signIn('username', 'password');---
Firebase Auth
Best for: Mobile applications, Google Cloud ecosystem
Strengths:
- Multi-platform SDKs (iOS, Android, Web, Unity, C++)
- Phone authentication (SMS verification)
- Anonymous authentication (guest mode)
- Seamless Firestore integration
- Real-time user presence
Limitations:
- Google Cloud ecosystem lock-in
- Limited server-side flexibility
- Less suitable for pure backend APIs
Pricing (2025):
- Free: Generous limits (10K SMS verifications/month)
- Pay-as-you-go: $0.06/verification (phone auth)
Integration Example:
import { getAuth, signInWithEmailAndPassword } from 'firebase/auth';
const auth = getAuth();
const userCredential = await signInWithEmailAndPassword(auth, 'email@example.com', 'password');
const user = userCredential.user;---
Feature Matrix
| Feature | Clerk | Auth0 | WorkOS | Supabase | Cognito | Firebase |
|---|---|---|---|---|---|---|
| Social OAuth | 10+ | 25+ | Limited | 10+ | 10+ | 15+ |
| Enterprise SSO (SAML) | ✓ (paid) | ✓✓ | ✓✓✓ | ✗ | ✓ | ✗ |
| Passwordless | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ |
| MFA | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓ |
| User Management UI | ✓✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
| Webhooks | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓ |
| Open Source | ✗ | ✗ | ✗ | ✓✓ | ✗ | ✗ |
| Self-Hosting Option | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ |
| Org/Team Management | ✓✓✓ | ✓ | ✓✓ | ✗ | ✓ | ✗ |
Decision Framework
Choose Clerk if:
- Building a Next.js/React SaaS application
- Need beautiful prebuilt UI components
- Want organizations/multi-tenancy out-of-the-box
- Prioritize developer experience and speed
Choose Auth0 if:
- Building enterprise-grade applications
- Need extensive social providers (25+)
- Require SAML/OIDC enterprise SSO
- Need advanced security features (bot detection, etc.)
- Compliance is critical (HIPAA, PCI DSS)
Choose WorkOS AuthKit if:
- Building B2B SaaS exclusively
- Enterprise SSO is primary requirement
- Need SCIM directory sync
- Want admin portal for enterprise customers
Choose Supabase Auth if:
- Already using PostgreSQL
- Want open-source with self-host option
- Need tight database integration (RLS)
- Want generous free tier without MAU limits
Choose AWS Cognito if:
- Building on AWS infrastructure
- Need deep Lambda/API Gateway integration
- Want generous free tier (50K MAU)
- Can handle configuration complexity
Choose Firebase Auth if:
- Building mobile-first applications
- Need multi-platform SDKs (iOS, Android, Web)
- Want phone authentication (SMS)
- Using Firestore or other Firebase services
Migration Considerations
From self-hosted to managed:
- Export user database with password hashes
- Use bulk user import APIs (Auth0, Cognito support bcrypt/Argon2)
- Implement gradual migration (authenticate against both systems during transition)
- Update redirect URIs and OAuth callbacks
Between managed providers:
- Most support SCIM or bulk user APIs
- Password hashes usually NOT transferable (require password reset)
- Social connections need re-authorization
- Test with small user cohort first
Cost Optimization Strategies
1. Use free tiers strategically - Cognito (50K), Supabase (50K), Clerk (10K) 2. Self-host for very large scale - Beyond 100K MAU, consider Keycloak 3. Leverage social OAuth - Reduce password management overhead 4. Implement caching - Cache user profiles/permissions to reduce API calls 5. Archive inactive users - Many providers charge per MAU (Monthly Active Users)
Support and SLA
| Provider | Community Support | Paid Support | Uptime SLA |
|---|---|---|---|
| Clerk | Discord | Email (Pro+) | 99.9% (Enterprise) |
| Auth0 | Forums | Email/Phone (Pro+) | 99.99% (Enterprise) |
| WorkOS | Dedicated (Enterprise) | 99.95% | |
| Supabase | Discord | Email (Pro+) | 99.9% |
| Cognito | AWS Forums | AWS Support | 99.99% |
| Firebase | Stack Overflow | Google Support | 99.95% |
Additional Resources
- Clerk Docs: https://clerk.com/docs
- Auth0 Docs: https://auth0.com/docs
- WorkOS Docs: https://workos.com/docs
- Supabase Auth: https://supabase.com/docs/guides/auth
- AWS Cognito: https://docs.aws.amazon.com/cognito/
- Firebase Auth: https://firebase.google.com/docs/auth
OAuth 2.1 Implementation Guide
OAuth 2.1 consolidates best practices and security improvements from OAuth 2.0, making PKCE mandatory and removing insecure flows.
Table of Contents
- Key Changes from OAuth 2.0
- PKCE Flow (Authorization Code + PKCE)
- Step 1: Generate Code Verifier and Challenge
- Step 2: Authorization Request
- Step 3: Handle Callback
- Step 4: Token Exchange
- Step 5: Store Tokens Securely
- OAuth 2.1 with Auth.js (Next.js)
- OAuth 2.1 with Authlib (Python FastAPI)
- Redirect URI Validation
- Valid Configuration
- Authorization Request
- Refresh Token Flow
- Device Flow (Replacement for Password Grant)
- Step 1: Device Requests Code
- Step 2: User Enters Code
- Step 3: Device Polls for Token
- Security Checklist
- Common Errors
- Error: PKCE Required
- Error: Redirect URI Mismatch
- Error: Invalid Code Verifier
- Provider-Specific Notes
- GitHub
- Microsoft (Azure AD)
- Auth0
- Testing OAuth 2.1 Flows
Key Changes from OAuth 2.0
| Feature | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| PKCE | Optional | MANDATORY for all clients |
| Implicit Grant | Allowed | REMOVED (security risks) |
| Password Grant | Allowed | REMOVED (use device flow) |
| Redirect URI Matching | Substring allowed | EXACT MATCH ONLY |
| Bearer Token in URL | Allowed | FORBIDDEN |
PKCE Flow (Authorization Code + PKCE)
Step 1: Generate Code Verifier and Challenge
TypeScript:
import { randomBytes, createHash } from 'crypto'
// 1. Generate code_verifier (43-128 characters, base64url)
const codeVerifier = randomBytes(32).toString('base64url') // 43 chars
// 2. Generate code_challenge (SHA-256 hash of verifier)
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url')
// Store codeVerifier in session/memory for later usePython:
import hashlib
import secrets
import base64
# 1. Generate code_verifier
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
# 2. Generate code_challenge
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')Rust:
use sha2::{Sha256, Digest};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
// 1. Generate code_verifier
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 32];
rng.fill(&mut bytes);
let code_verifier = URL_SAFE_NO_PAD.encode(bytes);
// 2. Generate code_challenge
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());Step 2: Authorization Request
Redirect user to authorization endpoint with PKCE parameters.
GET /oauth/authorize HTTP/1.1
Host: auth.provider.com
client_id=your_client_id
redirect_uri=https://app.example.com/callback # EXACT match required
response_type=code
scope=openid profile email
state=random_state_value # CSRF protection
code_challenge=<code_challenge>
code_challenge_method=S256TypeScript Example:
const authUrl = new URL('https://auth.provider.com/oauth/authorize')
authUrl.searchParams.set('client_id', 'your_client_id')
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('state', randomBytes(16).toString('hex'))
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
// Redirect user
window.location.href = authUrl.toString()Step 3: Handle Callback
User is redirected back with authorization code.
GET /callback HTTP/1.1
Host: app.example.com
code=authorization_code_here
state=random_state_valueValidate state parameter to prevent CSRF attacks.
Step 4: Token Exchange
Exchange authorization code for tokens using code_verifier.
POST /oauth/token HTTP/1.1
Host: auth.provider.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
code=authorization_code_here
redirect_uri=https://app.example.com/callback # MUST match step 2
client_id=your_client_id
code_verifier=<code_verifier> # PKCE verificationTypeScript Example:
const tokenResponse = await fetch('https://auth.provider.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: 'https://app.example.com/callback',
client_id: 'your_client_id',
code_verifier: codeVerifier, // From step 1
}),
})
const tokens = await tokenResponse.json()
// {
// access_token: "...",
// refresh_token: "...",
// id_token: "...",
// token_type: "Bearer",
// expires_in: 3600
// }Step 5: Store Tokens Securely
- Access token: Memory only (JavaScript variable)
- Refresh token: HTTP-only cookie with SameSite=Strict
- ID token: Memory (or discard if not needed)
Never store in localStorage or sessionStorage (vulnerable to XSS).
OAuth 2.1 with Auth.js (Next.js)
Auth.js handles PKCE automatically.
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
authorization: {
params: {
prompt: 'consent',
access_type: 'offline',
response_type: 'code', // OAuth 2.1 authorization code flow
},
},
}),
],
session: {
strategy: 'jwt',
maxAge: 7 * 24 * 60 * 60, // 7 days
},
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token
token.refreshToken = account.refresh_token
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken
return session
},
},
})
export const { GET, POST } = handlersOAuth 2.1 with Authlib (Python FastAPI)
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
config = Config('.env')
oauth = OAuth(config)
oauth.register(
name='google',
client_id=config('GOOGLE_CLIENT_ID'),
client_secret=config('GOOGLE_CLIENT_SECRET'),
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile',
'code_challenge_method': 'S256', # PKCE enabled
},
)
@app.get('/login')
async def login(request: Request):
redirect_uri = request.url_for('auth_callback')
return await oauth.google.authorize_redirect(request, redirect_uri)
@app.get('/auth/callback')
async def auth_callback(request: Request):
token = await oauth.google.authorize_access_token(request)
user = await oauth.google.parse_id_token(request, token)
# token contains: access_token, refresh_token, id_token
return userRedirect URI Validation
OAuth 2.1 requires exact match for redirect URIs.
Valid Configuration
{
"client_id": "app123",
"redirect_uris": [
"https://app.example.com/callback",
"https://app.example.com/auth/callback",
"http://localhost:3000/callback"
]
}Authorization Request
redirect_uri=https://app.example.com/callback ✅ Exact match
redirect_uri=https://app.example.com/auth ❌ Not in list
redirect_uri=https://app.example.com/callback?foo=bar ❌ Query params differRefresh Token Flow
When access token expires, use refresh token to get new tokens.
POST /oauth/token HTTP/1.1
Host: auth.provider.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
refresh_token=<refresh_token>
client_id=your_client_idResponse:
{
"access_token": "new_access_token",
"refresh_token": "new_refresh_token", // Rotated
"token_type": "Bearer",
"expires_in": 3600
}Refresh token rotation: Old refresh token is invalidated, new one issued.
Device Flow (Replacement for Password Grant)
For devices without browsers (TVs, IoT), use Device Authorization Grant (RFC 8628).
Step 1: Device Requests Code
POST /oauth/device/code HTTP/1.1
Host: auth.provider.com
client_id=your_client_id
scope=openid profileResponse:
{
"device_code": "device_code_here",
"user_code": "WDJB-MJHT", // User enters this
"verification_uri": "https://auth.provider.com/device",
"expires_in": 1800,
"interval": 5 // Poll every 5 seconds
}Step 2: User Enters Code
Display to user:
Go to https://auth.provider.com/device
Enter code: WDJB-MJHTStep 3: Device Polls for Token
POST /oauth/token HTTP/1.1
Host: auth.provider.com
grant_type=urn:ietf:params:oauth:grant-type:device_code
device_code=device_code_here
client_id=your_client_idPoll every interval seconds until user authorizes or code expires.
Security Checklist
- [ ] PKCE enabled for all flows
- [ ] S256 code challenge method (not plain)
- [ ] Exact redirect URI matching enforced
- [ ] State parameter used for CSRF protection
- [ ] Access tokens short-lived (5-15 minutes)
- [ ] Refresh token rotation implemented
- [ ] Tokens never in URL query parameters
- [ ] TLS 1.2+ for all endpoints
- [ ] No implicit or password grants
- [ ] Redirect URIs validated server-side
Common Errors
Error: PKCE Required
{
"error": "invalid_request",
"error_description": "PKCE is required for this client"
}Solution: Add code_challenge and code_challenge_method=S256 to authorization request.
Error: Redirect URI Mismatch
{
"error": "invalid_request",
"error_description": "redirect_uri does not match"
}Solution: Ensure exact match between registered URI and request URI (including protocol, host, path).
Error: Invalid Code Verifier
{
"error": "invalid_grant",
"error_description": "code_verifier does not match code_challenge"
}Solution: Ensure code_verifier sent in token request matches the one used to generate code_challenge in authorization request.
Provider-Specific Notes
- PKCE automatically enabled for public clients
access_type=offlinerequired for refresh tokensprompt=consentrequired to get refresh token each time
GitHub
- PKCE support added 2023
- Refresh tokens optional (enable in OAuth app settings)
- Scopes:
read:user,user:email,repo
Microsoft (Azure AD)
- PKCE required for all client types (2024+)
- Use
/.well-known/openid-configurationfor discovery - Multi-tenant apps:
commontenant in URL
Auth0
- PKCE enabled by default for SPAs
- Custom domains supported for branded login
- Universal Login recommended for security
Testing OAuth 2.1 Flows
Use scripts/validate_oauth_config.py to validate OAuth 2.1 compliance:
python scripts/validate_oauth_config.py --provider googleChecks:
- PKCE parameters present
- Redirect URI exact match
- No forbidden grant types
- TLS 1.2+ enforcement
Related skills
FAQ
Is PKCE required in OAuth 2.1?
Yes, PKCE with the S256 method is mandatory for all OAuth 2.1 flows, not just public clients.
Which authorization model should I pick?
RBAC with Casbin for simple roles under 20, ABAC with OPA or Cerbos for complex attribute rules, and ReBAC with SpiceDB for relationship-based permissions.