# How Do I Keep an API Key Out of an Agent's Sandbox?

> Run a small proxy on your own machine that holds the real key and adds it to each request. Expose it inside the sandbox with sbx forward --reverse, and give the agent only a proxy token you can revoke. The key never enters the VM, and every call is logged on your side.

Source: https://sandboxer.com/guides/keep-api-keys-out-of-an-agent-sandbox. Updated 2026-10-27.

**Short answer:** Run a small proxy on your own machine that holds the real key and adds it to each request. Expose it inside the sandbox with sbx forward --reverse, and give the agent only a proxy token you can revoke. The key never enters the VM, and every call is logged on your side.

[Pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely) covers how to put a secret into a sandbox when the agent needs it. This page is for when the agent should be able to *use* a key but never *hold* it. An agent runs as `user` with `sudo`, so it can read anything you put in the VM. The only safe place for the key is outside the VM. If what you want to hold back is the agent's own Sandboxer access, so a human approves each command, see [Require human approval before an agent runs a command](/guides/require-human-approval-before-an-agent-runs-a-command).

## How it fits together

```text
your machine                                     sandbox (the agent)
------------------------------------------       ---------------------------------
keyproxy.py on 127.0.0.1:7777                    PROXY_URL=http://127.0.0.1:7777
  holds UPSTREAM_API_KEY                         PROXY_TOKEN=<revocable token>
  checks the proxy token     <-- sbx forward --reverse --local 7777 --
  allows a few paths
  adds "Authorization: Bearer <real key>"
  logs every call to calls.log
        |
        v
  https://httpbin.org  (stands in for the real API)
```

`sbx forward --reverse` carries the port over the same tunnel as `sbx ssh`. Nothing becomes public, and the proxy listens only on your loopback. So the proxy token works only from inside that sandbox, through that tunnel. If the agent copies the token somewhere else, it is useless there.

We used [httpbin.org](https://httpbin.org) as the API. Its `/bearer` endpoint returns `401` without an `Authorization: Bearer` header, and `/headers` echoes back the headers it got. That shows the key really was added, without a real third-party key.

## The proxy (about 50 lines, Python standard library)

Save this as `keyproxy.py` on your machine or on a server you control. Never put it in the sandbox.

```python
#!/usr/bin/env python3
"""keyproxy: holds the real API key, injects it, logs every call.
Run it on your machine or server, never in the sandbox."""
import json, os, sys, time, urllib.error, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

UPSTREAM = "https://httpbin.org"             # the API you're protecting
ALLOW = ("/bearer", "/headers", "/anything/") # paths the agent may call
REAL_KEY = os.environ["UPSTREAM_API_KEY"]     # read once; never sent to the sandbox
TOKENS = "tokens.json"                        # {"<proxy token>": "<label>"}; edit to revoke
LOG = open("calls.log", "a", buffering=1)

class Proxy(BaseHTTPRequestHandler):
    def handle_one(self):
        t0 = time.time()
        sent = self.headers.get("Authorization", "").removeprefix("Bearer ")
        tokens = json.load(open(TOKENS))      # re-read on every call: revoking is instant
        who = tokens.get(sent)
        if not who:
            return self.reply(401, b'{"error":"unknown or revoked proxy token"}\n', "-", t0)
        if not self.path.startswith(ALLOW):
            return self.reply(403, b'{"error":"path not allowed"}\n', who, t0)
        n = int(self.headers.get("Content-Length") or 0)
        req = urllib.request.Request(UPSTREAM + self.path, data=self.rfile.read(n) if n else None,
                                     method=self.command)
        req.add_header("Authorization", "Bearer " + REAL_KEY)  # the only place the key is used
        if self.headers.get("Content-Type"):
            req.add_header("Content-Type", self.headers["Content-Type"])
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                status, body = r.status, r.read()
        except urllib.error.HTTPError as e:
            status, body = e.code, e.read()
        body = body.replace(REAL_KEY.encode(), b"[key redacted by proxy]")  # in case the API echoes it
        self.reply(status, body, who, t0)

    def reply(self, status, body, who, t0):
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
        LOG.write(json.dumps({"t": time.strftime("%H:%M:%S", time.gmtime()), "token": who,
                              "method": self.command, "path": self.path, "status": status,
                              "ms": int((time.time() - t0) * 1000)}) + "\n")

    do_GET = do_POST = handle_one
    def log_message(self, *a): pass

ThreadingHTTPServer(("127.0.0.1", 7777), Proxy).serve_forever()
```

Four choices in it matter:

- **The agent's token is not the key.** The proxy drops the agent's `Authorization` header and sends the real one. The token is a random string that means nothing outside your proxy.
- **An allowlist of paths.** A stolen-but-live token still can't reach endpoints you didn't list.
- **Responses are scrubbed.** Some APIs echo credentials back, and httpbin's `/headers` does. Without the `replace`, the key would reach the agent in a response body. It would also land in `sbx history`, which stores command output for 90 days.
- **Tokens are re-read on every call.** Removing one from `tokens.json` takes effect on the next request.

## Start it and connect the sandbox

On your machine, start the proxy with the key in its environment and mint a token for this run:

```bash
umask 077
export UPSTREAM_API_KEY='sk-demo-…'          # your real key; ours was a random fake
PROXY_TOKEN=$(openssl rand -hex 16)
printf '{"%s": "agent-run-1"}\n' "$PROXY_TOKEN" > tokens.json
python3 keyproxy.py &
```

Create the sandbox with `--no-env`, so it gets none of your account's secrets, and pass only the proxy address and token:

```bash
sbx new --name keyproxy-demo --type small --no-env \
  -e PROXY_URL=http://127.0.0.1:7777 -e PROXY_TOKEN="$PROXY_TOKEN"
sbx forward sb_jc43u5mqr6 --reverse --local 7777      # leave this running
```

```text
{"event":"forwarding","id":"sb_jc43u5mqr6","reverse":true,"local":7777,"remote":7777,"bind":"127.0.0.1"}
```

## What the agent sees

Calling the API directly, the agent has no key:

```bash
sbx exec sb_jc43u5mqr6 'curl -s -o /dev/null -w "%{http_code}\n" https://httpbin.org/bearer'
```

```text
401
```

Through the proxy, the same endpoint authenticates:

```bash
sbx exec sb_jc43u5mqr6 'curl -s -H "Authorization: Bearer $PROXY_TOKEN" $PROXY_URL/bearer'
sbx exec sb_jc43u5mqr6 'curl -s -H "Authorization: Bearer $PROXY_TOKEN" $PROXY_URL/headers'
```

```text
{
  "authenticated": true,
  "token": "[key redacted by proxy]"
}
{
  "headers": {
    "Accept-Encoding": "identity",
    "Authorization": "Bearer [key redacted by proxy]",
    "Host": "httpbin.org",
    "User-Agent": "Python-urllib/3.14",
    …
  }
}
```

The upstream got `Bearer <real key>`, and the proxy replaced it before the response went back. A path outside the allowlist, and a call without the token:

```bash
sbx exec sb_jc43u5mqr6 'curl -s -w "%{http_code}\n" -H "Authorization: Bearer $PROXY_TOKEN" $PROXY_URL/get'
sbx exec sb_jc43u5mqr6 'curl -s -w "%{http_code}\n" $PROXY_URL/bearer'
```

```text
{"error":"path not allowed"}
403
{"error":"unknown or revoked proxy token"}
401
```

An agent's tool needs only the two variables. We copied this in with `sbx scp` and ran it:

```python
import json, os, urllib.request

def call_api(path):
    """The agent's tool: it knows only the proxy URL and its proxy token."""
    req = urllib.request.Request(os.environ["PROXY_URL"] + path,
        headers={"Authorization": "Bearer " + os.environ["PROXY_TOKEN"]})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)

print(call_api("/anything/search?q=sandbox")["args"])
```

```text
{'q': 'sandbox'}
```

## Prove the key isn't in the sandbox

Our fake key started with `sk-demo-`. We searched the whole filesystem and every process's environment for that prefix. The `[o]` stops the search from matching its own command line, which the sandbox records in its history directory.

```bash
sbx exec sb_jc43u5mqr6 --timeout 600 '
  sudo grep -rIl -E "sk-dem[o]-" / --exclude-dir=proc --exclude-dir=sys --exclude-dir=dev 2>/dev/null | wc -l
  sudo sh -c "cat /proc/[0-9]*/environ" 2>/dev/null | tr "\0" "\n" | grep -cE "sk-dem[o]-"'
sbx history sb_jc43u5mqr6 --limit 100 --grep sk-demo
```

```text
0
0
{"ok":true,"type":"command.list","commands":[],"nextCursor":null}
```

No file, no process environment, nothing in command history. As a control, the same search for `PROXY_TOKE[N]=` found `/etc/sandboxer/env` and `/etc/profile.d/sandboxer.sh`. The proxy token *is* in the sandbox, as expected. That's why it has to be worthless anywhere else and easy to revoke.

## Revoke it mid-run

We started a detached agent loop making one call a second, then emptied `tokens.json` on our side about six seconds in:

```bash
sbx exec sb_jc43u5mqr6 --detach 'for i in $(seq 1 12); do
  printf "%s %s\n" $(date -u +%T) $(curl -s -o /dev/null -w %{http_code} \
    -H "Authorization: Bearer $PROXY_TOKEN" $PROXY_URL/anything/step-$i); sleep 1; done > /tmp/agent.log'
sleep 6; echo '{}' > tokens.json                         # on your machine
sbx exec sb_jc43u5mqr6 'cat /tmp/agent.log'
```

```text
15:51:57 200
15:51:59 200
15:52:00 200
15:52:01 200
15:52:03 200
15:52:04 401
15:52:05 401
…
15:52:12 401
```

The next call after the edit failed. No restart, no change inside the sandbox. To restore access, give the run a new token.

## Every call, logged on your side

`calls.log` is on your machine, so the agent can't edit or delete it. These are the proxy's lines for the same run:

```text
{"t": "15:52:03", "token": "agent-run-1", "method": "GET", "path": "/anything/step-4", "status": 200, "ms": 95}
{"t": "15:52:04", "token": "agent-run-1", "method": "GET", "path": "/anything/step-5", "status": 200, "ms": 94}
{"t": "15:52:05", "token": "-", "method": "GET", "path": "/anything/step-6", "status": 401, "ms": 0}
{"t": "15:52:07", "token": "-", "method": "GET", "path": "/anything/step-7", "status": 401, "ms": 0}
```

The proxy and sandbox clocks were about a second apart. Label tokens per sandbox or per run, and the log tells you which agent made which call.

## Failure modes we hit

| What happened | What the agent saw | Takeaway |
| - | - | - |
| The proxy was stopped while the tunnel was up | `curl: (56) Recv failure: Connection reset by peer` | Stopping the proxy cuts off every sandbox at once |
| The tunnel's ssh process was gone | `curl: (7) Failed to connect to 127.0.0.1 port 7777` | Calls fail closed; nothing falls back to a direct call |
| The tunnel dropped and reconnected (during a large `sbx ssh` transfer on the same sandbox) | Five calls with curl status `000`, then `200` again | Give the agent's tool a retry with backoff |

`sbx forward` reconnects on its own if the sandbox moves after a stop and resume ([SSH docs](/docs/ssh#forward-ports)). Start it again if you restart it yourself.

## Where to go from here

- **More than one sandbox:** run one `sbx forward --reverse` per sandbox, and give each its own token label.
- **No laptop in the loop:** run the proxy and the forwards on a server you control. If you put the proxy on a public URL instead, the token works from anywhere. Then add expiry and per-sandbox tokens.
- **Limit what else the agent can reach:** outbound internet is open by default. See [what can reach a sandbox](/guides/what-can-reach-a-sandbox-and-what-it-can-reach) before relying on egress rules. The agent has `sudo` and can change rules inside the VM.

## FAQ

### Can the agent steal the proxy token and use it elsewhere?

It can read it, but the proxy listens only on your machine's loopback, reached through this sandbox's tunnel. Outside the sandbox the token reaches nothing. Inside, it works until you remove it from `tokens.json`.

### What stops the agent from abusing the API through the proxy?

The path allowlist and revocation. Add rate limits or a spend counter in the proxy if the upstream bills per call. The log shows exactly what each token did.

### Does the proxy have to be Python?

No. Any HTTP server that checks a token, rewrites one header and logs works: a few lines of Node, Go or an nginx config. The pattern is what matters, not the language.

### Why not just pass a scoped key with `-e`?

Then the key is in `/etc/sandboxer/env` and every process's environment. That's fine for a cheap, short-lived key; see [pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely). A proxy is for keys you can't scope or can't afford to leak.

## Related

- [Pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely)
- [What can reach a sandbox, and what can it reach?](/guides/what-can-reach-a-sandbox-and-what-it-can-reach)
- [SSH, file transfer and port forwarding](/docs/ssh)
- [Run Claude Code without permission prompts, safely](/guides/claude-code-skip-permissions-safely)
