---
title: "Authenticated SSR"
description: "Render signed-in pages on the server. How nuxt-convex turns a request into a Convex token, how to plug in your own auth provider, and what that means for caching."
canonical_url: "https://nuxt-convex.onmax.me/nuxt-module/authenticated-ssr"
---
# Authenticated SSR

> Render signed-in pages on the server. How nuxt-convex turns a request into a Convex token, how to plug in your own auth provider, and what that means for caching.

When a request comes from a signed-in user, `nuxt-convex` resolves a Convex JWT for it on the server and uses it for SSR queries and the [server helpers](https://nuxt-convex.onmax.me/nuxt-module/server-routes). The token stays on the server: the browser only learns whether the user is authenticated.

## The token flow

```text
Browser request (cookies, headers)
  │
  ├─ nuxt-convex middleware: attaches a lazy, memoized token getter to the event
  │
  ├─ Nuxt plugin (SSR) or a server route asks for the token
  │    1. 'convex:token' hook: your code may set ctx.token (JWT or null)
  │    2. otherwise, the built-in provider (Better Auth by default):
  │         no *session_token cookie   → null, no network call
  │         session cookie             → GET <basePath>/convex/token in-process
  │                                      (proxied to Convex, where Better Auth runs)
  │    3. otherwise → null (anonymous)
  │
  ├─ Token found:
  │    • SSR queries and server helpers run as the user
  │    • response gets  Vary: Cookie  and  Cache-Control: private, no-cache
  │
  └─ HTML + payload: { isAuthenticated: true | false }   (never the token)
       │
       └─ Browser: the realtime client gets its own token from the client-side
          provider; until then it waits (expectAuth) instead of querying anonymously
```

A few details matter in practice:

- The token is resolved at most once per request, however many queries and helpers ask for it. Pages that don't need it (no SSR queries, `auth: 'none'` queries only) still resolve it once, because the plugin needs `isAuthenticated` for the auth components.
- `isAuthenticated` is shared through `useState('convex:auth')`, so `<ConvexAuthenticated>` and friends render the right slot on the server and hydrate without flipping.
- If resolving the token fails, the page renders signed out and the error is logged. A token request that returns 401 or 403 means signed out and isn't logged.

## Better Auth (default)

With `@nuxtjs/better-auth` installed, the provider defaults to `'better-auth'`. The recommended setup runs Better Auth inside Convex with `@convex-dev/better-auth`, sets `auth.clientOnly: true`, and lets `nuxt-convex` proxy `/api/auth/**` to your Convex site URL. On the server, the module exchanges the session cookie for a Convex JWT at `/api/auth/convex/token`; in the browser, its client plugin does the same and keeps the realtime client in sync with sign-in and sign-out.

See [Better Auth](https://nuxt-convex.onmax.me/integrations/better-auth) for the full setup.

## Custom providers

For Clerk, Auth0, WorkOS, or your own OIDC issuer, supply the token through the `convex:token` server hook. It runs before the built-in provider on every token resolution, and receives the event and a context:

```ts
interface ConvexTokenContext {
  /** Set a JWT, or null to force anonymous. Leave undefined to fall through to the built-in provider */
  token?: string | null
  /** Headers of the incoming request (web Headers) */
  headers: Headers
}
```

The token must be a JWT that your `convex/auth.config.ts` accepts: same issuer, and the `applicationID` as audience. Most providers issue one through a "Convex" JWT template or an audience setting.

### Server: from a cookie or a header

```ts [server/plugins/convex-token.ts]
import { useServerHooks } from 'nuxt/server'

export default defineNitroPlugin(() => {
  useServerHooks().hook('convex:token', async (event, ctx) => {
    // API clients send their own token
    const authorization = ctx.headers.get('authorization')
    if (authorization?.startsWith('Bearer ')) {
      ctx.token = authorization.slice('Bearer '.length)
      return
    }

    // Browsers carry a session cookie: exchange it with your provider's server SDK
    const session = readCookie(ctx.headers, 'my_session')
    ctx.token = session ? await getConvexJwtForSession(session) : null
  })
})

function readCookie(headers: Headers, name: string): string | undefined {
  const match = headers.get('cookie')?.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`))
  return match ? decodeURIComponent(match[1]!) : undefined
}
```

`getConvexJwtForSession` stands for your provider's call, such as fetching a token from a JWT template. `nitroApp.hooks.hook('convex:token', ...)` works the same way if you prefer the Nitro plugin argument.

Set `ctx.token = null` to force a request anonymous even when the built-in provider would find a session. Leave it `undefined` to let the built-in provider run.

To use only your hook, turn the built-in provider off. With `@nuxtjs/better-auth` installed this also disables the Better Auth proxy and client plugin:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  modules: ['nuxt-convex'],
  convex: {
    auth: { provider: false },
  },
})
```

### Browser: authenticate the realtime client

The hook only covers the server. The browser's realtime client needs its own token fetcher. When your provider's client SDK can't produce the Convex JWT, let the browser ask your server, which resolves it through the same hook:

```ts [server/api/convex-token.get.ts]
import { getConvexToken } from '#convex/server'

// The browser's own token, for its WebSocket connection
export default defineEventHandler(async event => ({ token: await getConvexToken(event) }))
```

Then hand a fetcher to the client once, in a client plugin:

```ts [app/plugins/convex-auth.client.ts]
export default defineNuxtPlugin({
  dependsOn: ['nuxt-convex'],
  setup() {
    useConvexAuth({
      fetchToken: async () => {
        const { token } = await $fetch<{ token: string | null }>('/api/convex-token')
        return token
      },
    })
  },
})
```

`fetchToken` must return `null` when the user is signed out. Convex calls it again, with `forceRefreshToken: true`, before the token expires. If your provider caches tokens on the client, pass that flag on to it.

## Caching

A page rendered with a user's token contains that user's data. When a token is found, `nuxt-convex` sets two headers on the response:

- `Cache-Control: private, no-cache`, so CDNs and shared proxies don't store it
- `Vary: Cookie`, appended to any existing `Vary`

Anonymous responses are left alone, so public pages stay cacheable.

<warning>

**Don't put Nitro's cache or swr route rules, or isr, on routes that signed-in users visit.** Nitro's cache keys on the URL, not on the cookie, and ignores the headers above. The first render it stores, with that user's SSR data and signed-in state, would be served to everyone. The same goes for `cachedEventHandler` around server routes that call Convex as the user.

</warning>

If a route must be cached, make it render anonymously for everyone: force `null` in the hook for that route. The browser client still signs in after hydration, so user-specific parts load on the client.

```ts [server/plugins/convex-token.ts]
import { getRequestURL, useServerHooks } from 'nuxt/server'

export default defineNitroPlugin(() => {
  useServerHooks().hook('convex:token', (event, ctx) => {
    // `/blog/**` has `swr: 3600` in routeRules: never render it as a user
    if (getRequestURL(event).pathname.startsWith('/blog'))
      ctx.token = null
  })
})
```

## Verify the result

- View the page source while signed in: the HTML contains your data, and the Nuxt payload holds the `convex:auth` state with `isAuthenticated: true`, but no JWT.
- `curl -I` the page with your session cookie: the response has `cache-control: private, no-cache` and `vary: Cookie`.
- Without the cookie, the same page renders signed out and has no `private` cache header.

## Next steps

- [Server Routes](https://nuxt-convex.onmax.me/nuxt-module/server-routes) to call Convex as the user from API routes
- [Security](https://nuxt-convex.onmax.me/nuxt-module/security) for what stays on the server
- [Better Auth](https://nuxt-convex.onmax.me/integrations/better-auth) for the default provider


## Sitemap

See the full [sitemap](https://nuxt-convex.onmax.me/sitemap.md) for all pages.
