Skip to Content
Server rendering

Server sessions

How the server keeps the session in every framework, from the cookie and its secret to refreshing, signing out and limits.

This page covers what every server-rendered setup shares: the cookie and its secret, a request's session, refreshes, sign-out, and limits. Read it once your app works, to deploy it. The setup guides show the code: TanStack Start, Next.js, and React Router with server rendering.

createServerAuth from @monarcode/session-kit/server keeps both tokens and the user in one cookie, <name>.session, sealed with AES-256-GCM through Web Crypto. It's httpOnly, so page scripts can't read it, and Secure and SameSite=Lax unless you pass other cookie options. It takes the options createAuth does, except storage and locking, plus secret, cookie and clientCredentials.

Keep the module that creates it on the server. Each setup guide shows how its framework keeps such a module out of the browser bundle.

The secret

Use 32 or more random characters from an environment variable. To make one, run openssl rand -base64 32. createServerAuth checks the secret on first use, not when the module loads, so build steps that import the module without the secret still work.

Your module still reads process.env.SESSION_SECRET when it loads. On Cloudflare Workers, process.env has the secret at that point when Node.js compatibility is on, the default from compatibility date 2025-04-01. Without Node.js compatibility, read it with import { env } from "cloudflare:workers", which also works at the top of a module.

To rotate the secret, pass a list. The first secret seals new cookies, and any of them opens one, so sessions survive the change. For example, to read the old secret from OLD_SESSION_SECRET only while it's set:

src/auth/server.ts
import {  } from "@monarcode/session-kit/server";
import {  } from "zod";

const  = [..SESSION_SECRET, ..OLD_SESSION_SECRET];

export const  = ({
  : "app",
  : .({ : .(), : .() }),
  // A secret that isn't set is left out, so rotating only changes the env.
  : .(() =>  !== ),
});

Each save seals the cookie with the first secret. Remove OLD_SESSION_SECRET once maxAge has passed since the change, 30 days by default. By then, every cookie it sealed has expired. A cookie that no listed secret opens reads as signed out.

A request's session

Your server code reads and changes the session through one request's session (RequestAuth). It has every serverAuth method, without the request's cookie jar to pass:

  • TanStack Start: startAuth(serverAuth), made once at module level. Each call finds the request Start is handling.
  • React Router: routerAuth(serverAuth) makes a function you call with a loader's, action's or middleware's context.
  • Next.js: nextSession(serverAuth, { cookies, redirect }), whose writable() gives it in Server Functions and Route Handlers, and readOnly() in Server Components.
  • Servers without an adapter: serverAuth.forRequest(jar), over the request's cookie jar.

It's shaped like a client, so code written for one works on the server with it. createAuthFetch(session, { baseUrl }) sends the cookie's token and saves a renewed one after a 401, and Effect's Auth.layer takes it (Effect on the server).

Some requests can't set cookies: a Next.js Server Component rendering, or a React Router loader once its response has left the middleware. There, nothing is saved, so signIn, signOut, updateUser and refreshIfDue reject with INVALID_OPTIONS. getSession still resolves, and credentials.get rejects with REFRESH_FAILED once the access token has expired. TanStack Start can't tell once a page has started streaming, so a cookie set then is lost. Refresh before rendering, with the middleware each setup guide adds: refreshSession in TanStack Start, sessionMiddleware in React Router, and nextSession's refresh in Next.js's Proxy.

Servers without an adapter

Every serverAuth method also takes the request's CookieJar first: serverAuth.getSession(jar) does what a request's getSession() does, and serverAuth.forRequest(jar) gives that request's session. For a server without an adapter, build a CookieJar over its cookies. Answer the browser client's session route with sessionHandler(serverAuth), called with the request and its jar.

Use one jar per request. Calls with the same jar share what it read and wrote and one refresh, so a jar kept at module level would show one request's session to the next. A jar that can't set cookies says so with writable: false.

Token refresh

Parallel requests to one instance that carry the same cookie share one refresh. For ten seconds afterwards, a request still carrying the old cookie gets the new tokens rather than refreshing again. A refresh that fails at your backend is shared too. For ten seconds, requests carrying that cookie reject with its error rather than call your backend again.

Each createServerAuth call is its own instance, and a framework may make several on one server, one per bundle. Separate processes, servers, and serverless functions are separate instances too. Instances share nothing, so with rotating refresh tokens, give your backend a short reuse window. Two instances that refresh the same session then don't sign it out.

Sign-out

A request's signOut clears the cookie in this browser, then calls your revoke. The cookie holds the whole session and the server keeps no list of sessions, so a copy of the cookie made before sign-out still reads as signed in. For ten seconds, the instance that signed out treats such a copy as signed out, as it treats an old cookie after a refresh. After that, the copy works for as long as its tokens do.

revoke is what ends the session: have your backend revoke the refresh token there, so a copy can't refresh, and keep access tokens short-lived, since the backend accepts one until it expires unless it checks for revocation.

The access token in the browser

Server code calls your API with a request's credentials. To call it from the browser too, with createAuthFetch or effetch's sessionBearer, opt in with clientCredentials: true. The session route then serves the access token, and the browser's client gets it there. Page scripts hold the access token in memory, never the refresh token, so a script injected into the page can use the API while the page is open, as it could with any token the browser holds.

The session route answers your own site's pages only: see The session route.

Limits

  • Size. The cookie can take about 7.5 KB across two cookies, since servers and proxies refuse larger request headers (nginx: 8 KB for the Cookie line, shared with the site's other cookies), and go on refusing every page until the cookie is cleared. That holds about 5.5 KB of tokens and user before encryption. A larger session rejects at sign-in with PERSISTENCE_FAILED, so keep the user small. An updateUser or a refresh that outgrows the cookie rejects the same way, and signs the user out.
  • Plain http. The cookie is Secure by default. Browsers accept that on http://localhost except Safari: pass cookie: { secure: false } for local development there, never in production.
  • Locking. locking isn't available. The server keeps the tokens, so there's nothing on the device to lock.

Next steps

On this page