I built a sandbox that runs an autonomous coding agent — it writes Python, executes it, hits a data warehouse, and builds a report, all from a prompt. That is untrusted code execution by definition: a prompt-injected or buggy agent can run whatever the sandbox can run.
The problem that ate a week of my life wasn't the sandbox boundary itself. It was this: the agent needs live results from a customer's third-party API — think a Salesforce or a data-warehouse connection — but it must never hold that connection's credential. If the sandbox can read the secret, a compromised sandbox can exfiltrate it, and the blast radius of one bad run becomes one customer's entire data platform.
This is a surprisingly common shape once you look for it: an LLM tool, a CI runner, a plugin, any workload you don't fully trust that nonetheless needs real data from a credentialed API. The clean answer is a credential broker, and getting the identity model right is the part everyone gets wrong the first time.
The trap: the API you already have trusts a header
Most platforms already have an internal RPC that makes the outbound call server-side. In my case there was a service — call it Invoke — that took a connection ID, a method, a path, and params, ran the real API call with a server-held secret, and returned the data. The secret already lived on the right side of the boundary. So the tempting move is obvious: just let the sandbox call Invoke.
Don't. Look at how that service decides who you are. Mine authorized on a single header — a tenant ID header — and trusted it completely. That's fine in normal operation, because a session gateway upstream is the only thing that sets it, and nothing past the gateway can forge it.
But the sandbox isn't past the gateway. It's untrusted and it sits outside the session. Hand it direct access to Invoke and anything it can write into a header, it can claim to be. Set the tenant header to someone else's tenant and you are someone else. Tenant impersonation, free of charge.
That gave me two non-negotiable constraints:
- The sandbox's channel must derive the tenant from something it cannot forge, never a request header.
- The sandbox must never hold the credential.
The pattern: one thing holds the secrets so nothing else does
This is the same principle underneath OAuth. The caller never touches the provider's real secret; instead a broker holds it, injects it into the outbound call, and returns only the response. Secrets never cross the trust boundary, and rotation, revocation, and audit all collapse into one place.
The twist for an untrusted sandbox is: how does the sandbox authenticate to the broker? It can't hold the provider secret — but it needs to prove "I'm allowed to ask you to call this API for tenant X." So you give it a short-lived, unforgeable token that encodes exactly that, and — this is the whole trick — the broker enforces the tenant from the token, not from the request.
The request the sandbox sends carries a connection ID, a method, a path, params — and no tenant, no credential. There's deliberately nowhere to put a tenant, because tenant isn't the caller's to assert.
The token, and the three guards that matter
A trusted, privileged process mints a per-job JWT at startup, bound to the tenant and the job, with a bounded lifetime. It's signed with a symmetric key (HS256) that the sandbox never sees.
const defaultTTL = 2 * time.Hour type Claims struct { JobID string `json:"job_id"` TenantID string `json:"tenant_id"` jwt.RegisteredClaims } func Verify(key []byte, tokenStr string) (*Claims, error) { claims := &Claims{} _, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) { // Alg-confusion guard: reject anything that isn't the HMAC we signed with. // Without this you're open to alg:none and RS→HS downgrade attacks. if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return key, nil }, jwt.WithIssuer(tokenIssuer), // issuer pin jwt.WithExpirationRequired(), // no exp = not a valid token ) if err != nil { return nil, err } if claims.TenantID == "" { return nil, errors.New("token missing tenant") } return claims, nil }
Verify a JWT like it's hostile, because in this design it literally arrives from your least-trusted component:
- —Alg-confusion guard. Reject any signing method that isn't the exact one you minted with. If your verify callback returns the key without checking
t.Method, an attacker can present analg: nonetoken or flip your verifier into treating a public key as an HMAC secret. This is the single most common JWT vulnerability in the wild. - —Issuer pin + required expiry. A token from another issuer isn't yours; a token with no expiry never dies. Reject both structurally.
- —Non-empty tenant. The tenant claim is the entire security decision — a token that verifies but has no tenant is malformed, not "empty is fine."
The short TTL (I used two hours) is intentional blast-radius control. Even if a token somehow escaped the sandbox, it's a coarse capability — "ask the broker to call this tenant's connections" — that expires on its own, can't be refreshed from inside the sandbox, and never exposes the underlying provider secret.
The one line that carries the whole design
// Authenticate to the broker: the Bearer token *is* the auth. claims, err := verifyToken(req.Header().Get("Authorization")) if err != nil { return unauthenticated(err) } // Stamp the tenant FROM THE TOKEN. Any tenant header the caller set is ignored. inner := buildRealRequest(req.Msg) // connection_id, method, path, params inner.SetTenant(claims.TenantID) // <-- not the caller's header // Run the real API call server-side, with the credential the sandbox never sees. return realInvoke(ctx, inner)
inner.SetTenant(claims.TenantID) is the line the whole thing rests on. A header is an assertion the caller controls; a verified claim is a fact. If you ever find yourself reading the tenant off the incoming request in a broker like this, stop — that's the impersonation hole reopening.
Why cross-tenant access becomes structurally safe
Here's the part I like most, because it's safe by absence rather than by vigilance. The real API call looks up the connection scoped to the tenant — effectively store.Get(tenant, connection_id) — and the tenant comes from the verified token. So if the sandbox passes a connection ID belonging to a different customer, the lookup just returns NotFound.
There is no cross-tenant code path to review, no allow-list to keep in sync, no "did we remember to filter here?" The foreign connection is simply invisible, because every query is scoped to a tenant the caller can't forge. Absence of a code path beats a correct code path every time.
What I'd change
My token is tenant-scoped, not per-capability-scoped — the sandbox can call any connection its tenant owns, not just the specific ones a given job needs. The tenant boundary was the load-bearing one, so I shipped that and deferred connector-level scoping. If I did it again I'd put the allow-list right into the token claims from the start: it's nearly free at mint time and turns "least privilege" from an aspiration into a property.
What to steal from this
- —Untrusted code that needs results from a credentialed API should never receive the credential. Front it with a broker that holds the secret and hand the caller only a coarse token that says "you may ask."
- —Identity comes from the token's claims, never from a header the caller can set. The most dangerous line in the system is the one that decides the tenant — make sure it reads the verified token.
- —Design so cross-tenant access is structurally impossible: scope every lookup to the token's tenant, so a foreign ID returns
NotFoundwith no special-case code at all. - —Treat every JWT as hostile: pin the signing method, pin the issuer, require expiry, reject empty required claims.
- —Keep the token short-lived and wipe it from any record the instant the work finishes. A short coarse capability is a contained fire; a long-lived secret is a house burning down.