# Proxies & Certificates

Reach APIs through a corporate proxy, trust an internal certificate authority and present a client certificate — for the whole process from the environment it starts with, for one API, one group of its endpoints or one call — a bank's mutual TLS step by step, and how Studio, the MCP server and create-courier take the same settings.

## Where they are set

Courier sends every request with the platform's own `fetch` and opens every socket with its own `WebSocket` — Node's, Bun's, Deno's, the browser's — and brings no HTTP client of its own. A proxy and a certificate are set where that platform takes them: for the whole process, in the environment it starts with; for one API, in its definition; for one group of its endpoints, in the group's; or for one call.

| You need | Set |
| --- | --- |
| Every request through the corporate proxy | [The environment](#the-whole-process): `NODE_USE_ENV_PROXY=1` and `HTTPS_PROXY` |
| An internal certificate authority trusted, or a TLS-inspecting proxy's | [The environment](#the-whole-process): `NODE_EXTRA_CA_CERTS`, or `NODE_USE_SYSTEM_CA=1` |
| A client certificate for one API — mutual TLS | [The API's definition](#one-api): a `dispatcher`, with undici's `fetch` and `WebSocket` |
| A client certificate for some of an API's endpoints — the payments a bank asks a certificate for | [The group's definition](#one-group): its own `dispatcher` |
| A client certificate, or a proxy, for one call — a certificate per company you call a bank for | [The call](#one-call): `.with({ dispatcher })` |
| A proxy, or an authority, for one API alone | [The API's definition](#one-api): a `ProxyAgent`, or an `Agent` with its `ca` |

`fetch`, `webSocket` and `dispatcher` — and Bun's `proxy` and `tls`, Deno's `client` — are each taken on their own: from the call when it sets one, else from the nearest group that does, else from the API, else the platform's own. A bank's certificate, step by step, is [below](#a-banks-client-certificate).

## The whole process

Node reads these when the process starts:

| Variable | What it does | Node |
| --- | --- | --- |
| `NODE_USE_ENV_PROXY=1` | Sends requests through the proxy `HTTPS_PROXY` and `HTTP_PROXY` name, except to the hosts `NO_PROXY` names | 22.21 and 24.5 on; 24.0–24.4 for `fetch` alone |
| `HTTPS_PROXY`, `HTTP_PROXY` | The proxy for `https://` and for `http://` requests — with credentials, `http://user:password@proxy.corp:3128` | |
| `NO_PROXY` | The hosts reached directly — [how it is read](#no_proxy) | |
| `NODE_EXTRA_CA_CERTS` | A PEM file of certificate authorities trusted besides Node's own | every version |
| `NODE_USE_SYSTEM_CA=1` | Trusts the authorities the operating system trusts as well | 22.19 and 24.6 on |

`--use-env-proxy` and `--use-system-ca` are the same switches as flags. Where a lowercase name and an uppercase one are both set, the lowercase one is read.

```bash
NODE_USE_ENV_PROXY=1 \
HTTPS_PROXY=http://proxy.corp:3128 \
NO_PROXY=localhost,127.0.0.1,.corp.internal \
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corp-root.pem \
node server.js
```

Every request Courier makes from that process goes through the proxy — calls, streams and channels alike — with nothing in an API's definition. Set the variables where the process is started: the shell, the service's unit, the container, the editor's `env` for [an MCP server](#the-mcp-server). A `.env` file the application loads is read too late, after Node has set up its proxy and the authorities it trusts.

### NO_PROXY

Entries are separated by commas, and case does not matter.

| Entry | Reached directly | |
| --- | --- | --- |
| `*` | Every host | |
| `api.corp.internal` | That host | |
| `api.corp.internal:8443` | That host on that port | |
| `.corp.internal` | `corp.internal` and every name under it | |
| `*.corp.internal` | Every name under `corp.internal` | `fetch` and `WebSocket` take `corp.internal` itself too |
| `10.0.0.7` | That address | |
| `10.0.0.1-10.0.0.255` | A range of IPv4 addresses | `node:http` alone, which Studio fetches with; `fetch` and `WebSocket` read no ranges |

Name hosts exactly, by address, or as a `.domain` suffix, and every client in the process reads the list the same way.

## One API

A client certificate, a proxy or an authority for one API alone is undici's `dispatcher`. Give the API's definition the dispatcher, and the `fetch` and `WebSocket` of the same undici:

```ts title="lib/sources.ts"
import { readFileSync } from "node:fs"
import { courier, defineApi } from "@noverax/courier/define"
import { Agent, fetch, WebSocket } from "undici"

const { endpoint } = courier()

export const payments = defineApi({
  name: "payments",
  baseURL: "https://payments.internal/v1",
  dispatcher: new Agent({
    connect: {
      ca: readFileSync("/etc/payments/ca.pem"),
      cert: readFileSync("/etc/payments/client.pem"),
      key: readFileSync("/etc/payments/client.key"),
    },
  }),
  fetch,
  webSocket: WebSocket,
  endpoints: {
    charge: endpoint.post("charges"),
  },
})
```

The dispatcher reaches everything the API sends: its calls and uploads, its streams, its channels' sockets, and what a [proxy route](/docs/proxy-mode/api-route) relays to it for the browser — except where a group or a call sets its own. Two APIs with dispatchers of their own never share a socket, though they reach the same host.

`fetch` and `webSocket` are what hand it on. Node's own refuse a dispatcher from another major version of undici with `UND_ERR_INVALID_ARG` — Node 24 is built on undici 7, while `npm install undici` installs 8 — so all three come from the same `undici`.

Through a proxy of its own:

```ts title="lib/sources.ts"
import { readFileSync } from "node:fs"
import { courier, defineApi } from "@noverax/courier/define"
import { fetch, ProxyAgent, WebSocket } from "undici"

const { endpoint } = courier()

export const partner = defineApi({
  name: "partner",
  baseURL: "https://api.partner.example/v2",
  dispatcher: new ProxyAgent({
    uri: "http://proxy.corp:3128",
    requestTls: { ca: readFileSync("/etc/ssl/certs/corp-root.pem") },
  }),
  fetch,
  webSocket: WebSocket,
  endpoints: {
    orders: endpoint.get("orders"),
  },
})
```

`requestTls` is the TLS to the API through the proxy's tunnel; `proxyTls`, the TLS to a proxy reached over `https://`. `EnvHttpProxyAgent` follows `HTTPS_PROXY` and `NO_PROXY` for one API, without turning Node's proxy on for the whole process.

> **Keep the key on the server**
>
> A definition that reads a key from disk runs wherever it is imported. In proxy mode the browser imports the [routes](/docs/proxy-mode/expose) Courier writes from the API instead, which hold its endpoints' names and nothing else, so `node:fs`, undici and the key never reach its bundle.

### Another fetch

`fetch` takes any function called as `fetch` is that answers with a `Response`: the `fetch` of a Cloudflare `mtls_certificates` binding, which presents the certificate the binding holds; that of a `Bun.FetchSession`, which keeps a proxy and an authority of its own; one that signs or records each request. It is typed to take undici's as it is, whose `RequestInit` and `Response` are undici's own types. What a `fetch` answers is checked: anything but a `Response` is refused with `ConfigurationError("SOURCE_TRANSPORT_INVALID")`.

In a browser and in React Native, an upload that reports its progress goes through `XMLHttpRequest`, which can report it, rather than through an API's `fetch`.

## One group

A group's `dispatcher`, `fetch` and `webSocket` are for its endpoints alone, and for those of the groups inside it. The bank below is reached with the platform's trust for its accounts, while its payments present the company's certificate:

```ts title="lib/sources.ts"
import { readFileSync } from "node:fs"
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import { Agent, fetch, WebSocket } from "undici"

const { endpoint } = courier()

export const bank = defineApi({
  name: "bank",
  baseURL: "https://api.bank.example/v1",
  fetch,
  webSocket: WebSocket,
  groups: {
    accounts: defineGroup({
      prefix: "accounts",
      endpoints: { list: endpoint.get("") },
    }),
    payments: defineGroup({
      prefix: "payments",
      dispatcher: new Agent({
        connect: {
          cert: readFileSync("/etc/bank/company.pem"),
          key: readFileSync("/etc/bank/company.key"),
          passphrase: process.env.BANK_KEY_PASSPHRASE,
        },
      }),
      endpoints: { create: endpoint.post("").variables<{ amount: number; to: string }>() },
    }),
  },
})
```

The group's dispatcher reaches its calls, its streams, its channels' sockets and what a proxy route relays for them; a group inside `payments` takes it too, unless it sets one of its own. The API's `fetch` and `webSocket` hand it on, as they hand on the API's own. Channels of two groups with different dispatchers never share a socket. `defineGroup` refuses a `fetch` or a `webSocket` that is not a function, and a `dispatcher` with no `dispatch` method, with `ConfigurationError("SOURCE_TRANSPORT_INVALID")`.

## One call

`.with()` gives the calls made through what it returns a transport of their own, over their group's and their API's:

```ts title="lib/payments.ts"
import type { Company } from "./companies"
import { Agent } from "undici"
import { createCaller } from "@noverax/courier"
import { bank } from "./sources"

const client = createCaller(bank)
const agents = new Map<string, Agent>()

/* One agent for each company's certificate, reused for every call made for it. */
function agentFor(company: Company): Agent {
  let agent = agents.get(company.id)
  if (!agent) {
    agent = new Agent({
      connect: { cert: company.cert, key: company.key, passphrase: company.passphrase },
    })
    agents.set(company.id, agent)
  }
  return agent
}

export function pay(company: Company, payment: { amount: number; to: string }) {
  return client.payments.with({ dispatcher: agentFor(company) }).create(payment)
}
```

An agent keeps its connections open for the calls after the first, so make one for each certificate and reuse it: one made for every call opens a connection for every call and keeps it until it is closed. A call's `fetch` sends that call alone, and its streams and channels take its `dispatcher` too, each channel on a socket of its own transport.

The call's own transport is for calls sent where it is made — a caller from `createCaller`, on the server, in a script, in the MCP server. A call through the [proxy](/docs/proxy-mode/api-route) is sent by the server, with the API's or the group's own; set on it, `fetch`, `webSocket` or `dispatcher` would reach only the browser's request to the proxy route, so a proxy client refuses them with `ConfigurationError("PROXY_OPTION_NOT_ALLOWED")` before anything is sent.

## A bank's client certificate

A bank's API asks for a client certificate over TLS 1.2, issued from a request the bank's instructions walk through:

- the key, made with `openssl genrsa -des3 -out Company.key 2048`, so it is kept under a passphrase;
- the certificate the bank issues for it, `Company.cer`, valid for a year, under the bank's own authority — a root and an intermediate;
- or both in one PKCS#12 bundle with that chain, `openssl pkcs12 -export -out Company.pfx -inkey Company.key -in Company.cer -certfile root.cer -certfile sub.cer`;
- and an access token, sent as `Authorization: Bearer …` beside the certificate.

Node takes the certificate as it comes. The key and the certificate:

```ts title="lib/bank-agent.ts"
import { readFileSync } from "node:fs"
import { Agent } from "undici"

/** An agent presenting the company's certificate, and the bank's intermediate after it. */
export function bankAgent(): Agent {
  return new Agent({
    connect: {
      cert: Buffer.concat([
        readFileSync("/etc/bank/Company.cer"),
        readFileSync("/etc/bank/sub.cer"),
      ]),
      key: readFileSync("/etc/bank/Company.key"),
      passphrase: process.env.BANK_KEY_PASSPHRASE,
      minVersion: "TLSv1.2",
    },
  })
}
```

Or the bundle:

```ts title="lib/bank-agent.ts"
import { readFileSync } from "node:fs"
import { Agent } from "undici"

export function bankAgent(): Agent {
  return new Agent({
    connect: {
      pfx: readFileSync("/etc/bank/Company.pfx"),
      passphrase: process.env.BANK_PFX_PASSPHRASE,
      minVersion: "TLSv1.2",
    },
  })
}
```

- **The passphrase.** Without it, Node cannot read the key and the connection fails with `bad decrypt`. Keep it where the server's other secrets are, never beside the key or in the repository.
- **The chain.** `cert` is the company's certificate followed by the bank's intermediate, in one PEM — the bundle already holds the chain. A `.cer` that does not start with `-----BEGIN CERTIFICATE-----` is DER; `openssl x509 -inform der -in Company.cer -out Company.pem` makes it PEM.
- **The bank's server.** Node checks it against the authorities it trusts. When the bank's server certificate is issued by the bank's own authority, add that authority — `ca` replaces Node's list, so keep the list: `ca: [...rootCertificates, readFileSync("/etc/bank/root.cer")]`, with `rootCertificates` from `node:tls`.
- **TLS 1.2.** `minVersion: "TLSv1.2"` refuses anything older, and a server that speaks 1.2 alone is reached over 1.2. Pin `maxVersion: "TLSv1.2"` as well only when the bank asks for exactly that version.
- **An old bundle.** A `.pfx` exported by OpenSSL 1.1 or older, or with `-legacy`, is encrypted with RC2 and 3DES, which Node 17 and later refuse with `Unsupported PKCS12 PFX data`. Export it again with OpenSSL 3, whose `openssl pkcs12 -export` encrypts with AES-256, or give Node the key and the certificate instead.

The access token goes with every request as any API's does, from [`authMiddleware`](/docs/advanced/middleware#authmiddleware):

```ts title="lib/sources.ts"
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import { authMiddleware } from "@noverax/courier/middleware"
import { fetch, WebSocket } from "undici"
import { bankAgent } from "./bank-agent"
import { accessToken } from "./bank-token"

const { endpoint } = courier()

export const bank = defineApi({
  name: "bank",
  baseURL: "https://api.bank.example/v1",
  fetch,
  webSocket: WebSocket,
  middleware: [authMiddleware({ getToken: accessToken })],
  groups: {
    payments: defineGroup({
      prefix: "payments",
      dispatcher: bankAgent(),
      endpoints: { create: endpoint.post("").variables<{ amount: number; to: string }>() },
    }),
  },
})
```

### Renewing the certificate

The certificate expires a year after it is issued. A dispatcher that hands each request to the agent of the day presents the renewed certificate from the next connection on, with no restart:

```ts title="lib/bank-dispatcher.ts"
import type { Dispatcher } from "undici"
import { bankAgent } from "./bank-agent"

let agent = bankAgent()

/** The bank's dispatcher: each new connection presents the certificate read last. */
export const bankDispatcher = {
  dispatch: (options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler) =>
    agent.dispatch(options, handler),
}

/** Read the renewed certificate; requests already sent finish on the old one. */
export async function renewBankCertificate(): Promise<void> {
  const retired = agent
  agent = bankAgent()
  await retired.close()
}
```

Give the group `dispatcher: bankDispatcher`, and call `renewBankCertificate()` once the new files are in place — on `SIGHUP`, from the job that installs them. Watch the date it runs out, `new X509Certificate(readFileSync("/etc/bank/Company.cer")).validTo` from `node:crypto`, so the renewal is asked for before then.

## Bun and Deno

**Bun** reads `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` by itself, with no switch to turn on. Its `proxy` and `tls` options (`ca`, `cert`, `key`, `passphrase`), set in an API's definition, a group's or a call's, reach the calls and streams they are set for, and the sockets, whose `WebSocket` takes them too.

**Deno** reads `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY`, and trusts the authorities in `DENO_CERT` (or `--cert`), the system's with `DENO_TLS_CA_STORE=system`, and from Deno 2.8 those in `NODE_EXTRA_CA_CERTS`. A `client` from `Deno.createHttpClient()` in an API's definition, a group's or a call's reaches the calls and the streams it is set for; channels open with Deno's `WebSocket` as it is.

## Browsers and React Native

A browser sends Courier's requests with its own proxy settings and its own trust store, and presents a client certificate itself when a server asks for one — the user picks which. There is nothing to set in Courier: a browser's sockets open without an API's `dispatcher`, `proxy` or `tls`, and its `fetch` ignores them.

React Native goes through the device's networking: its proxy, and the authorities it trusts — on Android, an authority the user installed only where the app's network security configuration allows it.

## Studio

Studio fetches a specification imported by URL itself — in the import dialog, with `courier-studio import`, when it checks an import for changes, and for `create-courier --openapi`. Started with Node's proxy turned on, it fetches through the proxy, as the project's own requests go:

```bash
NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://proxy.corp:3128 NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corp-root.pem npx @noverax/courier-studio
```

It still checks each host before the proxy is asked, as it checks a host it connects to itself: a private address the project does not name in `studio.allowedSpecHosts` is refused, and this machine is always reached directly — a proxy asked for `localhost` would reach its own. A name this machine cannot resolve is left to the proxy. An answer through a proxy does not show where it came from, so a redirect in it never leads back to this machine; a public demo's visitors never go through the proxy at all.

When a host cannot be reached, the error says what to set: `NODE_USE_ENV_PROXY=1`, when a proxy is set in the environment but Node was not told to use it; `NODE_EXTRA_CA_CERTS` or `NODE_USE_SYSTEM_CA=1`, when Node does not trust the host's certificate; the proxy's credentials, when the proxy answers `407`. A proxy is named by its origin, never with the credentials in its URL.

Calls Studio's playground sends to your APIs go through their definitions — the API's and the group's `dispatcher` and `fetch` included.

## The MCP server

The editor starts the MCP server, so the variables go in the server's `env`:

```json title=".cursor/mcp.json"
{
  "mcpServers": {
    "courier": {
      "command": "npx",
      "args": ["@noverax/courier-mcp", "--dotenv", ".env.local"],
      "env": {
        "NODE_USE_ENV_PROXY": "1",
        "HTTPS_PROXY": "http://proxy.corp:3128",
        "NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/corp-root.pem"
      }
    }
  }
}
```

VS Code's `.vscode/mcp.json` takes the same `env`. The file `--dotenv` names is loaded too late for these, as an application's `.env` is. The server's tools call your APIs as their definitions say, so an API's or a group's `dispatcher` applies to an agent's calls as it does to your app's.

## create-courier

`npx @noverax/create-courier` installs packages with npm, which takes its own settings — `npm config set https-proxy http://proxy.corp:3128` and `npm config set cafile /etc/ssl/certs/corp-root.pem`, or the `HTTPS_PROXY` variable — and imports a specification given with `--openapi` as [Studio](#studio) does.

## Security

- Use only proxies trusted and authorized for the deployment. A proxy sees where every request goes, and reads what it carries wherever TLS ends at the proxy — a TLS-inspecting proxy reads all of it.
- Keep a client certificate's key where only the server reads it, and out of the repository; keep its passphrase with the server's other secrets, never beside the key.
- `rejectUnauthorized: false` and `NODE_TLS_REJECT_UNAUTHORIZED=0` stop the server's certificate from being checked at all. Trust its authority instead, with `ca` or `NODE_EXTRA_CA_CERTS`.

## Next steps

- [Options](/docs/advanced/options) — every option an API's definition takes
- [Security Model](/docs/security/overview) — what the proxy route lets through, and what it never does
- [Importing an API](/docs/studio/import-api) — Studio's import, and which hosts it reaches
- [Agents in your editor](/docs/agents/editor) — the MCP server's configuration
