kitcn

Overview

Better Auth integration with Convex for type-safe authentication.

kitcn integrates Better Auth with Convex.

Key Concepts

Context-Aware Adapter

Use one entrypoint everywhere:

const auth = getAuth(ctx);

Use generated getAuth(ctx); generated runtime handles context-aware adapter selection:

  • direct DB adapter for queries/mutations (ctx.db available)
  • HTTP adapter for actions/HTTP contexts (ctx.runQuery/ctx.runMutation)

Authentication Flow

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

What happens in each scenario (note how Step 1 and Step 2 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

Both SSR and client requests follow the same two-step validation. The difference is transport.

SSR (HTTP): Read JWT from cookie (cached by default) → HTTP to Convex → validate JWT → session lookup → query executes → response cached in TanStack Query → client hydrates with prefetched data.

Client (WebSocket): Establish persistent connection → auth handshake (blocking) → validate JWT → session lookup → identity cached → all pending queries unblock.

Key difference from REST: Convex WebSocket queries are blocked until the auth handshake completes. With Static JWKS, the handshake is instant. With Dynamic JWKS, it adds 100-400ms blocking time on page load.

Next Steps

On this page