betterauth-fastapi-jwt-bridge

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Better Auth + FastAPI JWT Bridge

Better Auth + FastAPI JWT 认证桥接

Implement production-ready JWT authentication between Better Auth (Next.js) and FastAPI using JWKS verification for secure, stateless authentication.
使用JWKS验证在Better Auth(Next.js)与FastAPI之间实现可用于生产环境的JWT认证,以实现安全的无状态认证。

Architecture

架构

User Login (Frontend)
Better Auth → Issues JWT Token
Frontend API Request → Authorization: Bearer <token>
FastAPI Backend → Verifies JWT with JWKS → Returns filtered data
用户登录(前端)
Better Auth → 签发JWT令牌
前端API请求 → Authorization: Bearer <token>
FastAPI后端 → 使用JWKS验证JWT → 返回过滤后的数据

Quick Start Workflow

快速开始流程

Step 1: Enable JWT in Better Auth (Frontend)

步骤1:在Better Auth(前端)中启用JWT

typescript
// 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:
bash
undefined
typescript
// lib/auth.ts
import { betterAuth } from "better-auth"
import { jwt } from "better-auth/plugins"

export const auth = betterAuth({
    plugins: [jwt()],  // 启用JWT + JWKS端点
    // ... 其他配置
})
需要执行数据库迁移:
添加JWT插件后,运行迁移以创建所需表:
bash
undefined

Next.js (Better Auth CLI)

Next.js(Better Auth CLI)

npx @better-auth/cli migrate

**⚠️ IMPORTANT - Two Separate Tables Required:**

1. **`session` table must have `token` column** (core Better Auth requirement)
   - Error: `column "token" of relation "session" does not exist`
   - Fix: See [Database Schema Issues](references/troubleshooting.md#database-schema-issues)

2. **`jwks` table must exist** (JWT plugin requirement)
   - Error: `relation "jwks" does not exist`
   - Fix: See [Database Schema Issues](references/troubleshooting.md#database-schema-issues)

These are **separate migrations**. The JWT plugin creates the `jwks` table but does NOT modify the `session` table.
npx @better-auth/cli migrate

**⚠️ 重要 - 需要两张独立的表:**

1. **`session`表必须包含`token`列**(Better Auth核心要求)
   - 错误:`column "token" of relation "session" does not exist`
   - 修复方案:查看[数据库架构问题](references/troubleshooting.md#database-schema-issues)

2. **必须存在`jwks`表**(JWT插件要求)
   - 错误:`relation "jwks" does not exist`
   - 修复方案:查看[数据库架构问题](references/troubleshooting.md#database-schema-issues)

这是**两次独立的迁移**。JWT插件会创建`jwks`表,但不会修改`session`表。

Step 2: Verify JWKS Endpoint

步骤2:验证JWKS端点

Test the JWKS endpoint is working:
bash
python scripts/verify_jwks.py http://localhost:3000/api/auth/jwks
测试JWKS端点是否正常工作:
bash
python scripts/verify_jwks.py http://localhost:3000/api/auth/jwks

Step 3: Implement Backend Verification

步骤3:实现后端验证

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:
bash
pip install fastapi python-jose[cryptography] pyjwt cryptography httpx
assets/
中的模板复制到你的FastAPI项目中:
  • assets/jwt_verification.py
    backend/app/auth/jwt_verification.py
  • assets/auth_dependencies.py
    backend/app/auth/dependencies.py
安装依赖:
bash
pip install fastapi python-jose[cryptography] pyjwt cryptography httpx

Step 4: Protect API Routes

步骤4:保护API路由

python
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)
python
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与认证用户匹配
    return get_user_tasks(user_id)

Step 5: Configure Frontend API Client

步骤5:配置前端API客户端

Copy
assets/api_client.ts
to
frontend/lib/api-client.ts
and use:
typescript
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
:
typescript
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.
assets/api_client.ts
复制到
frontend/lib/api-client.ts
并使用:
typescript
import { getTasks, createTask } from "@/lib/api-client"

const tasks = await getTasks(userId)
⚠️ React组件模式:
Better Auth未提供
useSession()
钩子。请结合
useEffect
使用
authClient.getSession()
typescript
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>欢迎 {user?.name}</div>
}
完整示例请查看前端集成问题

Better Auth UUID Integration (Hybrid ID Architecture)

Better Auth UUID集成(混合ID架构)

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).
解决的问题: Better Auth内部使用字符串ID,但应用通常需要UUID以确保API路由和数据库外键的类型一致性。
解决方案: 混合ID方案 - 用户表同时包含
id
(字符串,Better Auth要求)和
uuid
(UUID,应用使用)。

Database Schema

数据库架构

sql
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,
    ...
);
sql
CREATE TABLE "user" (
    id VARCHAR PRIMARY KEY,              -- Better Auth字符串ID
    uuid UUID UNIQUE NOT NULL,           -- 应用UUID ⭐
    email VARCHAR UNIQUE NOT NULL,
    "emailVerified" BOOLEAN DEFAULT FALSE,
    name VARCHAR,
    "createdAt" TIMESTAMP NOT NULL,
    "updatedAt" TIMESTAMP NOT NULL
);

-- 由数据库自动生成UUID
ALTER TABLE "user" ALTER COLUMN uuid SET DEFAULT gen_random_uuid();

-- 所有外键指向user.uuid
CREATE TABLE tasks (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES "user"(uuid) ON DELETE CASCADE,  -- ⭐ 外键指向uuid
    title VARCHAR NOT NULL,
    ...
);

Frontend Configuration (Better Auth)

前端配置(Better Auth)

Add UUID generation hook and JWT custom claim:
typescript
// 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
        }
      },
    }),
  ],
})
添加UUID生成钩子和JWT自定义声明:
typescript
// 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,

  // 用于获取数据库生成的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 }
      }
    }
  },

  // 在JWT负载中包含UUID
  plugins: [
    jwt({
      algorithm: "EdDSA",
      async jwt(user, session) {
        return {
          uuid: user.uuid,  // ⭐ 供后端使用的自定义声明
        }
      },
    }),
  ],
})

Backend Pattern (FastAPI)

后端模式(FastAPI)

Extract UUID from JWT custom claim (not
sub
):
python
undefined
从JWT自定义声明中提取UUID(而非
sub
):
python
undefined

backend/app/auth/dependencies.py

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.
from uuid import UUID
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: payload = verify_jwt_token(token)
# 从自定义声明中提取UUID(而非'sub')
user_uuid_str = payload.get("uuid")  # ⭐
user_uuid = UUID(user_uuid_str)

# 通过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, # 来自URL路径 current_user: User = Depends(get_current_user) ) -> User: # 比较UUID(而非字符串ID) if current_user.uuid != user_id: raise HTTPException(403, "未授权") return current_user

**核心模式:** 始终通过`User.uuid`查询用户,并与JWT自定义声明中的UUID进行验证。

Key Components

核心组件

1. JWKS Verification Flow

1. JWKS验证流程

  1. Fetch JWKS (cached) from Better Auth endpoint
  2. Extract kid (key ID) from JWT token header
  3. Find matching public key in JWKS by kid
  4. Verify signature using Ed25519 public key
  5. Validate claims (issuer, audience, expiration)
  6. Extract user info from payload (
    sub
    claim)
  1. 获取JWKS(已缓存)从Better Auth端点
  2. 提取kid(密钥ID)从JWT令牌头
  3. 在JWKS中查找匹配的公钥通过kid
  4. 使用Ed25519公钥验证签名
  5. 验证声明(签发者、受众、过期时间)
  6. 从负载中提取用户信息
    sub
    声明)

2. User Isolation Pattern

2. 用户隔离模式

Always verify
user_id
from JWT matches
user_id
in URL:
python
if current_user["user_id"] != user_id:
    raise HTTPException(status_code=403, detail="Not authorized")
This prevents users from accessing other users' data.
始终验证JWT中的
user_id
与URL中的
user_id
是否匹配:
python
if current_user["user_id"] != user_id:
    raise HTTPException(status_code=403, detail="未授权")
这可防止用户访问其他用户的数据。

3. JWT Payload Structure (Updated with UUID Integration)

3. JWT负载结构(集成UUID后更新)

json
{
  "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.
json
{
  "sub": "user_abc123",       // Better Auth字符串ID
  "uuid": "a1b2c3d4-e5f6...", // 应用UUID(自定义声明)⭐
  "email": "user@example.com",
  "name": "User Name",
  "iat": 1234567890,          // 签发时间
  "exp": 1234567890,          // 过期时间
  "iss": "http://localhost:3000",
  "aud": "http://localhost:3000"
}
重要提示:
uuid
自定义声明用于后端用户识别和数据库查询。Better Auth使用字符串ID(
sub
)管理用户,而应用使用UUID(
uuid
)以确保类型一致性。

Environment Configuration

环境配置

Frontend (.env.local):
bash
BETTER_AUTH_SECRET="min-32-chars-secret"
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="http://localhost:8000"
Backend (.env):
bash
BETTER_AUTH_URL="http://localhost:3000"
DATABASE_URL="postgresql://..."
前端(.env.local):
bash
BETTER_AUTH_SECRET="min-32-chars-secret"
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="http://localhost:8000"
后端(.env):
bash
BETTER_AUTH_URL="http://localhost:3000"
DATABASE_URL="postgresql://..."

Testing & Validation

测试与验证

Test JWKS Endpoint

测试JWKS端点

bash
python scripts/verify_jwks.py http://localhost:3000/api/auth/jwks
Expected output shows public keys with
kid
,
kty
,
crv
, and
x
fields.
bash
python scripts/verify_jwks.py http://localhost:3000/api/auth/jwks
预期输出应显示包含
kid
kty
crv
x
字段的公钥。

Test JWT Verification

测试JWT验证

bash
python scripts/test_jwt_verification.py \
  --jwks-url http://localhost:3000/api/auth/jwks \
  --token "eyJhbGci..."
bash
python scripts/test_jwt_verification.py \
  --jwks-url http://localhost:3000/api/auth/jwks \
  --token "eyJhbGci..."

Troubleshooting

故障排查

Authentication Issues

认证问题

IssueSolution
"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
问题解决方案
"relation 'jwks' does not exist"创建JWKS表迁移 - 查看数据库架构问题
"column 'token' does not exist"为session表添加
token
列 - 查看数据库架构问题
"Token missing UUID (uuid claim)"配置Better Auth钩子和JWT插件 - 查看UUID集成问题
"User not found after registration"双重认证系统冲突 - 查看UUID集成问题
"authClient.useSession is not a function"在useEffect中使用
authClient.getSession()
- 查看前端集成问题
"No authentication token available"使用
session.data.session.token
而非
session.session.token
- 查看前端集成问题
"Unable to find matching signing key"清除jwt_verification.py中的JWKS缓存
"Token has expired"前端需要刷新会话
"Invalid token claims"检查签发者/受众是否与BETTER_AUTH_URL匹配
403 Forbidden(UUID不匹配)确保进行UUID比较,而非字符串与UUID比较 - 查看UUID集成问题

Frontend-Backend Integration Issues (NEW - 2026-01-02)

前端-后端集成问题(新增 - 2026-01-02)

IssueRoot CauseSolution
Tasks not displaying despite 200 OKBackend returns array, frontend expects paginated objectHandle both formats with
Array.isArray()
check - see Frontend-Backend Integration
Tag filtering crashesBackend 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 checkAdd null checks with defaults for optional fields - see Priority Sorting
Tags not saving to databaseTaskCreate schema doesn't accept tags fieldUse multi-step operation: create task, then assign tags - see Tag Assignment
Edit form fields blankUncontrolled components + field name mismatches + datetime formatUse controlled components, match field names, convert datetime - see Edit Form
500 Error: timezone comparisonComparing offset-naive and offset-aware datetimesNormalize both to UTC before comparison - see Timezone Fix
Tag color validation failsFrontend required color, backend allows optionalMake color optional in Zod schema, provide defaults - see Tag Color
Tag filter checkboxes brokenBackend 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:
  1. Always read backend Pydantic schemas before writing frontend types
  2. Handle optional fields with null checks and defaults
  3. Use controlled components for pre-filled forms
  4. Match field names exactly between frontend and backend
  5. Test with actual backend responses, not mocked data
See
references/troubleshooting.md
for detailed solutions and prevention strategies.
问题根本原因解决方案
返回200 OK但任务未显示后端返回数组,前端期望分页对象使用
Array.isArray()
检查以兼容两种格式 - 查看前端-后端集成
标签筛选功能崩溃后端返回标签对象
{id, name, color}
,前端期望
number[]
更新TypeScript类型以匹配Pydantic架构 - 查看标签筛选
分页显示"NaN"可选优先级字段在运算时未做空值检查为可选字段添加空值检查和默认值 - 查看优先级排序
标签未保存到数据库TaskCreate架构不接受tags字段使用多步操作:先创建任务,再分配标签 - 查看标签分配
编辑表单字段为空非受控组件+字段名称不匹配+日期时间格式问题使用受控组件,匹配字段名称,转换日期时间格式 - 查看编辑表单
500错误:时区比较比较无时区偏移和有时区偏移的日期时间在比较前将两者统一为UTC时间 - 查看时区修复
标签颜色验证失败前端要求必填颜色,后端允许可选在Zod架构中将颜色设为可选,提供默认值 - 查看标签颜色
标签筛选复选框失效后端返回
id: number
,FilterContext使用
string[]
将ID转换为字符串后再进行比较 - 查看标签筛选
📚 必读内容: 查看故障排查指南中的前端-后端集成问题章节,获取带代码示例的详细修复方案。该章节记录了实现过程中发现的8个关键问题及其解决方法。
核心经验:
  1. 在编写前端类型前务必阅读后端Pydantic架构
  2. 为可选字段添加空值检查和默认值
  3. 使用受控组件实现预填充表单
  4. 确保前端与后端的字段名称完全匹配
  5. 使用实际后端响应进行测试,而非模拟数据
详细解决方案和预防策略请查看
references/troubleshooting.md

Advanced Topics

高级主题

JWKS Caching Strategy

JWKS缓存策略

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.
本实现使用
@lru_cache
缓存JWKS响应:
  • 如果令牌包含未知
    kid
    则失效缓存
  • 公钥极少变更(可安全缓存)
  • 减少对Better Auth的网络请求
实现细节请查看
references/jwks-approach.md

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.
上线前需完成:
  • ✅ 所有API调用仅使用HTTPS
  • ✅ 已验证令牌过期时间
  • ✅ 已验证签发者/受众声明
  • ✅ 已强制实施用户ID授权
  • ✅ 已正确配置CORS
  • ✅ 错误信息未泄露敏感信息
完整清单请查看
references/security-checklist.md

Resources

资源

scripts/

scripts/

  • verify_jwks.py
    - Test JWKS endpoint availability
  • test_jwt_verification.py
    - Validate JWT token verification
  • verify_jwks.py
    - 测试JWKS端点可用性
  • test_jwt_verification.py
    - 验证JWT令牌验证功能

references/

references/

  • jwks-approach.md
    - Detailed JWKS implementation guide
  • security-checklist.md
    - Production security requirements
  • troubleshooting.md
    - Common issues and fixes
  • jwks-approach.md
    - JWKS实现详细指南
  • security-checklist.md
    - 生产环境安全要求
  • troubleshooting.md
    - 常见问题与修复方案

assets/

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)
  • jwt_verification.py
    - 完整的JWKS验证模块模板
  • auth_dependencies.py
    - FastAPI依赖项模板
  • api_client.ts
    - 前端API客户端模板
  • better_auth_migrations.py
    - 用于Better Auth表的Alembic迁移模板(包含token列修复)

Why JWKS Over Shared Secret?

为何选择JWKS而非共享密钥?

AspectJWKSShared Secret
Security✅ Asymmetric (more secure)⚠️ Symmetric (less secure)
Scalability✅ Multiple backends⚠️ Secret must be shared
Production✅ Recommended⚠️ Development only
ComplexityMediumSimple
Recommendation: Always use JWKS for production.
方面JWKS共享密钥
安全性✅ 非对称(更安全)⚠️ 对称(安全性较低)
可扩展性✅ 支持多个后端⚠️ 必须共享密钥
生产环境适用性✅ 推荐使用⚠️ 仅适用于开发环境
复杂度中等简单
建议: 生产环境中始终使用JWKS。