What is proxy mode?
In proxy mode, browser requests never reach your backend directly. Instead, every call goes through a server-side handler that acts as a reverse proxy:
- The browser calls
createProxyCaller, which POSTs (or GETs) to a configurable proxy URL - The server handler (
createHandler) validates the origin, parses the payload, and callsexecuteRequest executeRequestlooks up the source, group, and endpoint, creates anHttpClient, and makes the real HTTP call to your backend- The response is forwarded back to the browser
┌──────────────────────────────────────────────────────────────┐
│ Browser (Client Component) │
│ │
│ createProxyCaller<typeof api>("api") │
│ │ │
│ ▼ │
│ fetch GET "/api/request?_s=…&_g=…&_m=…&_v=…" (a read) │
│ fetch POST — body { variables, options } (a write) │
└──────────────────┬───────────────────────────────────────────┘
│ GET or POST /api/request
▼
┌──────────────────────────────────────────────────────────────┐
│ Server Handler (any framework) │
│ │
│ createHandler({ sources }) → handle, handleGet, handlePost │
│ │ → origin validation │
│ │ → cookies only where named │
│ │ → header whitelisting │
│ ▼ │
│ executeRequest(sources, payload, context) │
│ │ │
│ ▼ │
│ HttpClient → fetch → GET/POST/PUT/PATCH/DELETE │
└──────────────────┬───────────────────────────────────────────┘
│ HTTP request
▼
┌──────────────────────────────────────────────────────────────┐
│ Backend API (any REST server) │
│ e.g. Express, FastAPI, Rails, Go │
└──────────────────────────────────────────────────────────────┘Why use proxy mode?
- Hide API secrets — your backend URL, API keys, and tokens stay on the server. The browser only sees
/api/request. Declare a boundary to keep the API definition itself off the wire: pass it in place of the API object, and the bundle carries the endpoint names you opened and nothing else. - Refuse what you did not open — the proxy addresses endpoints by name, so without a boundary every endpoint your app registered is callable from a page, including the ones only server code was meant to reach.
- Bypass CORS — the proxy handler runs on the same origin as your frontend, so there are no cross-origin restrictions.
- Carry your session — the browser sends its cookies to the proxy route (
credentials: "include"by default), and they cross to the backend, with the backend'sset-cookiecoming back, for the sources and endpoints that name them. See Cookies. - Centralized error handling — the handler catches errors and returns consistent JSON responses with proper HTTP status codes.
- Framework-agnostic — works with Next.js, Hono, Express, Fastify, Nuxt, and NestJS through thin adapter packages.
Request flow
Here is the complete flow from a browser call to the backend response:
// 1. Browser — call a typed endpoint
const caller = createProxyCaller<typeof api>("api")
const user = await caller.users.getById({ id: 5 })
// 2. Under the hood, Courier sends a read as GET, its variables as JSON in _v:
// GET /api/request?_s=api&_g=users&_m=getById&_v={"id":5}
// (a write goes as POST, with { "variables", "options" } in the body)
// 3. Server handler reads the endpoint and its variables from the URL,
// creates an HttpClient, and calls: GET {baseURL}/users/5
// 4. Response flows back: backend → handler → browserSecurity
Proxy mode includes three built-in security layers: host validation, origin validation and header whitelisting. Security Model follows the user's credentials through every layer — the proxy, your server, streams and channels — and what Courier writes down.
Host validation
Every proxy route — GET, POST, SSE and WebSocket — answers only for the host names the application is reached by. A request addressed to any other host gets 421 Misdirected Request, and a WebSocket opened on one is closed with 4421:
{ "success": false, "code": "HOST_NOT_ALLOWED", "message": "Host \"rebound.example:3000\" is not served here. Add its name to allowedHosts if this application is reached by it." }This stops DNS rebinding, which origin validation cannot see: a page on the attacker's own name points that name at the machine your app runs on, so its calls reach your app addressed to the attacker's name, same-origin as far as the browser is concerned. Only your app knowing its own names can refuse them.
localhost, any*.localhostname and IP literals (127.0.0.1,[::1],192.168.1.20) are always served.- Every other name is served when
allowedHostslists it — exactly, or*.example.comfor its sub-domains — on any port. - With no
allowedHosts, development serves nothing else, as Vite does. Production serves every name, because it is reached through names you routed to it; list them to have it refuse the rest too.
courierHandler({
// The names this app is reached by, beyond localhost and IP addresses
allowedHosts: ["app.example.com", "*.preview.example.com"],
})The WebSocket handlers take the same option. Behind a reverse proxy the host is the one the browser used, read from X-Forwarded-Host where the adapter trusts it — see Behind a Reverse Proxy.
Origin validation
Every proxy route — GET, POST, SSE and WebSocket — refuses a request another site's page made, with 403 Origin not allowed. This stops a third-party page from calling your API with your users' cookies:
- Where the browser sends
Sec-Fetch-Site, it decides.same-originandnone(the user typed the address) are served;same-siteandcross-siteare served only when theirOriginis inallowedOrigins. An<img>or a link on another site carries noOrigin, so it is refused. - Where it does not — an older browser, or a site served over plain HTTP — the
Originmust be the origin the request was sent to, or be listed. - A request with neither header is not a browser request from another page, and is served.
courierHandler({
// Pages on these origins may call the proxy too
allowedOrigins: ["https://admin.example.com", "https://*.example.com"],
})https://*.example.com covers the sub-domains of example.com over HTTPS on the default port, not the apex. A pattern without a scheme, *.example.com, covers them over any scheme on its default port; name the port to cover another, as in *.example.com:8443.
An entry is written as a browser sends an origin — no path, no trailing slash, the scheme's default port left out — since one written otherwise could never match. Such an entry stops the handler from being created, with INVALID_ALLOWED_ORIGIN naming the form to use. null is refused as well: every sandboxed frame and data: page sends it, whatever site it belongs to.
A page on a listed origin calls the proxy with CORS, and the handler answers for it: a preflight gets 204 with Access-Control-Allow-Origin and Access-Control-Allow-Credentials, the headers it asked to send and the methods the proxy takes, and every answer carries the same permission along with Access-Control-Expose-Headers. The preflight is an OPTIONS request, so route OPTIONS to the handler too — app.all(...) in Express and Hono, export const { GET, POST, OPTIONS } = apiRoute(...) in Next.js — and on the SSE route as well, since a stream that resumes sends Last-Event-ID. The development allowance for local ports is not a listing: it lets such a page's requests through, but gives it no permission to read the answers.
Upstream redirects
A proxied call carries the server's credentials — whatever your middleware added — so the proxy follows an upstream redirect only while it stays on the origin the call was sent to. A redirect to another origin is not followed: the caller receives its status as an error, without its Location, and the credentials never reach the other host. A source that trusts every redirect its upstream sends can say so:
defineApi({ baseURL: "https://api.example.com", redirect: "follow", groups: { users } })Server-side callers keep fetch's own handling.
Header whitelisting
Not all browser headers are forwarded to the backend. Courier uses a whitelist to prevent leaking internal headers while passing through the ones your backend needs:
// Only these headers are forwarded from the browser to the backend:
const whitelistedHeaders = [
"referer",
"origin",
"authorization",
"user-agent",
"x-request-id",
// ... plus custom headers via allowedHeaders option
]Cookie is not on the list. The browser sends every cookie of your application's origin, your own session among them, so a source or an endpoint that should receive them names cookie — see Cookies.
Authorization goes on when a script set it — a Bearer token, or a scheme of the API's own. The browser also sends one by itself: once a user has answered your site's own login prompt — a staging site behind HTTP Basic authentication, an intranet signed in with Kerberos — every request to it carries Basic, Digest, Negotiate or NTLM credentials. Those are your site's, not the API's, so they stay behind, and the log names the API once. A source whose upstream takes the same credentials names authorization, in allowedHeaders or on one endpoint with .headers([...]), and receives them as the browser sent them.
The headers that claim a client address — X-Forwarded-For, X-Real-IP, Forwarded, X-Client-IP — are not copied from the browser, which can write any of them. The proxy writes X-Forwarded-For itself from the connection it served. Behind a reverse proxy on a loopback or private address, the chain that proxy wrote is kept and its own address appended; a caller on a public address is the whole chain. Where the runtime does not tell the proxy the caller's address — the Web-standard adapters — no X-Forwarded-For is sent. A platform that writes the header itself, such as a CDN that overwrites whatever the client sent, can have its copy forwarded by naming it: allowedHeaders: ["x-forwarded-for"].
The backend's answer is filtered the same way: the browser receives the headers that describe any answer — ETag, Cache-Control, Last-Modified, Retry-After, the rate-limit headers and a few more — and nothing the backend attached for itself.
Custom headers
Need more? Name them with the allowedHeaders option in defineApi(), or on one endpoint with .headers([...]). A named header crosses the proxy both ways: from the browser to the backend, and from the backend's answer back to the browser — a Location, a Link, an X-Total-Count. Cookies are named the same way: cookie for the browser's, set-cookie for the backend's.
When to use proxy vs direct mode
Use proxy mode for client components where the browser initiates the request. Use direct mode for server components and server actions where the server calls the backend directly with zero overhead.
// Proxy mode — browser cannot see API_URL or secrets
// Browser → GET /api/request → Server → GET https://internal-api.company.com/users
const caller = createProxyCaller<typeof api>("api")
const users = await caller.users.getAll()
// Direct mode — server calls backend directly, no proxy hop
// Server → GET https://internal-api.company.com/users
const sameUsers = await directCaller.users.getAll()Same API definitions
Both modes share the same defineApi and defineGroup definitions. You define your API once and use it from both client and server code with full type safety.
Next Steps
- createProxyCaller — set up the browser-side proxy client
- Server Handler — configure the server-side proxy endpoint
- Security Model — where credentials travel, and what each layer refuses
- Path Parameters — dynamic URL segments with
:paramsyntax