Skip to content
Guides · Agent cookbook

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

Updated October 27, 2026 .md

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

How it fits together#

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

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

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:

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
{"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:

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

Through the proxy, the same endpoint authenticates:

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'
{
  "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:

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'
{"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:

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"])
{'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.

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

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

{"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). 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 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. A proxy is for keys you can't scope or can't afford to leak.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide