# 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.

## 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](/docs/security/overview) shows where these credentials travel through every layer, and what Courier refuses and keeps out of its logs.

## Describe the session

```ts title="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`

| Option | What it does |
| --- | --- |
| `cookies` | The 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. |
| `bearer` | A cookie holding a token the API takes as `Authorization: Bearer <token>`. The cookie itself is sent only when `cookies` names it too. |
| `csrf` | The 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](#when-there-is-no-token-yet). |
| `relay` | Pass the session cookies the API sets back to the browser — default `true`. |
| `allowedOrigins` | Pages on other origins whose requests may make writes with the session, written as the proxy's `allowedOrigins` are. |
| `allowHttp` | Send 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`

| Option | What it carries |
| --- | --- |
| `requestId` | The request's own `x-request-id` — the id nginx's `$request_id`, a load balancer or a platform wrote, so each API's logs join your application's — when it is one: up to 200 visible characters, none a quote or a backslash. Otherwise an id made for it. Every call made while serving the request carries the same id, on every attempt a retry makes. A header's name names another header; several — `["x-request-id", "x-correlation-id"]` — are each sent the id, read from the first the request carries one in. |
| `trace` | W3C `traceparent` and `tracestate`, when the request carries a valid one and no OpenTelemetry tracer in the process propagates it already. |
| `clientAddress` | The user's address as `X-Forwarded-For`: the chain the proxies in front recorded, with the address of the one that connected. A header's name, or several — `["x-real-ip", "x-client-ip"]` — are each sent the user's address alone as well, for an API that reads it from one header; naming `x-forwarded-for` sends it there in place of the chain, and `forwarded` as RFC 7239's `for=`. See [The user's address](/docs/deployment/overview#the-users-address). |
| `userAgent` | The user's browser, as `User-Agent`. |
| `locale` | The user's languages, as `Accept-Language`. |
| `headers` | Further headers of the request, by name — a tenant, a region your edge added. Credentials, connection headers and the headers that claim a client's address cannot be listed: a request could have written them itself; `clientAddress` sends the user's address in those. |

## 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 made | What it carries |
| --- | --- |
| Server code while a framework serves a request | The session from that request, found by the framework's adapter — see [the setup per framework](#framework-setup) |
| The proxy route, for a browser's call | The session from the browser's request to the proxy |
| A proxy caller in server code, such as universal code rendering a page | The 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 browser | Its 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](#an-api-on-another-site) |
| A React Native app | Its 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](/docs/frameworks/react-native#the-users-session) |
| Server code with no request — a script, a cron job, a build | Nothing |

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

```ts title="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)
```

```ts title="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))
```

```ts title="SvelteKit, any 2.x — src/hooks.server.ts"
import { requestContextHandle } from "@noverax/courier-sveltekit/handle"

export const handle = requestContextHandle()
```

```ts title="Nuxt 3 and 4 — server/plugins/courier-request.ts"
import { requestContextPlugin } from "@noverax/courier-nuxt"

export default defineNitroPlugin(requestContextPlugin)
```

```ts title="Express — app.ts"
import { requestContext } from "@noverax/courier-express"

app.use(requestContext())
```

```ts title="Fastify 4 and 5 — server.ts"
import { requestContext } from "@noverax/courier-fastify"

app.addHook("onRequest", requestContext())
```

```ts title="Hono 4 — src/index.ts"
import { requestContext } from "@noverax/courier-hono/request-context"

app.use(requestContext())
```

```ts title="Astro 5 — src/middleware.ts"
import { requestContext } from "@noverax/courier-astro/request-context"

export const onRequest = requestContext()
```

```tsx title="React Router 7 and 8 — app/root.tsx"
import { requestContext } from "@noverax/courier-remix/request-context"

export const middleware = [requestContext()]
```

```js title="Remix v2 — server.js, before Remix's handler"
import { requestContext } from "@noverax/courier-express"

app.use(requestContext())
app.all("*", createRequestHandler({ build }))
```

```tsx title="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))
```

```ts title="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.js | What differs | How the session handles it |
| --- | --- | --- |
| 14 | `headers()` and `cookies()` answer synchronously; a server's `fetch` is kept in the data cache by default | Their answer is awaited, which takes either form; a call made for a request is fetched with `cache: "no-store"` |
| 15 | Both answer with a promise | Awaited |
| 16 | Both 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:

```ts title="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:

```ts title="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](/docs/frameworks/nextjs#a-page-that-writes-while-it-renders).

## 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](/docs/proxy-mode/overview), 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](/docs/advanced/realtime#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](/docs/middleware/built-in#cachemiddleware).

## Errors where the API is defined

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

| Code | When |
| --- | --- |
| `SESSION_OPTIONS_INVALID` | An 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_TRANSPORT` | The API is reached over plain HTTP across the public internet, and `allowHttp` is not set |
| `SESSION_HEADER_CONFLICT` | The API's `allowedHeaders`, or an endpoint's `.headers()`, names `cookie` or `set-cookie`, which would carry every cookie past the session |
| `FORWARD_OPTIONS_INVALID` | An option `forward` does not have, or a header it may not carry. A page does not check `forward`, which passes nothing on there: the server that shares the definition refuses it |
| `INVALID_ALLOWED_ORIGIN` | An `allowedOrigins` entry no page's `Origin` can equal |

## Next Steps

- [createCaller](/docs/server-side/caller) — the caller server code makes its calls through
- [Server Actions](/docs/server-side/actions) — `createAction` and its request
- [Server Handler](/docs/proxy-mode/api-route) — the proxy route a browser's calls go through
