# Security Model

Where your users' credentials travel through Courier — browser, proxy, server and API — what each layer passes on, refuses and writes down, and what to set in production.

## What Courier secures, and what it leaves to your API

Courier carries calls from your application to your APIs. Deciding who a user is and what they may do stays with the API: Courier sends the credentials a call needs, to the API it is meant for, and nothing else, anywhere else. Along the way it refuses the calls another site sets off, keeps one user's answers away from another, and keeps credentials out of its logs, its caches and Studio.

This page follows the user's credentials through each layer. [User Sessions](/docs/server-side/session) describes how a session is set up, and [Server Handler](/docs/proxy-mode/api-route) how the proxy route is mounted.

## Where credentials travel

| Where the call starts | What holds the user's credentials | What Courier sends to the API | What it never sends |
| --- | --- | --- | --- |
| A page, calling the API directly | The browser's own cookies, which it sends itself as far as its rules let it | The double-submit token a page can read, repeated on writes | — the browser decides the rest |
| A page, through your proxy route | The cookies and headers the browser sends to your application | `Referer`, `Origin`, `Authorization` with a token your scripts set, `User-Agent`, `X-Request-Id`, the headers `allowedHeaders` or `.headers()` name, the session's cookies, and `X-Forwarded-For` the proxy writes from the connection and the chain the proxies in front recorded | Your application's other cookies, `Authorization` in `Basic`, `Digest`, `Negotiate` or `NTLM`, and every header that claims a client address |
| Your server, while it serves a request — a page, a Server Action, a route | The request being served | The cookies `session.cookies` names, `Authorization: Bearer` from the `bearer` cookie, the double-submit header on writes, `Referer` with your origin, and the headers `forward` names | Everything else the request carries |
| A React Native app | The platform's cookie store, and the readable cookies Courier keeps from the API's answers | The double-submit token Courier kept, repeated on writes | — the platform sends its own cookies |
| A stream (SSE) | As for a call from the same place | The same, again on every reconnect | The same |
| A channel (WebSocket) | As for a call from the same place | On Node and React Native, headers on the handshake; from a browser, the first frame on the socket | Credentials in the address |

### The proxy serves only what you mark

A page reaches an endpoint through the proxy only where a mark opens it: `.expose()` on the endpoint, or `expose` on its group or its API, the nearest deciding. Any other is answered as a name the source does not define, so an endpoint written for a cron job or an admin tool never runs for a page with your server's credentials, and a caller guessing names learns nothing of what exists. A page's client takes the routes Courier writes from the marks — the names of the endpoints a page may call and how it reaches them, nothing of the API's definition. See [Exposing Endpoints](/docs/proxy-mode/expose).

### The proxy passes on only what it names

The proxy forwards a short list of the browser's headers to the API, and adds those a source, a group or an endpoint names in `allowedHeaders` or `.headers()`. Three kinds stay behind unless named:

- **Cookies.** The browser sends every cookie of your application's origin, its own session included, and forwarded whole they would reach whatever upstream a source points at — a third party's too. An API with a `session` gets the cookies its session names.
- **The browser's own logins.** `Authorization` in `Basic`, `Digest`, `Negotiate` or `NTLM` answers your application's own login prompt, a staging site's for instance, and is dropped, with one warning per source and scheme. A `Bearer` token, or any scheme your scripts set, goes on.
- **Client addresses.** `X-Forwarded-For`, `X-Real-IP`, `Forwarded` and the like can be written by any page. The proxy writes `X-Forwarded-For` itself: the connection's address, after the chain a proxy on the local machine, a private network or `COURIER_TRUSTED_PROXIES` recorded; where the framework does not report the connection, as Next.js does not, the chain the proxy in front recorded. See [The user's address](/docs/deployment/overview#the-users-address).

What the browser forwards only fills in what your server leaves unset. A header the API sets in its `headers`, its session, a middleware or an endpoint's closure is the one the API receives, and the browser's copy is dropped: a page that writes its own `Authorization` or `Referer` cannot replace the service token or the `Referer` your upstream checks. An `X-Request-Id` goes on only when it is one — a single word of up to 200 visible characters, none a quote or a backslash, as the ids nginx, Kong, AWS and Google Cloud write are — so a page cannot write what it likes into the API's logs.

The API's answer comes back with its own short list of headers — caching, rate limits, the request's id — and its cookies travel on a path of their own, described below.

### Only to the API's origin

A call that carries the user's credentials from a server follows a redirect only while it stays on the API's origin; a redirect anywhere else is answered with as it came, an upload's included. A session is never sent over plain HTTP across the public internet: such a call fails with `SESSION_INSECURE_TRANSPORT`, unless the API is on the local machine or a private network, or its session sets `allowHttp`. The address a session asks for its double-submit token, `csrf.from`, is a path on the API's own origin, and an endpoint's path always stays under its API's `baseURL`.

### Streams and channels

A stream from a page asks the browser for its cookies with `credentials: "include"`; from a server, it carries the session read again on every reconnect, so a session the API renewed is the one the next connection sends.

A browser cannot put headers on a WebSocket handshake, so a Courier client sends what proves who it is in the socket's first frame — the caller's `ws.auth`, and the headers it would otherwise send — never in the address, where it would reach access logs and browser history. On Node and React Native the same headers go on the handshake itself. Through the proxy, the upstream socket is opened for each user with that user's session, never shared between users.

### React Native

A React Native app keeps its cookies where no script reads them, and sends them to every call to their host whether the API describes a session or not. The cookies a page could read — the double-submit token, a bearer token the API did not set `HttpOnly` — Courier keeps from the answers of its own calls, for the host, domain and path they were set for and until they expire. A `Domain` of one label, such as `com`, would reach every other API of the app, and is not taken.

## What reaches the browser

An API's answer can renew the session, hand out a double-submit token, or sign the user out. On a server, Courier carries those cookies on — to the calls still to be made for the same request, and to the user's browser on your application's response — with these rules:

- Only the cookies the session names, the double-submit cookie and the bearer cookie; any other cookie stays with the API.
- `Path=/`, since the API's path names its own routes, not your pages.
- A `Domain` only where it covers your application's host; otherwise the cookie stays with that host alone.
- `Secure` where your application is served over HTTPS; on the local machine over plain HTTP, only where the API set it, as WebKit on Linux keeps no secure cookie there. Elsewhere it is dropped, and `SameSite=None` becomes `Lax`; a `__Host-` or `__Secure-` cookie, which cannot exist without it, is refused.
- `session.relay: false` keeps even the session's own cookies within the request: the calls made for it still send them, and the browser is not handed them.

A page that has already started streaming can no longer set a cookie, and the log says so. Where each framework can and cannot set one is on its page and in [User Sessions](/docs/server-side/session).

## Requests another site sets off

A page on another site can make the user's browser send your application a request, with the user's cookies on it. Courier refuses such a request wherever it would act for the user.

### The proxy's origin rule

Every call the proxy serves — GET, POST, a stream and a channel — is checked for where it comes from:

- **`Sec-Fetch-Site`** decides wherever the browser sends it, which a current browser does on every request to a secure origin: `same-origin`, and `none` for what the user typed, pass; `same-site` and `cross-site` pass only from an origin in `allowedOrigins`. A page on a sibling subdomain is another site here.
- **`Origin`** is compared with the origin the request was addressed to where the browser says nothing else.
- **Neither header** means no browser made the request — a server, a CDN, a script — and it is served: it carries none of a user's cookies by itself.

`allowedOrigins` takes exact origins, `https://*.example.com` for the subdomains of a host on one scheme and port, or `*.example.com` for them on any scheme. Any scheme includes plain HTTP, and anyone on a visitor's network can answer for an HTTP name with a page of their own, which then calls with the visitor's cookies — so write the scheme unless a page served over HTTP must call the application. A pattern admits every host under it, whoever runs it: `https://*.vercel.app` admits every app any Vercel user deploys, and `*.com` every site, each reading what your users are answered — list the origins of your own deployments, or a pattern under a domain only you control. With none configured, development also admits a page on another port of the local machine, while the app itself is served from that machine, so Studio can drive it. An app on any other address never admits one: a server started without `NODE_ENV` counts as in development, and a page on a visitor's own machine — a local tool, another dev server — is not the application's.

`allowedHosts` names the hosts your application answers to. Without it, development answers only the local machine's names and IP addresses, so a name an attacker points at `127.0.0.1` — DNS rebinding — cannot reach a developer's server; production answers every name its operator set up. A request for any other host is answered `421` on every transport.

A GET to the proxy reaches only an endpoint that reads, and the variables of an endpoint marked `sensitiveVariables` are refused in an address: such a call goes as POST.

### Writes made with the user's session

Your server makes calls with the user's session while it serves a request, and a write made then acts for the user. It is made only when the request being served is one of your application's own: a form, a Server Action or a `fetch` that passes the origin rule above; a page the browser marks as your application's or as opened by the user, or, from a browser too old to say, one whose `Referer` is a page of yours. Anything else is refused with `403` before it reaches the API, and the log and Studio say why.

- A sign-in is guarded too: the double-submit token it would carry, or ask for, vouches for it as a session would.
- A write that does no harm when a stranger sets it off — signing out from a link — says so with `session: "unguarded"`.
- A read sent over POST — a GraphQL query, an endpoint declared `.reads()` — is a read here, and a page another site linked to still loads with it. Declare an endpoint a read only when it changes nothing.

### Channels

A channel's socket passes the origin rule as it opens, so a page on another site cannot open one with the user's cookies. Its first frame may add the headers a browser could not put on the handshake, and never those a browser or a proxy sets: `Cookie`, `Origin`, `Host`, `Referer`, `Forwarded`, `X-Forwarded-*`, `Sec-*`, `Proxy-*` and the headers that claim a client address. A frame's header names and values must be ones HTTP can carry, at most 64 of them; a frame that breaks those rules is refused, and with `onConnect` the connection is closed with `4401`.

## AI agents

An endpoint reaches an agent only when it is marked `.agent()` or the project's `agents` policy offers it, and then as an MCP tool whose call runs through the endpoint's middleware and schemas like any other — see [MCP Server](/docs/agents/overview).

- **Nothing offered by default.** A project that sets no policy offers the endpoints marked `.agent()` alone. The policy lives in `courier.config.ts`, under review: a `deny` pattern wins over everything, `.agent(false)` over anything that would offer an endpoint, and an `access` ceiling over the endpoints' own marks. A server's start can narrow it — `--read-only`, `--deny` — and never widen it. `courier-mcp --list`, Studio and the pull request report show what is offered and why, and a pattern that names no endpoint is warned of.
- **No browser session.** An agent's call carries none of the request it arrived on: no cookie, no forwarded header. It runs with what your middleware gives the principal `authorize` let in, and middleware tells it apart by `ctx.agent`.
- **Callers you authorize.** `createMcpHandler` cannot be created without `authorize`. A tool `allow` keeps from a caller is answered as an unknown one, and a request from a browser page is served only from your app's origin or one of `allowedOrigins`.
- **A tool's limit.** `.agent({ limit })` bounds how often a caller may call a tool — counted per caller with `limitPer` — and a call beyond it is refused before anything runs or anyone is asked.
- **A person confirms what cannot be undone.** A `destructive` tool runs once the person at the client confirmed that exact call — and so does every write where the policy says `confirm: "write"`; the question goes to the person, out of the model's reach. A confirmation counts for one tool with the same arguments for five minutes, signed with `confirmSecret`. `allowDestructive` stands in for the person only where the client cannot ask one.
- **What reaches the model.** An error answer's status and body, cut short; never a response header. An answer longer than `maxResultChars` is refused.
- **What Studio keeps.** An agent's calls show with the tool and the client; the principal never leaves your server. Studio's own MCP endpoint answers only with Studio's token, and replaces every credential and every body field named like a secret in what it hands an agent. Its `replay_write`, which sends a recorded write again, runs only once the person confirms the call shown to them — see [Studio for Your Agent](/docs/agents/studio#replaying-after-a-fix).

## Webhooks

A webhook is a call made to your app by its sender, and [`defineWebhook`](/docs/advanced/webhooks) checks each delivery before your handler sees it:

- **Its size.** The body is counted as it arrives and refused at the chunk that crosses `maxBodyBytes` (1 MB by default), never held whole first.
- **Its signature.** It is checked over the body exactly as it came, in time that does not depend on where it differs, under a secret read when the delivery comes. A webhook without a secret refuses every delivery rather than take an unsigned one.
- **Its age.** Stripe's scheme and Standard Webhooks sign the time a delivery was sent; one older than five minutes is refused as a replay. GitHub's scheme and one of your own sign no time: keep the ids of the deliveries you have handled where a replay matters.
- **Its body.** It must be JSON and pass the webhook's schema; a refusal tells the sender what failed and nothing more.

Studio can have a delivery sent to your app — **Deliver again** in a delivery's inspector — and the app signs it, so the secret stays where it is. Only an app attached with the project's Studio token is asked, and it delivers only to a route its deliveries came to or to an address on its own machine.

## Caches

`cacheMiddleware({ scope: "public" })` never stores or serves an answer to a call that carries a session, and says so once per endpoint. `scope: "session"` keeps one partition per set of credentials a call sends, named by their SHA-256 digest so that no credential sits in a cache key, a Redis keyspace or a log line. In a browser or a React Native app, where the platform sends the cookies, a session's calls count as carrying it and are partitioned on the device.

## What Courier writes down

- **Its log.** An `HTTPError` carries the API's answer whole. Printed — by Courier's log, `console.error`, or Node's report of an uncaught error — it shows every header with each credential's value hidden: a cookie the API set keeps its name and attributes, an `Authorization` its scheme. `error.headers` still holds them all for the code that reads them. `COURIER_LOG_FORMAT=json` prints an error's name, message, code and status alone. `logMiddleware` logs a failed call's status, never the answer it carried. A warning about something a client sent names it by what the server knows, never in the client's own words.
- **Studio.** Studio keeps what it captured in `.courier/studio.db`, readable by your account alone, in a folder that keeps itself out of Git. The value of `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie`, and of any header whose name has a word such as `auth`, `token`, `secret`, `password`, `session`, `csrf`, `xsrf`, `api-key` or `signature` in it is written as `[redacted]` — a webhook's signature among them, which with its body would let anyone deliver it again. The inspector hides those values until asked, and a request copied as cURL or `fetch` leaves them out. An error's text, its cause and a request's address are shown whole to the page that holds Studio's token, and leave it — written down, exported, told to an agent or shown to a page without the token — with their credentials cut. Studio makes changes only for a page opened with the token in `.courier/studio-token` — see [Studio](/docs/studio/overview). A [session file](/docs/studio/session-files) shared for a bug report carries no credential either, and replaces every body field and query parameter named like a secret, listing each one before it is saved. To follow [contract drift](/docs/studio/contract-drift), Studio keeps the shape of each answer — its fields' names and JSON types, never a value.

## Limits at the edge

| What | Default | Option |
| --- | --- | --- |
| Calls from one caller through the proxy | None — set one | `rateLimit` |
| Requests no browser sent, Studio's Playground in development aside | Served | `browsersOnly` |
| A JSON body to the proxy | 1 MB | `maxBodyBytes` |
| An upload to the proxy | 10 MB | `maxUploadBytes` |
| A stream's lifetime | 30 minutes | `sseMaxLifetimeMs` |
| A WebSocket message | 64 KB | `maxMessageSize` |
| Connections from one address | 50 | `maxConnectionsPerIP` |
| Messages per connection | 100 a second | `maxMessagesPerSecond` |
| Subscriptions per connection | 100 | `maxSubscriptionsPerConnection` |
| Wait for a channel's first frame | 3 seconds | `authTimeoutMs` |

Subscribing, leaving and re-authenticating are counted apart from other messages, so a flood of messages cannot hold back an unsubscribe. A request the server cannot read — an upgrade whose address or `Host` no URL can name, a frame that is not one a client sends — is answered or closed on its own, and a failure while one connection is served ends that connection, never the process the others live in.

## Development and production

| | Development | Production |
| --- | --- | --- |
| A page on another local port | Admitted while `allowedOrigins` is empty and the app is served locally | Never |
| Hosts answered while `allowedHosts` is empty | The local machine's names and IP addresses | Every name |
| The app's origin, for Studio's Playground | Taken, when not configured, from a request whose own `Host` is `localhost`, `127.0.0.1` or `[::1]` on the port the server accepted it on, sent from this machine, as far as the framework tells — see [`appOrigin`](/docs/api/config) | Never taken from a request |
| Studio's Playground under `browsersOnly` | Let through on a one-time pass only Studio's runtime in the process serving the route redeems — see [Proxy mode](/docs/proxy-mode/overview#rate-limits) | Refused, as a script is: no pass is honoured |

Courier counts as in development wherever `NODE_ENV` is not `production`, so set it on every server you deploy, a custom Node server included.

## In production

- Set `NODE_ENV=production`.
- Reach your APIs over HTTPS, and leave `allowHttp` unset. Trust an internal certificate authority with `NODE_EXTRA_CA_CERTS` or an API's own `dispatcher`, never by turning the certificate check off — see [Proxies & Certificates](/docs/advanced/proxies-and-certificates).
- Name the session's cookies exactly, and a prefix only where every cookie under it belongs to the session.
- List in `allowedOrigins` only pages you serve, and in `allowedHosts` the names your application answers to.
- Mark `session: "unguarded"` only on writes that do no harm when a stranger sets them off, and never declare a write `.reads()`.
- Forward the client's address only behind a proxy on the local machine or a private network.
- Print the log as JSON wherever it leaves the machine, with `COURIER_LOG_FORMAT=json`.
- Keep Studio on your own machine: it is a development tool, and its port is never one to expose.
- Give an MCP endpoint an `authorize` that identifies each caller, a `confirmSecret` shared by its replicas, and offer `.agent()` only on endpoints you would hand to an agent.

## Reporting a vulnerability

Report a security issue privately through GitHub's vulnerability reporting on the repository — never in a public issue. What is in scope, and how quickly you will hear back, is in `SECURITY.md`.
