Skip to content

Proxy ModeProxy Mode

Proxy Mode

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

13 min readUpdated proxy mode

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
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 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.
  • 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:

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

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.

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

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

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

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