BETTER-CONVEX

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:

FeatureDescription
Convex as database adapterStore auth data in Convex tables
Type-safe triggersHooks for user/session lifecycle events
React hooksuseAuth and conditional rendering
Next.js integrationServer-side caller with JWT caching

Let's understand the key concepts.

Key Concepts

Local vs Component Installation

ApproachSchema LocationDatabase Access
Component (@convex-dev/better-auth)Component schemaVia ctx.runQuery/ctx.runMutation
Local (better-convex/auth)Your app schemaDirect 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

AdapterUsed InAccess
adapter via getAuth(ctx)Queries, mutationsDirect DB access
httpAdapter via createAuth(ctx)HTTP routes, actionsHTTP 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

ComponentStorageCan Be Invalidated?Default LifetimePurpose
JWTCookie (signed)❌ No (stateless)15 minutesFast identity verification
SessionConvex DB✅ Yes (stateful)30 daysAuthorization 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)
TransportHTTP request per queryPersistent connection
Token sourceCookie or fetch from /api/auth/convex/tokenSent during WebSocket handshake
Validation timingPer requestOnce 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:

ScenarioStep 1: JWTStep 2: SessionResultUser Experience
Normal✅ Valid✅ Valid200 OKAccess granted
User signs out🗑️ Deleted🗑️ Deleted401Redirected to login
Admin revokes session✅ Valid🗑️ Deleted401Logged out on next request
JWT expired, session valid❌ Expired✅ ValidAuto-refresh → 200Transparent
JWT expired, session expired❌ Expired❌ Expired401Redirected to login
JWT valid, session expired✅ Valid❌ Expired401Logged 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.

Next Steps

On this page