Better Auth + FastAPI JWT Bridge
Implement production-ready JWT authentication between Better Auth (Next.js) and FastAPI using JWKS verification for secure, stateless authentication.
Architecture
User Login (Frontend) ↓ Better Auth → Issues JWT Token ↓ Frontend API Request → Authorization: Bearer <token> ↓ FastAPI Backend → Verifies JWT with JWKS → Returns filtered data
Quick Start Workflow
Step 1: Enable JWT in Better Auth (Frontend)
// lib/auth.ts import { betterAuth } from "better-auth" import { jwt } from "better-auth/plugins"
export const auth = betterAuth({ plugins: [jwt()], // Enables JWT + JWKS endpoint // ... other config })
Database Migration Required:
After adding JWT plugin, run migrations to create required tables:
Next.js (Better Auth CLI)
npx @better-auth/cli migrate
⚠️ IMPORTANT - Two Separate Tables Required:
session table must have token column (core Better Auth requirement)
-
Error: column "token" of relation "session" does not exist
-
Fix: See Database Schema Issues
jwks table must exist (JWT plugin requirement)
-
Error: relation "jwks" does not exist
-
Fix: See Database Schema Issues
These are separate migrations. The JWT plugin creates the jwks table but does NOT modify the session table.
Step 2: Verify JWKS Endpoint
Test the JWKS endpoint is working:
python scripts/verify_jwks.py http://localhost:3000/api/auth/jwks
Step 3: Implement Backend Verification
Copy templates from assets/ to your FastAPI project:
-
assets/jwt_verification.py → backend/app/auth/jwt_verification.py
-
assets/auth_dependencies.py → backend/app/auth/dependencies.py
Install dependencies:
pip install fastapi python-jose[cryptography] pyjwt cryptography httpx
Step 4: Protect API Routes
from app.auth.dependencies import verify_user_access
@router.get("/{user_id}/tasks") async def get_tasks( user_id: str, user: dict = Depends(verify_user_access) ): # user_id is verified to match authenticated user return get_user_tasks(user_id)
Step 5: Configure Frontend API Client
Copy assets/api_client.ts to frontend/lib/api-client.ts and use:
import { getTasks, createTask } from "@/lib/api-client"
const tasks = await getTasks(userId)
⚠️ React Component Pattern:
Better Auth does NOT provide a useSession() hook. Use authClient.getSession() with useEffect :
import { useState, useEffect } from "react" import { authClient } from "@/lib/auth-client"
function MyComponent() { const [user, setUser] = useState(null)
useEffect(() => { async function loadSession() { const session = await authClient.getSession() if (session?.data?.user) { setUser(session.data.user) } } loadSession() }, [])
return <div>Welcome {user?.name}</div> }
See Frontend Integration Issues for complete examples.
Better Auth UUID Integration (Hybrid ID Architecture)
Problem Solved: Better Auth uses String IDs internally, but applications often need UUID for type consistency across API routes and database foreign keys.
Solution: Hybrid ID approach - User table has both id (String, Better Auth requirement) and uuid (UUID, application use).
Database Schema
CREATE TABLE "user" ( id VARCHAR PRIMARY KEY, -- Better Auth String ID uuid UUID UNIQUE NOT NULL, -- Application UUID ⭐ email VARCHAR UNIQUE NOT NULL, "emailVerified" BOOLEAN DEFAULT FALSE, name VARCHAR, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL );
-- UUID auto-generated by database ALTER TABLE "user" ALTER COLUMN uuid SET DEFAULT gen_random_uuid();
-- All foreign keys point to user.uuid CREATE TABLE tasks ( id UUID PRIMARY KEY, user_id UUID REFERENCES "user"(uuid) ON DELETE CASCADE, -- ⭐ FK to uuid title VARCHAR NOT NULL, ... );
Frontend Configuration (Better Auth)
Add UUID generation hook and JWT custom claim:
// lib/auth.ts import { betterAuth } from "better-auth" import { jwt } from "better-auth/plugins" import { Pool } from "pg"
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export const auth = betterAuth({ database: pool,
// Hook to fetch database-generated UUID hooks: { user: { created: async ({ user }) => { const result = await pool.query( 'SELECT uuid FROM "user" WHERE id = $1', [user.id] ) const uuid = result.rows[0]?.uuid return { ...user, uuid } } } },
// Include UUID in JWT payload plugins: [ jwt({ algorithm: "EdDSA", async jwt(user, session) { return { uuid: user.uuid, // ⭐ Custom claim for backend } }, }), ], })
Backend Pattern (FastAPI)
Extract UUID from JWT custom claim (not sub ):
backend/app/auth/dependencies.py
from uuid import UUID
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: payload = verify_jwt_token(token)
# Extract UUID from custom claim (not 'sub')
user_uuid_str = payload.get("uuid") # ⭐
user_uuid = UUID(user_uuid_str)
# Query by UUID
user = await session.execute(
select(User).where(User.uuid == user_uuid)
)
return user.scalar_one_or_none()
async def verify_user_match( user_id: UUID, # From URL path current_user: User = Depends(get_current_user) ) -> User: # Compare UUIDs (not String IDs) if current_user.uuid != user_id: raise HTTPException(403, "Not authorized") return current_user
Key Pattern: Always query by User.uuid and validate against UUID from JWT custom claim.
Key Components
- JWKS Verification Flow
-
Fetch JWKS (cached) from Better Auth endpoint
-
Extract kid (key ID) from JWT token header
-
Find matching public key in JWKS by kid
-
Verify signature using Ed25519 public key
-
Validate claims (issuer, audience, expiration)
-
Extract user info from payload (sub claim)
- User Isolation Pattern
Always verify user_id from JWT matches user_id in URL:
if current_user["user_id"] != user_id: raise HTTPException(status_code=403, detail="Not authorized")
This prevents users from accessing other users' data.
- JWT Payload Structure (Updated with UUID Integration)
{ "sub": "user_abc123", // Better Auth String ID "uuid": "a1b2c3d4-e5f6...", // Application UUID (custom claim) ⭐ "email": "user@example.com", "name": "User Name", "iat": 1234567890, // Issued at "exp": 1234567890, // Expiration "iss": "http://localhost:3000", "aud": "http://localhost:3000" }
Important: The uuid custom claim is used for backend user identification and database queries. Better Auth manages users with String IDs (sub ), while the application uses UUIDs (uuid ) for type consistency.
Environment Configuration
Frontend (.env.local):
BETTER_AUTH_SECRET="min-32-chars-secret" BETTER_AUTH_URL="http://localhost:3000" NEXT_PUBLIC_API_URL="http://localhost:8000"
Backend (.env):
BETTER_AUTH_URL="http://localhost:3000" DATABASE_URL="postgresql://..."
Testing & Validation
Test JWKS Endpoint
python scripts/verify_jwks.py http://localhost:3000/api/auth/jwks
Expected output shows public keys with kid , kty , crv , and x fields.
Test JWT Verification
python scripts/test_jwt_verification.py
--jwks-url http://localhost:3000/api/auth/jwks
--token "eyJhbGci..."
Troubleshooting
Authentication Issues
Issue Solution
"relation 'jwks' does not exist" Create JWKS table migration - see Database Schema Issues
"column 'token' does not exist" Add token column to session table - see Database Schema Issues
"Token missing UUID (uuid claim)" Configure Better Auth hook and JWT plugin - see UUID Integration Issues
"User not found after registration" Dual auth system conflict - see UUID Integration Issues
"authClient.useSession is not a function" Use authClient.getSession() in useEffect - see Frontend Integration Issues
"No authentication token available" Use session.data.session.token not session.session.token
- see Frontend Integration Issues
"Unable to find matching signing key" Clear JWKS cache in jwt_verification.py
"Token has expired" Frontend needs to refresh session
"Invalid token claims" Check issuer/audience match BETTER_AUTH_URL
403 Forbidden (UUID mismatch) Ensure UUID comparison, not String vs UUID - see UUID Integration Issues
Frontend-Backend Integration Issues (NEW - 2026-01-02)
Issue Root Cause Solution
Tasks not displaying despite 200 OK Backend returns array, frontend expects paginated object Handle both formats with Array.isArray() check - see Frontend-Backend Integration
Tag filtering crashes Backend returns tag objects {id, name, color} , frontend expected number[]
Update TypeScript types to match Pydantic schemas - see Tag Filtering
Pagination shows "NaN" Optional priority field used in arithmetic without null check Add null checks with defaults for optional fields - see Priority Sorting
Tags not saving to database TaskCreate schema doesn't accept tags field Use multi-step operation: create task, then assign tags - see Tag Assignment
Edit form fields blank Uncontrolled components + field name mismatches + datetime format Use controlled components, match field names, convert datetime - see Edit Form
500 Error: timezone comparison Comparing offset-naive and offset-aware datetimes Normalize both to UTC before comparison - see Timezone Fix
Tag color validation fails Frontend required color, backend allows optional Make color optional in Zod schema, provide defaults - see Tag Color
Tag filter checkboxes broken Backend returns id: number , FilterContext uses string[]
Convert IDs to strings for comparison - see Tag Filters
📚 Critical Reading: See Frontend-Backend Integration Issues section in troubleshooting guide for detailed fixes with code examples. This section documents 8 critical issues discovered during implementation and their resolutions.
Key Learnings:
-
Always read backend Pydantic schemas before writing frontend types
-
Handle optional fields with null checks and defaults
-
Use controlled components for pre-filled forms
-
Match field names exactly between frontend and backend
-
Test with actual backend responses, not mocked data
See references/troubleshooting.md for detailed solutions and prevention strategies.
Advanced Topics
JWKS Caching Strategy
The implementation uses @lru_cache to cache JWKS responses:
-
Cache invalidated if token has unknown kid
-
Public keys rarely change (safe to cache)
-
Reduces network calls to Better Auth
See references/jwks-approach.md for implementation details.
Security Checklist
Before production:
-
✅ HTTPS only for all API calls
-
✅ Token expiration validated
-
✅ Issuer/audience claims verified
-
✅ User ID authorization enforced
-
✅ CORS properly configured
-
✅ Error messages don't leak sensitive info
See references/security-checklist.md for complete list.
Resources
scripts/
-
verify_jwks.py
-
Test JWKS endpoint availability
-
test_jwt_verification.py
-
Validate JWT token verification
references/
-
jwks-approach.md
-
Detailed JWKS implementation guide
-
security-checklist.md
-
Production security requirements
-
troubleshooting.md
-
Common issues and fixes
assets/
-
jwt_verification.py
-
Complete JWKS verification module template
-
auth_dependencies.py
-
FastAPI dependencies template
-
api_client.ts
-
Frontend API client template
-
better_auth_migrations.py
-
Alembic migration templates for Better Auth tables (including token column fix)
Why JWKS Over Shared Secret?
Aspect JWKS Shared Secret
Security ✅ Asymmetric (more secure) ⚠️ Symmetric (less secure)
Scalability ✅ Multiple backends ⚠️ Secret must be shared
Production ✅ Recommended ⚠️ Development only
Complexity Medium Simple
Recommendation: Always use JWKS for production.