Overview
Better Auth integration with Convex for type-safe authentication.
In this guide, we'll explore better-convex's integration with Better Auth. You'll learn the key concepts, understand the architecture, and see what components are available for both server and client.
Overview
better-convex provides a seamless integration between Better Auth and Convex:
| Feature | Description |
|---|---|
| Convex as database adapter | Store auth data in Convex tables |
| Type-safe triggers | Hooks for user/session lifecycle events |
| React hooks | useAuth and conditional rendering |
| Next.js integration | Server-side caller with JWT caching |
Let's understand the key concepts.
Key Concepts
Local vs Component Installation
| Approach | Schema Location | Database Access |
|---|---|---|
Component (@convex-dev/better-auth) | Component schema | Via ctx.runQuery/ctx.runMutation |
Local (better-convex/auth) | Your app schema | Direct ctx.db access |
better-convex uses the local approach:
- Auth tables live in your app schema
- Triggers can directly access and modify your app tables
- Single transaction for complex operations
Two Adapters
| Adapter | Used In | Access |
|---|---|---|
adapter via getAuth(ctx) | Queries, mutations | Direct DB access |
httpAdapter via createAuth(ctx) | HTTP routes, actions | HTTP context |
// Queries/mutations: direct DB access
const auth = getAuth(ctx);
// HTTP routes/actions: HTTP adapter
const auth = createAuth(ctx);Authentication Flow
Let's understand how authentication works. Every request - whether from SSR or client - goes through the same two-step validation process:
┌─────────────────────────────────────────────────────────────────────────┐
│ REQUEST (SSR or WebSocket) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 1: JWT Validation (cryptographic) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ • Decode JWT payload │ │
│ │ • Verify signature using JWKS keys │ │
│ │ • Check exp claim (is token expired?) │ │
│ │ • Extract sessionId, userId from claims │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Static JWKS (recommended) │ Dynamic JWKS │ │
│ │ Keys embedded in env var │ Fetched from auth server │ │
│ │ → Instant verification │ → +100-400ms per request │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 2: Session Lookup (database) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Query: session._id = sessionId AND expiresAt > now │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ • Session found & valid → ✅ Request proceeds │
│ • Session not found → ❌ 401 (revoked or never existed) │
│ • Session expired → ❌ 401 (natural expiration) │
└─────────────────────────────────────────────────────────────────────────┘This two-step process is important: Static JWKS speeds up Step 1, but Step 2 always hits the database. That's why session revocation works immediately - even if the JWT is cryptographically valid, the session lookup fails.
JWT vs Session
| Component | Storage | Can Be Invalidated? | Default Lifetime | Purpose |
|---|---|---|---|---|
| JWT | Cookie (signed) | ❌ No (stateless) | 15 minutes | Fast identity verification |
| Session | Convex DB | ✅ Yes (stateful) | 30 days | Authorization source of truth |
The JWT is like a temporary badge - it proves who you are, but can't be revoked. The session is the master record - delete it, and the badge becomes useless.
SSR vs Client: Same Validation, Different Transport
Both SSR and client requests go through the same two-step validation above. The difference is how they get there:
| SSR (HTTP) | Client (WebSocket) | |
|---|---|---|
| Transport | HTTP request per query | Persistent connection |
| Token source | Cookie or fetch from /api/auth/convex/token | Sent during WebSocket handshake |
| Validation timing | Per request | Once at connection, then cached |
| JWKS impact | +100-400ms per request (if dynamic) | +100-400ms blocking handshake (if dynamic) |
Why Static JWKS matters: With dynamic JWKS, WebSocket queries are blocked for 100-400ms while Convex fetches signing keys. With static JWKS, validation is instant. This is especially noticeable on page load when multiple queries fire at once.
All Auth States
Here's what happens in each scenario. Note how Step 1 (JWT) and Step 2 (Session) interact:
| Scenario | Step 1: JWT | Step 2: Session | Result | User Experience |
|---|---|---|---|---|
| Normal | ✅ Valid | ✅ Valid | 200 OK | Access granted |
| User signs out | 🗑️ Deleted | 🗑️ Deleted | 401 | Redirected to login |
| Admin revokes session | ✅ Valid | 🗑️ Deleted | 401 | Logged out on next request |
| JWT expired, session valid | ❌ Expired | ✅ Valid | Auto-refresh → 200 | Transparent |
| JWT expired, session expired | ❌ Expired | ❌ Expired | 401 | Redirected to login |
| JWT valid, session expired | ✅ Valid | ❌ Expired | 401 | Logged out on next request |
| User banned | ✅ Valid | ✅ Valid (banned flag) | 403 | "Account banned" |
Key insight: JWT validity doesn't guarantee access. The session lookup (Step 2) is the source of truth.
Session Lifecycle
Sign Out
When a user signs out, both the JWT cookie and session are deleted:
POST /api/auth/sign-out
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. Delete session from Convex DB │
│ 2. Delete JWT cookie (Set-Cookie: better-auth.jwt=; Max-Age=0) │
│ 3. Client: isAuthenticated = false, unsubscribe WebSocket queries │
└─────────────────────────────────────────────────────────────────────────┘Admin Revokes Session
When an admin revokes a session, only the database record is deleted. The user's JWT cookie remains, but becomes useless:
Admin: POST /api/auth/admin/revoke-user-session
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Session deleted from DB (JWT cookie still exists in user's browser) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ User's next request: │
│ Step 1: JWT decoded successfully ✅ │
│ Step 2: Session lookup → null ❌ │
│ Result: 401 UNAUTHORIZED │
└─────────────────────────────────────────────────────────────────────────┘Security note: Revocation takes effect on the user's next request. For sensitive operations requiring immediate invalidation, use Better Auth's sensitiveSessionMiddleware.
JWT Expiration & Auto-Refresh
When a JWT expires (default: 15 min), the client automatically fetches a fresh one - if the session is still valid:
Client: JWT expired (exp < now)
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ GET /api/auth/convex/token (automatic via ConvexAuthProvider) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Server checks session in DB: │
│ • Session valid → Issue new JWT, set cookie │
│ • Session invalid → 401, redirect to login │
└─────────────────────────────────────────────────────────────────────────┘The client uses a 60-second leeway when checking expiration to prevent edge cases where the token expires mid-request.
Request Flows
Now let's see exactly what happens for SSR and client requests. This is useful for debugging.
SSR Flow (HTTP Prefetching)
When you call caller.posts.list() on the server:
┌─────────────────────────────────────────────────────────────────────────┐
│ SSR REQUEST (server-side data fetching) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. Get JWT Token │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ JWT Caching ENABLED (default) │ │
│ │ ├─ Check cookie for cached JWT │ │
│ │ ├─ If valid & not expired → use cached token (0ms) │ │
│ │ └─ If missing/expired → fetch fresh token from auth endpoint │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 2. HTTP Request to Convex (fetchQuery) │
│ Server → Convex backend with JWT in Authorization header │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 3. Token Validation (Step 1 from above) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Static JWKS (recommended) │ │
│ │ ├─ Parse JWT claims │ │
│ │ └─ Verify signature using embedded keys │ │
│ │ ADDED LATENCY: ~0ms │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Dynamic JWKS (not recommended) │ │
│ │ ├─ Fetch OIDC discovery ← +50-200ms network call │ │
│ │ ├─ Fetch JWKS keys ← +50-200ms network call │ │
│ │ └─ Verify signature │ │
│ │ ADDED LATENCY: +100-400ms per request │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 4. Session Lookup (Step 2 from above) │
│ Query: session._id = sessionId AND expiresAt > now │
│ ├─ Session valid → ✅ Query executes │
│ └─ Session invalid → ❌ 401 Unauthorized │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 5. Query Executes & Response │
│ Data cached in TanStack Query, rendered to HTML │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 6. Hydration (fire-and-forget preloading) │
│ ├─ HTML sent to browser with dehydrated query cache │
│ ├─ Client hydrates with prefetched data (instant render) │
│ └─ WebSocket subscribes for real-time updates │
└─────────────────────────────────────────────────────────────────────────┘Client Flow (WebSocket)
When you use useCRPC().posts.list.useQuery() in client components:
┌─────────────────────────────────────────────────────────────────────────┐
│ PAGE LOAD / NAVIGATION │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. WebSocket Connection │
│ Browser → Convex backend (establishes persistent connection) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 2. Auth Handshake (⚠️ BLOCKING - queries wait here) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Unlike REST APIs where queries start immediately, Convex │ │
│ │ WebSocket requires auth validation BEFORE any query can run. │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ Client sends session token → Convex validates JWT │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 3. Token Validation (Step 1 from above) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Static JWKS (recommended) │ │
│ │ └─ Instant validation, no network calls │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Dynamic JWKS (not recommended) │ │
│ │ └─ +100-400ms delay before ANY query can run │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 4. Session Lookup (Step 2 from above) │
│ Same as SSR: validates session exists and isn't expired │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 5. Identity Cached → Queries Unblocked │
│ All pending queries now execute instantly │
│ useQuery(crpc.posts.list.queryOptions()) → runs │
│ useQuery(crpc.user.me.queryOptions()) → runs │
│ ... navigate to other pages → all queries instant │
└─────────────────────────────────────────────────────────────────────────┘Key difference from REST APIs: REST queries fire immediately and validate auth inline. Convex WebSocket queries are blocked until the auth handshake completes. With Static JWKS, this handshake is instant. With Dynamic JWKS, it adds 100-400ms blocking time on page load.
That's the complete authentication flow. You now understand how JWT validation (Step 1) and session lookup (Step 2) work together—and why revoking a session immediately invalidates access, even with a valid JWT.