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 describes how a session is set up, and Server Handler 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 | 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 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
sessiongets the cookies its session names. - The browser's own logins.
AuthorizationinBasic,Digest,NegotiateorNTLManswers your application's own login prompt, a staging site's for instance, and is dropped, with one warning per source and scheme. ABearertoken, or any scheme your scripts set, goes on. - Client addresses.
X-Forwarded-For,X-Real-IP,Forwardedand the like can be written by any page. The proxy writesX-Forwarded-Foritself from the connection, and trusts a forwarding chain only from a proxy on the local machine or a private network.
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
Domainonly where it covers your application's host; otherwise the cookie stays with that host alone. Securewhere 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, andSameSite=NonebecomesLax; a__Host-or__Secure-cookie, which cannot exist without it, is refused.session.relay: falsekeeps 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.
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-Sitedecides wherever the browser sends it, which a current browser does on every request to a secure origin:same-origin, andnonefor what the user typed, pass;same-siteandcross-sitepass only from an origin inallowedOrigins. A page on a sibling subdomain is another site here.Originis 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. 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.
- Nothing offered by default. A project that sets no policy offers the endpoints marked
.agent()alone. The policy lives incourier.config.ts, under review: adenypattern wins over everything,.agent(false)over anything that would offer an endpoint, and anaccessceiling 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
authorizelet in, and middleware tells it apart byctx.agent. - Callers you authorize.
createMcpHandlercannot be created withoutauthorize. A toolallowkeeps 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 ofallowedOrigins. - A tool's limit.
.agent({ limit })bounds how often a caller may call a tool — counted per caller withlimitPer— and a call beyond it is refused before anything runs or anyone is asked. - A person confirms what cannot be undone. A
destructivetool runs once the person at the client confirmed that exact call — and so does every write where the policy saysconfirm: "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 withconfirmSecret.allowDestructivestands 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
maxResultCharsis 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.
Webhooks
A webhook is a call made to your app by its sender, and defineWebhook 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
HTTPErrorcarries 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, anAuthorizationits scheme.error.headersstill holds them all for the code that reads them.COURIER_LOG_FORMAT=jsonprints an error's name, message, code and status alone.logMiddlewarelogs 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 ofAuthorization,Proxy-Authorization,Cookie,Set-Cookie, and of any header whose name has a word such asauth,token,secret,password,session,csrf,xsrf,api-keyorsignaturein 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 orfetchleaves them out. Studio makes changes only for a page opened with the token in.courier/studio-token— see Studio. A session file 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, Studio keeps the shape of each answer — its fields' names and JSON types, never a value.
Limits at the edge
| What | Default | Option |
|---|---|---|
| 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 from the first request when not configured | Never taken from a request |
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
allowHttpunset. Trust an internal certificate authority withNODE_EXTRA_CA_CERTSor an API's owndispatcher, never by turning the certificate check off — see Proxies & Certificates. - Name the session's cookies exactly, and a prefix only where every cookie under it belongs to the session.
- List in
allowedOriginsonly pages you serve, and inallowedHoststhe 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
authorizethat identifies each caller, aconfirmSecretshared 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.