Skip to content

Server-SideUser Sessions

User Sessions

Carry the user's session, and the request's own headers, to your API — from a page the server renders, through the proxy, and from the browser.

16 min readboth modes

The problem it solves

A server that renders a page for a user calls the API on that user's behalf, and its own fetch carries none of the user's cookies. Handing the call the incoming headers whole sends too much: every cookie of your application — its own session, analytics, preferences — and the headers the browser addressed to your application rather than to the API, whatever host the API is on.

An API that keeps a session says so where it is defined. Courier then sends each call made for a user the cookies that hold the session and nothing else, repeats the double-submit token a write needs, refuses a write another site set off, and carries the cookies the API sets back to the user's browser. Security Model shows where these credentials travel through every layer, and what Courier refuses and keeps out of its logs.

Describe the session

api/index.ts
import { courier, defineApi } from "@noverax/courier/define"

const { endpoint } = courier()

export const api = defineApi({
  name: "api",
  baseURL: process.env.API_URL!,
  // The cookies the API keeps the user's session in, and the token it asks writes to repeat
  session: {
    cookies: ["sessionid", "logto_*"],
    csrf: { cookie: "csrftoken", header: "x-csrftoken" },
  },
  // The headers of the request being served that the API should see
  forward: { requestId: true, trace: true, clientAddress: true },
  endpoints: {
    me: endpoint.get("me").response<{ id: string; name: string }>(),
    rename: endpoint.post("me/name").variables<{ name: string }>(),
    signOut: endpoint.post("logout", { session: "unguarded" }),
    catalog: endpoint.get("catalog", { session: false, forward: false }),
  },
})

session

OptionWhat it does
cookiesThe cookies the session is kept in: names, matched exactly as browsers keep them, or prefixes written with a trailing * — "logto_*" matches logto_app and logto_ref. Only these are sent.
bearerA cookie holding a token the API takes as Authorization: Bearer <token>. The cookie itself is sent only when cookies names it too.
csrfThe double-submit token: the cookie it is kept in, sent with the session, the header a write repeats it in, and from, where the API hands it out — see When there is no token yet.
relayPass the session cookies the API sets back to the browser — default true.
allowedOriginsPages on other origins whose requests may make writes with the session, written as the proxy's allowedOrigins are.
allowHttpSend the session over plain HTTP to a host on the public internet. Left unset, it travels only over HTTPS or to a private host.

forward

OptionWhat it carries
requestIdThe request's own x-request-id when it carries a well-formed one, otherwise an id made for it — shared by every call made while serving it. A string names another header.
traceW3C traceparent and tracestate, when the request carries a valid one and no OpenTelemetry tracer in the process propagates it already.
clientAddressThe user's address as X-Forwarded-For: the one the framework vouches for, or the chain the proxy in front recorded.
userAgentThe user's browser, as User-Agent.
localeThe user's languages, as Accept-Language.
headersFurther headers of the request, by name — a tenant, a region your edge added. Credentials and connection headers cannot be listed.

Where a call takes it from

Courier finds the request being served as a call starts, before it first waits — some frameworks know the request only until then — and reads it when the call is sent.

Where the call is madeWhat it carries
Server code while a framework serves a requestThe session from that request, found by the framework's adapter — see the setup per framework
The proxy route, for a browser's callThe session from the browser's request to the proxy
A proxy caller in server code, such as universal code rendering a pageThe proxy in the same process, with the session of the user the page is for — no request leaves the server
Server code that names a request: createCaller(api, { context: { req } })The session from that request
A browserIts own cookies, sent by the browser: the call asks for them with credentials: "include", so an API on another origin of the page's site gets them too; one on another site gets them only where the browser lets it — see an API on another site
A React Native appIts own cookies, sent by the platform as a browser sends them; what a page would read from document.cookie is kept from the API's answers — see React Native
Server code with no request — a script, a cron job, a buildNothing

A header the call sets itself — in a caller's headers, or with .with({ headers }) — wins over the one the session would send.

Framework setup

Next.js — lib/api.server.ts
import { createServerCaller } from "@noverax/courier-next"
import { api } from "@/api"

// Pages, layouts, Server Actions and Route Handlers call through it
export const server = createServerCaller(api)
SvelteKit 2.20+ — src/hooks.server.ts
import { getRequestEvent } from "$app/server"
import { registerRequestContext } from "@noverax/courier"
import { svelteKitRequestContext } from "@noverax/courier-sveltekit"

registerRequestContext(svelteKitRequestContext(getRequestEvent))
SvelteKit, any 2.x — src/hooks.server.ts
import { requestContextHandle } from "@noverax/courier-sveltekit/handle"

export const handle = requestContextHandle()
Nuxt 3 and 4 — server/plugins/courier-request.ts
import { requestContextPlugin } from "@noverax/courier-nuxt"

export default defineNitroPlugin(requestContextPlugin)
Express — app.ts
import { requestContext } from "@noverax/courier-express"

app.use(requestContext())
Fastify 4 and 5 — server.ts
import { requestContext } from "@noverax/courier-fastify"

app.addHook("onRequest", requestContext())
Hono 4 — src/index.ts
import { requestContext } from "@noverax/courier-hono/request-context"

app.use(requestContext())
Astro 5 — src/middleware.ts
import { requestContext } from "@noverax/courier-astro/request-context"

export const onRequest = requestContext()
React Router 7 and 8 — app/root.tsx
import { requestContext } from "@noverax/courier-remix/request-context"

export const middleware = [requestContext()]
Remix v2 — server.js, before Remix's handler
import { requestContext } from "@noverax/courier-express"

app.use(requestContext())
app.all("*", createRequestHandler({ build }))
SolidStart 1 and 2 — src/entry-server.tsx
import { registerRequestContext } from "@noverax/courier"
import { solidStartRequestContext } from "@noverax/courier-solid"
import { getRequestEvent } from "solid-js/web"

registerRequestContext(solidStartRequestContext(getRequestEvent))
NestJS 10 to 12, Express or Fastify — src/main.ts
import { requestContext } from "@noverax/courier-nestjs"

const app = await NestFactory.create(AppModule)
app.use(requestContext())

createAction takes the action's request itself, and withRequestContext from @noverax/courier-next/pages serves getServerSideProps and API routes of the Pages Router. Any other Node server serves a request with withIncomingRequest(nodeIncomingRequest(req, res), next) from @noverax/courier/node; another framework registers a provider of its own with registerRequestContext.

Next.js builds pages ahead of time

Reading the request is what tells Next.js that a page belongs to one user and has to be rendered per request. next build does not run instrumentation.ts, so a provider registered only there finds no request while pages are built: a page whose calls carry the session would be built once, without anyone's, and served to everyone. createServerCaller registers the provider where its caller is made, which a build loads.

Next.jsWhat differsHow the session handles it
14headers() and cookies() answer synchronously; a server's fetch is kept in the data cache by defaultTheir answer is awaited, which takes either form; a call made for a request is fetched with cache: "no-store"
15Both answer with a promiseAwaited
16Both answer with a promise alone; with cacheComponents, request data is read under <Suspense> and never inside "use cache"A component whose calls carry the session goes under <Suspense>, as one reading cookies() itself would, and becomes the page's dynamic part; inside "use cache" such a call fails with Next.js's own error, and an endpoint with session: false is cached as any other call

In every version, what Next.js stops a render with — a page found to be dynamic, a prerender postponed, a request read inside a cache — reaches Next.js as it came, and a build, a script or generateStaticParams finds no request and sends no session.

What a call sends

  • The cookies cookies names, as the browser sent them, and the double-submit cookie — never the rest of your application's cookies.
  • Authorization: Bearer with the bearer cookie's token, decoded where your framework's cookie API URL-encoded it.
  • The double-submit token on every verb a CSRF check guards — POST, PUT, PATCH, DELETE — and not on reads.
  • Referer set to your application's origin, where csrf is set: Django and Laravel Sanctum take a call for one of your pages only when it names one.
  • The headers forward names.

A session travels only over HTTPS, or to a private host — the local machine, a private network, a cluster's http://api:8080 — and its redirects stay on the API's origin, so the credentials never reach a host the API sent the call on to.

When there is no token yet

A user the API has not handed the double-submit token yet — on a first visit, signing in, or in a React Native app started again — would send a write without it, and the API would refuse it. Name where the API hands the token out, and a write that finds none asks there first:

api/index.ts
import { courier, defineApi } from "@noverax/courier/define"

const { endpoint } = courier()

export const api = defineApi({
  name: "api",
  baseURL: process.env.API_URL!,
  session: {
    cookies: ["laravel_session"],
    csrf: { cookie: "XSRF-TOKEN", header: "X-XSRF-TOKEN", from: "/sanctum/csrf-cookie" },
  },
  endpoints: {
    signIn: endpoint.post("login").variables<{ email: string; password: string }>(),
  },
})
  • from is a path under the API's baseURL, such as "csrf/" for a Django view decorated with ensure_csrf_cookie, or one from the root of its origin, such as Laravel Sanctum's "/sanctum/csrf-cookie".
  • The GET goes as the API's calls go — its address, headers, fetch and middleware — with the user's session, and only before a write that finds no token: a read repeats none, and a write that finds one asks for nothing. The writes waiting on one user's token share one request.
  • Middleware sees the request with purpose: "csrf-token", its method naming no endpoint, so a middleware that signs every call signs it too, and one meant for endpoints alone can pass it by. A cache keeps none of its answers, and Studio lists it as a request of its own, made within the time of the write that waits for it.
  • On a server, the token reaches the write and the user's browser, as any cookie the API sets does; a browser keeps it itself; a React Native app keeps it from the answer.
  • A request that fails, or an answer that sets no token the call can read, leaves the write to go without it, and the log says so.

Up front

A page whose writes come later — from the browser, or while it renders — can ask for the token as it is served, as Django's ensure_csrf_cookie does. ensureCsrfToken(api) asks once, as a write would, where the request does not carry the token, and the token reaches the browser with the response being prepared:

server.ts
import { ensureCsrfToken } from "@noverax/courier"
import { api } from "./api"

app.get("/account", async (_req, res) => {
  await ensureCsrfToken(api)
  res.send(renderAccountPage())
})

It needs a request being served, as every server-side call does, and answers whether the request carries the token then; served for another site's link, it asks too, since a token decides no write. A Next.js page cannot set cookies while it renders, so Next.js asks in a middleware: see Next.js.

The cookies the API sets

When the API renews the session, or ends it on signing out, its cookies are carried on — from an error answer as from a success:

  • The calls still to come for the same request send the new value at once.
  • The browser is handed them on the response being prepared, made for your application's origin: set for every path, since the API's path names its own routes; a Domain kept only where it covers your host; Secure over HTTPS, and on the local machine only where the API set it, as WebKit on Linux keeps no secure cookie for http://localhost. A __Host- or __Secure- cookie your origin cannot keep is not handed over, and the log says why.

Cookies the session does not name stay with the API. Set relay: false to keep even the session's own within the request.

An API on another site

A page's calls go with the cookies the browser keeps for the API, as far as the page's site lets them. An API on another origin of the page's site, api.example.com for a page on app.example.com or another port of the same host, gets them in every browser. An API on another site, another domain or 127.0.0.1 for a page on localhost, gets them only where the browser lets a third party keep cookies:

  • Safari keeps none for it.
  • Chrome and Firefox keep the ones it sets SameSite=None; Secure, none where the user blocks third-party cookies, and Firefox keeps them apart for each site that calls it.

Either way, the API answers the page's origin with Access-Control-Allow-Origin naming it and Access-Control-Allow-Credentials: true. A double-submit token is a cookie the page reads, so the API sets it where the page can read it, for its host or a parent domain such as Domain=example.com; a page cannot read another site's cookies, and a write then goes without its token, which the log says.

Courier says once, in the browser's console, when an API that keeps its session in cookies is on another site than the page. Serve the API on the page's site, under a subdomain, or call it through the proxy, which carries the session with the application's own requests.

What a framework lets a response carry

Next.js lets a Server Action or a Route Handler set cookies, not a page while it renders; a session the API renews during a render reaches the calls of that render only, and the log says so. Next.js also URL-encodes the value it writes, so a cookie whose value is not already URL-encoded is not handed over through it — relay it through the proxy route instead, which passes cookies on byte for byte. SvelteKit, Nuxt and Express hand every cookie over as the API wrote it.

Writes another site sets off

A page on another site can make the user's browser request one of your pages, with the user's cookies on it. A write made then with the session acts for the user without their asking — the request forgery the API's own defences exist to stop, which the session would otherwise walk past. So a write the session or its double-submit token vouches for is refused with 403, and never sent, unless the request being served is one of your application's own:

  • a form, a Server Action or a fetch — POST and the like — passes the proxy's origin rule: Sec-Fetch-Site says same-origin, or its Origin is your application's, or it is no browser's request at all;
  • a page — GET and the like, which any link can request — has to be one the browser says came from your application, or one the user opened themselves. A browser too old to send Sec-Fetch-Site has to name one of your pages as its Referer.

An origin in session.allowedOrigins counts as your application's own; with none listed, so does another port of the local machine in development, while your application is served from that machine too. A sign-in is guarded too, before anyone has a session: the double-submit token vouches for it, and so does the token a write would ask for — such a write is refused before anything is asked.

The log says why a write was refused, and Studio lists it, ended by its 403 and the reason.

A write that does no harm when someone else sets it off — signing out from a link — says so with session: "unguarded".

A read sent over POST — a GraphQL query, a search, an endpoint declared .reads() — is a read here: it goes with the double-submit token, which the API asks of every POST, and a page reached from another site's link still loads with it. Declare an endpoint a read only when it changes nothing: a write declared one is made for another site's link too, with the user's session and their token.

Next.js does not tell a request's method

A Server Action's request is POST; any other request a Next.js App Router serves is read as a page's. A Route Handler's write for a client that is no browser, and so marks nothing, is refused — such a client authenticates with its own token, not with your users' cookies.

Calls that answer everyone alike

An endpoint with session: false is called without the session, and a call to it does not read the request at all: a page that makes only such calls can be built once, and a call can run inside "use cache" or a build. forward: false keeps the forwarded headers off an endpoint's calls likewise.

Streams and channels

A stream or a channel of an API with a session carries the user's session as a read over HTTP does — the cookies cookies names, the bearer, the headers forward names — and the API's own headers, read anew on every connection, so a reconnect sends the token the user has now.

A stream:

  • From a server, its connections send the session taken from the request being served when the stream was opened, and the cookies its answer sets reach the browser as a call's do.
  • From a browser, its connections ask the browser to send its cookies — credentials: "include", unless the API sets credentials itself — and send the bearer the page reads.
  • From a React Native app, its connections send the app's cookies and the bearer the app's calls have learned, and learn what the stream's answer sets.
  • A stream the proxy relays is opened upstream with the session of the browser that opened it. A proxy caller sends the browser's cookies to the proxy route with its streams as with its calls.

A channel, on every socket it opens and every reconnect:

  • On a server, they go on the socket's handshake, taken from the request being served when the channel is opened. A socket is kept to that request: one request's channels share it, and two users' channels never share one.
  • In a browser, the platform sends the session's cookies on the handshake itself, as it sends them with a call. A page cannot put a header on a WebSocket's handshake, so the bearer and the API's headers go in the socket's first frame, which the server reads as the handshake's own — see Who a connection is.
  • In a React Native app, the platform sends its cookies on the handshake, and the bearer and the API's headers go on it beside them.
  • A channel the proxy relays is opened upstream with the session of the browser that subscribed, the headers allowedHeaders lets through and those the middleware set, as a relayed call is. One user's subscriptions share an upstream socket; no two users' do.

A channel repeats no double-submit token: the server checks a socket's origin as it opens, so a page on another site cannot open one on the user's behalf.

Caching

cacheMiddleware({ scope: "public" }) never answers a call that carries the user's session from its shared copy, nor stores its answer, and names the endpoint once in the log. scope: "session" gives each set of credentials a call sends a partition of its own, named by their SHA-256 digest so the credentials never sit in a store's keys. On Next.js 14, calls made for a request are fetched with cache: "no-store", so its data cache keeps no user's answer.

In a browser or a React Native app, where the platform sends the session's cookies out of a script's reach, every call of an API with a session counts as carrying it, and scope: "session" partitions by the credentials a script can read and by the writes made with the session — so users taking turns on one device never share a cached answer. See the cache middleware.

Errors where the API is defined

defineApi refuses a session that could not work as written, with a ConfigurationError:

CodeWhen
SESSION_OPTIONS_INVALIDAn option a session does not have, a cookie name no browser keeps, a header no request carries, or no credential named at all
SESSION_INSECURE_TRANSPORTThe API is reached over plain HTTP across the public internet, and allowHttp is not set
SESSION_HEADER_CONFLICTThe API's allowedHeaders, or an endpoint's .headers(), names cookie or set-cookie, which would carry every cookie past the session
FORWARD_OPTIONS_INVALIDAn option forward does not have, or a header it may not carry
INVALID_ALLOWED_ORIGINAn allowedOrigins entry no page's Origin can equal

Next Steps