SSR sessions

Own token cookies and refresh concurrency safely in server-rendered applications.

Use @cavuno/board/server for session-cookie encoding and single-flight refresh patterns. The app, not the shared SDK client, owns each user’s tokens.

Why the server client uses nostore

A module-scoped server client can serve many users. It must never persist one user’s access token in shared SDK storage. Configure storage: 'nostore', keep the bearer pair in Cavuno’s httpOnly session cookie, and pass the current access token on each private call.

ts
// src/lib/cavuno.server.ts
import { createBoardClient } from '@cavuno/board';
import { createSessionRefresher } from '@cavuno/board/server';
export const board = createBoardClient({
board: process.env.PUBLIC_CAVUNO_BOARD!,
auth: { storage: 'nostore' },
});
export const refreshSession = createSessionRefresher(board);

createSessionRefresher fails during construction when a server client uses persistent storage. That failure prevents a cross-user token leak rather than silently accepting an unsafe configuration.

Read and refresh a session

The helpers consume and return cookie header strings, so the same logic can be connected to Next.js, TanStack Start, Remix, or a Worker response.

ts
// src/lib/load-board-user.ts
import { isUnauthorized } from '@cavuno/board';
import {
clearSessionCookie,
isExpiringSoon,
parseSessionCookie,
serializeSessionCookie,
} from '@cavuno/board/server';
import { board, refreshSession } from './cavuno.server';
export async function loadBoardUser(cookieHeader: string | null) {
let session = parseSessionCookie(cookieHeader);
if (!session) return { user: null, setCookie: null };
let setCookie: string | null = null;
if (isExpiringSoon(session, Date.now())) {
session = await refreshSession(session);
if (!session) {
return { user: null, setCookie: clearSessionCookie() };
}
setCookie = serializeSessionCookie(session);
}
const options = {
cache: 'no-store' as const,
headers: { authorization: `Bearer ${session.accessToken}` },
};
try {
const user = await board.me.retrieve(undefined, options);
return { user, setCookie };
} catch (error) {
if (!isUnauthorized(error)) throw error;
const rotated = await refreshSession(session);
if (!rotated) {
return { user: null, setCookie: clearSessionCookie() };
}
const user = await board.me.retrieve(undefined, {
...options,
headers: { authorization: `Bearer ${rotated.accessToken}` },
});
return { user, setCookie: serializeSessionCookie(rotated) };
}
}

Attach setCookie to the framework response only when it is non-null. In a multi-board host, pass the same { board: identifier } option to parseSessionCookie, serializeSessionCookie, and clearSessionCookie so sessions cannot collide.

Verify the flow

  1. Log in, reload, and confirm the account remains authenticated.
  2. Force the access token near expiry and issue concurrent private reads. They must share one in-process refresh and receive the rotated cookie.
  3. Revoke or corrupt the refresh token. The next request must clear the cookie and continue signed out without a refresh loop.
  4. Confirm private responses use Cache-Control: private or no-store at every shared cache.

Production notes

  • The SDK cookie codec emits Path=/; HttpOnly; Secure; SameSite=Lax and uses the __Host- prefix.
  • Single-flight refresh is per process or isolate. Proactive refresh reduces cross-instance races but cannot eliminate them.
  • Refresh tokens are single-use. Retry a protected request at most once after rotation.
  • Never serialize a refresh token into HTML, client props, logs, or analytics.

Continue with Caching to keep authenticated responses out of shared caches.