# Proxy Mode

Framework-agnostic reverse proxy that hides secrets, bypasses CORS, and carries the cookies you name from any frontend to any backend.

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

1. The browser calls `createProxyCaller`, which POSTs (or GETs) to a configurable proxy URL
2. The server handler (`createHandler`) validates the origin, parses the payload, and calls `executeRequest`
3. `executeRequest` looks up the source, group, and endpoint, creates an `HttpClient`, and makes the real HTTP call to your backend
4. The response is forwarded back to the browser

```text title="Architecture"
┌──────────────────────────────────────────────────────────────┐
│  Browser (Client Component)                                  │
│                                                              │
│  createProxyCaller(apiRoutes)                                │
│       │                                                      │
│       ▼                                                      │
│  fetch GET  "/api/request?_s=…&_g=…&_m=…&_v=…" (cached read) │
│  fetch POST — body { variables, options }      (any other)   │
└──────────────────┬───────────────────────────────────────────┘
                   │  GET or POST /api/request
                   ▼
┌──────────────────────────────────────────────────────────────┐
│  Server Handler (any framework)                              │
│                                                              │
│  createHandler({ sources }) → handle, handleGet, handlePost  │
│       │  → only endpoints marked .expose()                   │
│       │  → 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`. The page's client takes the [routes](/docs/proxy-mode/expose) Courier writes in place of the API object, so the bundle carries the names of the endpoints you opened and nothing else.
- **Refuse what you did not open** — the proxy serves only the endpoints you mark `.expose()`, on the endpoint, its group or its API, and answers any other as a name that does not exist. An endpoint written for server code alone stays the server's until someone marks it.
- **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's `set-cookie` coming back, for the sources and endpoints that name them. See [Cookies](/docs/proxy-mode/api-route#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:

```ts title="Request lifecycle"
// 1. Browser — call a typed endpoint
const caller = createProxyCaller(apiRoutes)
const user = await caller.users.getById({ id: 5 })

// 2. Under the hood, Courier sends a read that declares .cache() as GET,
//    its variables as JSON in _v:
//    GET /api/request?_s=api&_g=users&_m=getById&_v={"id":5}
//    (any other call 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 → browser
```

## Security

Proxy mode includes four built-in security layers: the endpoints you marked, host validation, origin validation and header whitelisting. [Security Model](/docs/security/overview) follows the user's credentials through every layer — the proxy, your server, streams and channels — and what Courier writes down.

### Only what you mark

The proxy addresses endpoints by name, and resolves the name against every source your application registered. It serves an endpoint only when a mark opens it — `.expose()` on the endpoint, or `expose` on its group or its API, the nearest one deciding — and answers any other as a name the source does not define, so a caller guessing names learns nothing of what exists. Every proxy route — GET, POST, SSE and WebSocket — applies the marks. See [Exposing Endpoints](/docs/proxy-mode/expose).

### 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`:

```json
{ "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 `*.localhost` name and IP literals (`127.0.0.1`, `[::1]`, `192.168.1.20`) are always served.
- Every other name is served when `allowedHosts` lists it — exactly, or `*.example.com` for 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.

```ts
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](/docs/deployment/overview#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-origin` and `none` (the user typed the address) are served; `same-site` and `cross-site` are served only when their `Origin` is in `allowedOrigins`. An `<img>` or a link on another site carries no `Origin`, so it is refused.
- Where it does not — an older browser, or a site served over plain HTTP — the `Origin` must 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.

```ts
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`. 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 the proxy with the visitor's cookies — so prefer `https://*.example.com` unless a page served over HTTP must call it.

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.

### Rate limits

The proxy calls your APIs with your server's keys, for anyone who asks: the origin check refuses another site's page, while a script that sends no `Origin` is served, as a server and a CDN are. `rateLimit` holds each caller to what a person would make. Every call takes one from `requests` in each `window`, a write takes one from `writes` first, and a call past either is answered `429` before its body is read — the API never sees it:

```ts
courierHandler({
  rateLimit: {
    requests: 300,                              // calls one caller may make…
    window: 60_000,                             // …in each minute
    writes: { requests: 20, window: 60_000 },   // and of those, writes
  },
})
```

A budget lets its calls go at once and then frees one each `window / requests`, so a caller cannot spend two budgets across a window's edge. The answer to a refused call carries `Retry-After`, and every answer tells the caller its budgets in the IETF's `RateLimit-Policy` and `RateLimit` headers — `"calls";q=300;w=60` and `"calls";r=299;t=1`; a page on a listed origin can read them too. A refused call's body is `{ "success": false, "code": "RATE_LIMITED", "message": … }`, and Studio shows it refused.

A caller is the user's address, read from the right of the forwarding chain past your own proxies — see [The user's address](/docs/deployment/overview#the-users-address) — so a user cannot become another caller by writing an address of their own, and an IPv6 address counts by its /64 network, which one subscriber gets whole. Many users behind one office's address share its budget; count signed-in users apart by their session with `key`, which returns `undefined` to leave a request unlimited:

```ts
courierHandler({
  rateLimit: {
    requests: 300,
    window: 60_000,
    key: (request) => sessionIdOf(request) ?? addressOf(request),
  },
})
```

The counts are kept in the process. Replicas behind one balancer share them in Redis, where every call is taken in one script on Redis's clock — see [Redis](/docs/deployment/redis#rate-limit-store):

```ts
import { createRedisRateLimitStore } from "@noverax/courier-redis"

courierHandler({
  rateLimit: { requests: 300, window: 60_000, store: createRedisRateLimitStore({ redis }) },
})
```

`browsersOnly: true` refuses with `403` a request that carries neither `Sec-Fetch-Site` nor `Origin`, as a script, curl or a crawler sends. It keeps that noise off your APIs and protects nothing — any client can send both headers — and it refuses server code that calls the proxy route over HTTP, and a same-origin read from a browser that sends neither, such as Safari before 16.4. The refusal's body is `{ "success": false, "code": "BROWSERS_ONLY", "message": … }`. The WebSocket handler does not apply it.

Studio's Playground runs a Proxy-mode call from your server's process, so it sends neither header either. In development, Studio's runtime gives each such call a one-time pass, and the proxy lets a call that carries one past `browsersOnly` and past nothing else: `allowedHosts`, `.expose()`, `rateLimit`, the body limits and Courier's middleware still apply.

- **The pass.** 256 random bits in the `X-Courier-Studio-Pass` header, good for one request within 30 seconds; each connection of a stream gets one of its own. An unknown, spent or expired pass is a request without one, answered the same.
- **Where it goes.** Only to a proxy route on this machine — `localhost`, an address in `127.0.0.0/8` or `[::1]` — or on the app's origin: the one `runtime.appOrigin`, `COURIER_APP_URL` or `APP_URL` names, or, in development, the one the server tells of itself (see [How the app learns its origin](/docs/api/config#app-origin)). No header a request sends decides that origin. A Playground call follows a redirect only on its route's own origin, so neither the pass nor the call's headers leave for one a redirect names.
- **Who sees it.** Only the runtime attached in the process that serves the route can redeem it, and the proxy takes the header off every request before Courier's middleware and hooks, the upstream or Studio sees it — where the runtime lets it change an incoming request's headers. Under one that does not, as `Deno.serve` and workerd, the pass is never honoured, and the header stays on for the middleware and Studio's events to see, shown there as a credential; the upstream never sees it. Your framework's own middleware runs before the route — an Express `app.use`, a Next.js `middleware.ts` or `proxy.ts`, a SvelteKit `handle` hook — so it sees the header: keep it out of what such middleware logs, as you keep `Authorization` out.
- **In production** nothing gets through on a pass: the runtime connects to Studio only in development, so it issues and redeems none there, and the proxy honours none where `NODE_ENV` is `production` or where there is no `process` to ask.

When the route runs where the pass cannot be redeemed — an Edge route, a Cloudflare worker, an app behind a remote `appOrigin`, or a runtime that may not change the incoming request's headers, as under `Deno.serve` — or the pass does not arrive whole and in time — a proxy in front of the route takes the header off, the route is reached more than 30 seconds later, as while it compiles on first request, more than 256 calls are waiting for theirs, or the pass was already spent — the Playground answers `browsersOnly refused this call` and offers the ways past it: send the call again, run it in Direct mode, set `browsersOnly: process.env.NODE_ENV === "production"` so development answers every caller, or serve the proxy route on the Node runtime, in the process Studio's runtime is attached to.

### Upstream redirects

A proxied call carries the server's credentials — whatever your middleware added — and so does a stream the proxy relays. 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 relayed stream ends with code `4000` plus the status (`4302` for a 302). A source that trusts every redirect its upstream sends can say so:

```ts
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:

```typescript
// 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](/docs/proxy-mode/api-route#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.

A forwarded header never replaces one your server sets. An API that sends a service token in its `headers` sends that token whatever `Authorization` the page wrote; the same holds for a `Referer`, an `X-Request-Id` or any other header the API's `headers`, its session, a middleware or an endpoint's closure set. The browser's copy goes on only where nothing on the server set one.

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 — the socket's address on Node, the one SvelteKit, SolidStart, Astro, h3 or Hono's runtime reports elsewhere. Behind a reverse proxy on a loopback or private address, or one `COURIER_TRUSTED_PROXIES` names, the chain that proxy wrote is kept and its own address appended; a caller on a public address is the whole chain. Next.js, React Router and edge functions report no connection, and there the chain the proxy in front recorded goes on as it came — Next.js writes the socket's address there itself where no proxy did. See [The user's address](/docs/deployment/overview#the-users-address). 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.

```ts title="Comparison"
// Proxy mode — browser cannot see API_URL or secrets
// Browser → GET /api/request → Server → GET https://internal-api.company.com/users
const caller = createProxyCaller(apiRoutes)
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

- [Exposing Endpoints](/docs/proxy-mode/expose) — mark what a page may call, and give its client the routes
- [createProxyCaller](/docs/proxy-mode/proxy-caller) — set up the browser-side proxy client
- [Server Handler](/docs/proxy-mode/api-route) — configure the server-side proxy endpoint
- [Security Model](/docs/security/overview) — where credentials travel, and what each layer refuses
- [Path Parameters](/docs/advanced/path-params) — dynamic URL segments with `:param` syntax
