By default, Better Auth fetches JWKS (JSON Web Key Set) from the database for every JWT sign/verify operation. This adds 2-4 database calls per token operation.
Static JWKS eliminates these database lookups by storing keys in an environment variable.
1. Add the JWKS generation action to your Convex backend:
import { internalAction } from './_generated/server'
export const getLatestJwks = internalAction({
args: {},
handler: async (ctx) => {
const auth = createAuth(ctx)
return await auth.api.getLatestJwks()
},
})
2. Generate and set the JWKS environment variable:
npx convex run auth:getLatestJwks | npx convex env set JWKS
3. Update your auth config to use static JWKS:
import { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config'
import type { AuthConfig } from 'convex/server'
const jwks = process.env.JWKS
export default {
providers: [getAuthConfigProvider({ jwks })],
} satisfies AuthConfig
4. Update the Better Auth plugin:
const jwks = process.env.JWKS
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
// ... other config
plugins: [
convex({ authConfig, jwks }),
],
})
}
| Aspect | Dynamic JWKS (default) | Static JWKS |
|---|---|---|
| Setup | Automatic | Requires CLI command |
| Performance | 2-4 DB calls per token op | 0 DB calls |
| Key rotation | Automatic | Re-run command + redeploy |
| Key storage | Database | Environment variable |
npx convex run auth:getLatestJwks | npx convex env set JWKS
For data that doesn't need real-time updates, skip WebSocket subscriptions with subscribe: false:
const { data, refresh } = await useConvexQuery(
api.config.getSettings,
{},
{ subscribe: false }
)
refresh() to re-fetch)With subscribe: false, data won't update automatically. Use refresh() when needed:
const { data, refresh } = await useConvexQuery(
api.posts.search,
computed(() => query.value ? { q: query.value } : 'skip'),
{ subscribe: false }
)
// Trigger search manually
async function doSearch() {
await refresh()
}
The module automatically caches tokens on the client to avoid redundant validation requests. When Convex's client requests a token refresh, we return the cached token if it was validated recently (within 10 seconds).
This is handled automatically - no configuration needed.
Reduce SSR latency by caching Convex JWT tokens across requests. By default, every SSR request fetches a fresh token from the auth server (50-200ms). With caching enabled, subsequent requests use the cached token instantly.
export default defineNuxtConfig({
convex: {
authCache: {
enabled: true,
ttl: 900 // 15 minutes (in seconds)
}
}
})
For deployments with multiple server instances (e.g., serverless, horizontal scaling), use Redis to share the cache:
export default defineNuxtConfig({
convex: {
authCache: {
enabled: true,
ttl: 900
}
},
nitro: {
storage: {
'cache:convex:auth': {
driver: 'redis',
url: process.env.REDIS_URL
}
}
}
})
To immediately invalidate the cache when a user logs out:
import { clearAuthCache } from '#imports'
export default defineEventHandler(async (event) => {
const sessionToken = getCookie(event, 'better-auth.session_token')
if (sessionToken) {
await clearAuthCache(sessionToken)
}
// ... rest of logout logic
})
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable SSR auth token caching |
ttl | number | 900 | Cache TTL in seconds (15 min default) |
| Aspect | Without Cache | With Cache |
|---|---|---|
| TTFB | +50-200ms per request | Near-zero after first request |
| Session revocation | Immediate | Up to TTL delay (use clearAuthCache for immediate) |
| Infrastructure | None | Memory (default) or Redis |
Multiple components calling the same query share a single subscription. The module uses useState keys based on query name and arguments.
<!-- Both components share the same subscription -->
<template>
<Header /> <!-- calls useConvexQuery(api.user.current) -->
<Sidebar /> <!-- also calls useConvexQuery(api.user.current) -->
</template>
No duplicate fetches, no duplicate subscriptions.
In your Convex dashboard, you may see queries called twice:
Q auth:getCurrentUser (HTTP API) <- SSR
Q teams:listMyTeams (HTTP API) <- SSR
Q auth:getCurrentUser (WebSocket) <- Client subscription
Q teams:listMyTeams (WebSocket) <- Client subscription
This is expected behavior. Here's why:
| Call | Purpose | When |
|---|---|---|
| HTTP API | SSR - embed data in HTML | Server render |
| WebSocket | Real-time subscription | Client hydration |
No. Notice they show (cached) and 0ms duration:
success (cached) Q auth:getCurrentUser <- 0ms, no re-computation
Convex is smart - the WebSocket subscription returns cached data instantly since the query result hasn't changed.
Option 1: subscribe: false - SSR only, no WebSocket subscription:
const { data } = await useConvexQuery(
api.config.get,
{},
{ subscribe: false }
)
Dashboard shows:
Q config:get (HTTP API) <- SSR only
Option 2: server: false - Client only, no SSR:
const { data } = await useConvexQuery(
api.session.get,
{},
{ server: false }
)
Dashboard shows:
Q session:get (WebSocket) <- Client only
Option 3: Both - No SSR, no subscription (one-time client fetch):
const { data, refresh } = await useConvexQuery(
api.reports.get,
{},
{ server: false, subscribe: false }
)
Dashboard shows:
Q reports:get (HTTP API) <- Single client fetch via HTTP
Use browser DevTools Network tab to measure:
/api/auth/convex/token response timeBefore static JWKS, you'll see adapter:findMany calls for JWKS. After, these disappear.
subscribe option