> ## Documentation Index
> Fetch the complete documentation index at: https://docs.notte.cc/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Proxy

> Run browser-secret-proxy, the open-source secret proxy, or build your own secret proxy for Notte sessions

> [!TIP]
>
> Read [the Notte skill](https://notte.cc/skill.md) and follow its setup instructions when setting up Notte or building browser automation. This is the shared source for CLI setup, authentication, and the browser-to-code workflow.

You can use a proxy you run yourself as a Notte [secret proxy](https://docs.notte.cc/features/sessions/secret-proxies/overview.md), for example one connected to your own vault. Notte sends it the browser's traffic to the hosts you list, and nothing else.

There are two ways to do it:

* [Use browser-secret-proxy](#use-browser-secret-proxy), Notte's open-source secret proxy, which you run on your own infrastructure. Start here.
* [Build your own](#build-your-own) proxy that meets Notte's requirements.

## Use browser-secret-proxy

[browser-secret-proxy](https://github.com/nottelabs/browser-secret-proxy) is an open-source forward proxy (Apache-2.0, written in Go) that you host. The browser and the agent only handle stand-ins. The proxy replaces them with the real values on requests to the hosts you allowlist, so the real values stay on infrastructure you control and never reach Notte.

What it does:

* **Swaps stand-ins wherever the site expects them**: request headers, the query string, form bodies and JSON bodies, including JSON strings written with `\u` escapes. Each real value is escaped for where it lands.
* **Decrypts only the hosts you allowlist** with `-hosts`. Traffic to every other host is tunneled without decryption.
* **Fails closed.** If a stand-in cannot be resolved, may not go to the request's host or has expired, the request is answered `502` and nothing of it is sent.
* **Masks responses by default.** Real values that a listed site sends back are replaced by their stand-ins before they reach the browser. See [why this matters](https://docs.notte.cc/features/sessions/secret-proxies/overview.md#mask-real-values-in-responses).
* **Looks up real values in one of three ways**, set with `-resolver`:
  * `file`: a JSON file of stand-ins and their real values, reloaded when it changes. A secret manager can write it for you.
  * `http`: a detokenize service you run, called over mutual TLS. Use it when your values already live in a vault or tokenization service with an API, or to compute [2FA codes](https://docs.notte.cc/features/sessions/secret-proxies/credentials.md#2fa-codes) at request time.
  * `sealed`: the values travel in the proxy password as a token that your issuer signs and encrypts to the proxy, with no lookup service and no storage.
* **Relays the browser's connection without changing how it looks to the site.**

It handles HTTPS sites only: plain HTTP requests through it are refused.

### Quick start

This runs the proxy with the `file` resolver and starts a Notte session that uses it.

**1. Build the proxy**

```bash Terminal theme={null}
# Needs Go 1.26 or later
git clone https://github.com/nottelabs/browser-secret-proxy
cd browser-secret-proxy
make build     # bin/browser-secret-proxy
make docker    # or a container image: browser-secret-proxy:dev
```

Releases publish binaries and a signed image, `ghcr.io/nottelabs/browser-secret-proxy:<version>`, on the repository's GitHub Releases page.

**2. Create a CA**

The proxy issues a certificate for each listed host from this CA, and the session's browser trusts it for those hosts only. Anyone holding `ca.key` can impersonate any host to a client that trusts `ca.pem`.

```bash Terminal theme={null}
# The CA the session's browser trusts for the listed hosts. Keep ca.key private.
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 \
  -subj "/CN=browser-secret-proxy CA" -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign" -keyout ca.key -out ca.pem
chmod 600 ca.key
```

**3. Map stand-ins to real values**

Write `swaps.json` and restrict it with `chmod 600 swaps.json`. Pick stand-ins that cannot appear by accident in other traffic, such as a prefix of your own.

```json swaps.json theme={null}
{
  "tok_standin_123": "<real API token>"
}
```

**4. Run the proxy**

```bash Terminal theme={null}
# Basic auth that Notte sends with every CONNECT
export SECRET_PROXY_USERNAME=proxy-user
export SECRET_PROXY_PASSWORD='a-long-random-password'

./bin/browser-secret-proxy \
  -listen 127.0.0.1:8080 \
  -ca-cert ca.pem -ca-key ca.key \
  -hosts 'api.example.com' \
  -swaps swaps.json
```

The proxy listens in plain HTTP, so its credentials would travel in clear text. Put a TLS terminator in front of it, such as a load balancer with a TLS listener, stunnel or HAProxy in TCP mode, on a public address Notte can reach. See the proxy's [deployment guide](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/deployment.md) for Docker, systemd, Kubernetes and TLS setups.

**5. Start a session**

Add a `secret` entry with your terminator's address, the proxy credentials, `ca.pem` and the same hosts as `-hosts`. A host in the entry but not in `-hosts` is tunneled without swaps.

```python bsp_session.py theme={null}
from pathlib import Path

from notte_sdk import NotteClient
from notte_sdk.types import NotteProxy, ProxySettings, SecretProxy

client = NotteClient()

proxies: list[ProxySettings] = [
    # The session's exit for every host you do not list
    NotteProxy.from_country("us"),
    SecretProxy(
        # The TLS terminator in front of browser-secret-proxy
        server="https://browser-secret-proxy.example.com:443",
        # SECRET_PROXY_USERNAME and SECRET_PROXY_PASSWORD
        username="proxy-user",
        password="a-long-random-password",
        # The CA from step 2, never its key
        ca_certificate=Path("ca.pem").read_text(),
        # The same hosts as -hosts
        hosts=["api.example.com"],
    ),
]

with client.Session(proxies=proxies) as session:
    # The browser sends the stand-in; api.example.com receives the real value
    session.execute(type="goto", url="https://api.example.com/echo?token=tok_standin_123")
```

```typescript bsp_session.ts theme={null}
import { readFileSync } from 'node:fs';
import { NotteClient, type NotteProxy, type SecretProxy } from 'notte-sdk';

const client = new NotteClient();

// The session's exit for every host you do not list
const exit: NotteProxy = { type: 'notte', country: 'us' };

const secret: SecretProxy = {
  type: 'secret',
  // The TLS terminator in front of browser-secret-proxy
  server: 'https://browser-secret-proxy.example.com:443',
  // SECRET_PROXY_USERNAME and SECRET_PROXY_PASSWORD
  username: 'proxy-user',
  password: 'a-long-random-password',
  // The CA from step 2, never its key
  ca_certificate: readFileSync('ca.pem', 'utf8'),
  // The same hosts as -hosts
  hosts: ['api.example.com'],
};

await client.Session({ proxies: [exit, secret] }).use(async (session) => {
  // The browser sends the stand-in; api.example.com receives the real value
  await session.execute({ type: 'goto', url: 'https://api.example.com/echo?token=tok_standin_123' });
});
```

The proxy logs `swapped stand-ins` with the host and the number of fields, never the values. Because masking is on, an echo endpoint shows the stand-in again, not the real value.

To give the site one IP address for the whole session, set `-upstream-proxy` (or `SECRET_PROXY_UPSTREAM`) to a gateway credential's `proxy_url_tls`. See [Share the exit IP](#share-the-exit-ip).

### Learn more

* [README](https://github.com/nottelabs/browser-secret-proxy#readme): every flag, format and limit, and the [security model](https://github.com/nottelabs/browser-secret-proxy#security-model).
* [Documentation](https://github.com/nottelabs/browser-secret-proxy/tree/main/docs): [concepts](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/concepts.md), [deployment](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/deployment.md), [resolvers](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/resolvers.md) and [security](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/security.md).
* [Cookbook](https://github.com/nottelabs/browser-secret-proxy/tree/main/docs/cookbook), one recipe per secret, with the exact flags and values:
  * [Card number and CVC at checkout](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/cookbook/card.md)
  * [Password in a login form](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/cookbook/password-form.md)
  * [Password in a JSON login call](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/cookbook/password-json.md)
  * [2FA TOTP code](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/cookbook/totp.md)
  * [API token in a Bearer header](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/cookbook/bearer-token.md)
  * [API key in a query string](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/cookbook/api-key-query.md)
  * [Response masking](https://github.com/nottelabs/browser-secret-proxy/blob/main/docs/cookbook/masking.md)

## Build your own

If you want to write your own proxy, or connect one you already run, it must meet the requirements below.

### Requirements

Your proxy must:

1. **Be reachable on a public address**, as `http://host:port` or `https://host:port`. With `https://`, its TLS certificate must be valid for that hostname, issued by a public CA or by the CA you send in `ca_certificate`.
2. **Speak HTTP proxy protocol**: `CONNECT host:443` tunnels for HTTPS sites, and absolute-form requests (`GET http://host/path`) for plain HTTP sites.
3. **Accept Basic auth** in the `Proxy-Authorization` header if you set `username` and `password`, and answer `407` to wrong credentials. Notte then fails the browser's request with `502`.
4. **Decrypt HTTPS for the listed hosts only**, presenting a certificate for the requested host issued by your CA. Send that CA certificate, never its private key, in `ca_certificate`. The browser trusts it for the listed hosts in that session only.
5. **Replace stand-ins with real values only on requests to the listed hosts**, wherever the site expects them: request headers, the query string, form bodies and JSON bodies, including JSON strings written with `\u` escapes. If a value cannot be swapped, return an error instead of forwarding the stand-in.
6. **Replace real values with stand-ins in responses** from the listed hosts, so a site that sends a real value back does not expose it to the browser, the agent, screenshots, recordings or network logs. See [why this matters](https://docs.notte.cc/features/sessions/secret-proxies/overview.md#mask-real-values-in-responses).
7. **Forward requests unchanged apart from the swapped values**, over a new TLS connection to the site that verifies the site's certificate.

To fill 2FA codes, your proxy can also compute a value at request time, for example the current TOTP code for a stored seed. See [2FA codes](https://docs.notte.cc/features/sessions/secret-proxies/credentials.md#2fa-codes).

### Share the exit IP

To make the site see one IP address for the whole session, send your proxy's outgoing connections for the listed hosts through Notte's proxy gateway with a [gateway credential](https://docs.notte.cc/features/sessions/secret-proxies/overview.md#share-the-exit-ip). Use `proxy_url_tls` if your proxy can use TLS to its upstream, or `proxy_url_plain` if it cannot, and start the session with the same `sticky_session`, and the same `country` or `city`, on its `notte` entry.

```bash cURL theme={null}
# Your proxy's upstream for listed hosts: Notte's gateway with a minted credential
UPSTREAM=$(curl -s -X POST https://api.notte.cc/proxies/gateway/credentials \
  -H "Authorization: Bearer $NOTTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"country": "us", "sticky_session": "checkout-4821"}' | jq -r '.proxy_url_tls')

# Check it: this request leaves from the same exit as a session with the same sticky_session
curl -s --proxy "$UPSTREAM" https://api.ipify.org
```

The credential is valid for your plan's maximum session duration, so mint a new one for each session or before it expires.

### Test your proxy

Check that a listed host goes through your proxy and presents a certificate from your CA:

```bash cURL theme={null}
# Through your proxy, a listed host must present a certificate issued by your CA.
# --proxy-cacert verifies the proxy's own certificate with your CA; drop it if the
# proxy uses a certificate from a public CA.
curl -sv -o /dev/null \
  --proxy https://secret-proxy.example.com:443 \
  --proxy-user "YOUR_PROXY_USERNAME:YOUR_PROXY_PASSWORD" \
  --proxy-cacert your-ca.pem \
  --cacert your-ca.pem \
  https://api.stripe.com/
```

You can also save the proxy in the console with the Custom preset and run **Test connection**, which checks the address, the connection, the credentials and the CA. See [Save the configuration in the console](https://docs.notte.cc/features/sessions/secret-proxies/overview.md#save-the-configuration-in-the-console).

### Start a session

```python start_session.py theme={null}
from pathlib import Path

from notte_sdk import NotteClient
from notte_sdk.types import NotteProxy, ProxySettings, SecretProxy

client = NotteClient()

proxies: list[ProxySettings] = [
    # The session's exit for every host you do not list
    NotteProxy.from_country("us"),
    # Your secret proxy, for the listed hosts only
    SecretProxy(
        server="https://secret-proxy.example.com:443",
        username="YOUR_PROXY_USERNAME",
        password="YOUR_PROXY_PASSWORD",
        ca_certificate=Path("your-ca.pem").read_text(),
        hosts=["api.stripe.com", "*.adyen.com"],
    ),
]

with client.Session(proxies=proxies) as session:
    # Server, hosts and CA fingerprints; never the credentials or the certificate
    assert session.response is not None
    summary = session.response.secret_proxy
    if summary is not None:
        print(summary.server, summary.hosts, summary.ca_sha256)
```

```typescript start_session.ts theme={null}
import { readFileSync } from 'node:fs';
import { NotteClient, type NotteProxy, type SecretProxy } from 'notte-sdk';

const client = new NotteClient();

// The session's exit for every host you do not list
const exit: NotteProxy = { type: 'notte', country: 'us' };

// Your secret proxy, for the listed hosts only
const secret: SecretProxy = {
  type: 'secret',
  server: 'https://secret-proxy.example.com:443',
  username: 'YOUR_PROXY_USERNAME',
  password: 'YOUR_PROXY_PASSWORD',
  ca_certificate: readFileSync('your-ca.pem', 'utf8'),
  hosts: ['api.stripe.com', '*.adyen.com'],
};

await client.Session({ proxies: [exit, secret] }).use(async (session) => {
  // Server, hosts and CA fingerprints; never the credentials or the certificate
  const summary = session.getResponse()?.secret_proxy;
  if (summary) {
    console.log(summary.server, summary.hosts, summary.ca_sha256);
  }
});
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.