The Sandbox Signs Its Requests: Workload Identity Replaces the Key in the Box
If your agent sandbox holds an API key, one injected sentence can read it. In the first week of November, six vendors converged on the same answer. The sandbox holds nothing, and the egress proxy adds the credential after the request leaves. Two of them go further. The credential is a token that names the sandbox, minted outside it, and the receiver verifies it with public keys. This essay follows one such request through all four stops and then shows where the design fails.
The dates sit three days apart. On November 3, E2B made Workload Identity generally available, and the CNCF published OpenKruise Agents v0.6.0. On November 4, Render launched Sandboxes with credential brokering in early access. On November 5, Baseten announced Sandboxes and an Agent Proxy to follow. The same day, Google announced Agent Substrate 1.0 and its CNCF donation. Anthropic published an IP allowlist page that tells customers to "move to Workload Identity Federation to stop using static keys."
This site has covered the egress path four times. The eval sandbox that held the keys showed why a key inside the box is a production key. Agent Substrate showed actors that live in snapshots and borrow a worker pod. Agent Substrate egress showed a gateway that trusts nothing the agent says about a destination. Agent Compute Engine showed the credential added at egress. This essay does not repeat them. It reads the token itself: what is in it, who signs it, who checks it, and what it buys at a cloud token service. Every claim below comes from the vendor docs and the standards they cite, read on November 5 and 6. The demo at the end runs on a laptop with no cloud account.
One request as E2B and Agent Substrate document it. The sandbox holds an agent and a browser and no secret. The issuer holds the signing key. The proxy stamps a token only on requests to a host that has a rule. The receiver desk fetches the key set once, checks the claims in order, and trades a good token for a 900 second STS credential. The flow, the claim checks, and the 900 seconds come from the docs. The token lifetime knob covers the 300 to 3600 second range that Substrate documents, because E2B does not publish its lifetime. The timing and evil.example are illustrative.
Stop one: the token is minted outside the box
In E2B's design, you name a token and its audience when you create the sandbox. The audience is the service that will verify the token, such as sts.amazonaws.com. The only token type is JWT-SVID, which is the JWT form of a SPIFFE identity. A sandbox can hold at most five of them. You then reference the token in a network rule for one host. The configuration from the docs, trimmed:
import { Sandbox, Secret } from 'e2b'
const sandbox = await Sandbox.create({
iam: {
tokens: {
backend: Secret.iamToken({ audience: 'sts.amazonaws.com', tokenType: 'JWT-SVID' }),
},
},
network: {
allowOut: ({ rules }) => [...rules.keys()],
denyOut: ({ allTraffic }) => [allTraffic],
rules: {
'api.example.com': [{
transform: ({ iam }) => ({
headers: { Authorization: `Bearer ${iam.tokens.backend}` },
}),
}],
},
},
})
From the E2B workload identity docs. The allow and deny lines are the docs' own, and they matter: a rule alone grants nothing.
Read the header line again. iam.tokens.backend is not a token. The docs call it a placeholder string, ${e2b.identity.tokens.backend}, stored in the network configuration of the sandbox. When an HTTPS request that matches the rule leaves the sandbox, the egress proxy replaces the placeholder with a signed token. The docs say that "token issuance and injection happen outside the sandbox." The proxy keeps a minted token for one execution and one audience, and it sends that token again until it expires. Code inside the sandbox does not have to send the header at all. It runs curl https://api.example.com/data, and the header appears after the request has left.
Agent Substrate documents the same stop with one difference. An egress policy rule can carry an actorJwt entry with audiences, one to sixteen values, and expirationSeconds, from 300 to 3600 with no default. The actor must send the named header with any placeholder value, and the gateway replaces it. A request without the header is forwarded unchanged. ateapi mints the token when the gateway asks. The gateway sends the same token again until only a third of its lifetime remains, so every upstream sees at least that third.
What is in the token? E2B's discovery document, fetched on November 6, lists the claims it supports: iss, sub, aud, iat, nbf, exp, project_id, sandbox_id, execution_id, and template_id. The subject is a SPIFFE ID with the project, the sandbox, and the execution as path segments. An inert example, with made-up identifiers and no signature:
header
{ "alg": "ES256", "kid": "k-2026-11", "typ": "JWT" }
claims
{
"iss": "https://id.e2b.dev",
"sub": "spiffe://id.e2b.dev/proj_7f3/sbx_19c/exec_01",
"aud": "sts.amazonaws.com",
"iat": 1793966741,
"nbf": 1793966741,
"exp": 1793967641,
"project_id": "proj_7f3",
"sandbox_id": "sbx_19c",
"execution_id": "exec_01"
}
Claim names from E2B's discovery document and docs. The values are illustrative. A real token also has a third part, the signature, and E2B signs with ES256.
The JWT-SVID standard is short, and its rules explain the shape. The sub claim must be the SPIFFE ID of the workload. The aud claim must be present, and the standard recommends one value. The exp claim must be set, and the standard asks for a validity period as small as reasonably possible. Only the RSA, ECDSA, and RSA-PSS algorithms of RFC 7518 are allowed, so HMAC and none are out. A validator must reject any other alg. The kid header is optional, and in practice every issuer here sets it, because it is how the receiver picks a key. SPIRE, the reference SPIFFE implementation, mints JWT-SVIDs with a default lifetime of five minutes. E2B does not publish the lifetime of its tokens. Substrate's range is the 300 to 3600 seconds above.
Stop two: the proxy adds it per destination host
The proxy decides by host, and that one fact drives most of the failure modes later. E2B's secret injection doc states it plainly. Injection is keyed on the destination host. So "every process in the sandbox that sends an HTTPS request to that host gets the secret, including a browser and any page it loads." The doc's advice is to run a browser or untrusted code in a separate sandbox. Two more rules come with it. Plain HTTP requests get no injected headers. And an HTTP client with its own CA bundle must trust the egress proxy. The proxy terminates TLS to read the request.
When this site read Substrate's egress gateway in September, header injection was not finished. At 1.0 the guide documents it on https rules, where the gateway terminates the actor's TLS, as that essay described. A tls_passthrough rule forwards the bytes unread, so no injection happens on it. OpenKruise Agents v0.6.0 puts an in-pod L4 proxy before a central Envoy gateway with an external processor. Its SecurityProfile can replace a header with a Kubernetes Secret through a tokenTransformation rule, with failStrategy: Block when the credential is missing. Credentials "never appear in Pod specs, environment variables, or Sandbox metadata," the CNCF post says. Rotating a Secret "takes effect without restarting the Sandbox." It also says that containers running as root or with NET_ADMIN or SYS_ADMIN can bypass the in-pod control. The post calls the policy defense in depth and tells you to add NetworkPolicy or host firewalls.
Stop three: the receiver verifies it with public keys
The receiver never talks to the sandbox vendor. It fetches two public documents from the issuer and checks the token itself. The first is the OpenID Connect discovery document at a fixed path under the issuer URL. This is E2B's, as served on November 6:
GET https://id.e2b.dev/.well-known/openid-configuration
{
"issuer": "https://id.e2b.dev",
"jwks_uri": "https://id.e2b.dev/.well-known/jwks.json",
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["ES256"],
"claims_supported": ["iss", "sub", "aud", "iat", "nbf", "exp",
"project_id", "sandbox_id", "execution_id", "template_id"]
}
GET https://id.e2b.dev/.well-known/jwks.json
{ "keys": [ { "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig",
"kid": "C6DHZGiw1P0mjNv37bsT3dJKNXXDfSAcjUeRzQ6sNpQ",
"x": "...", "y": "..." } ] }
Both documents fetched from id.e2b.dev on November 6, 2026. The key coordinates are shortened here. The key set held one key that day.
The issuer field must equal the iss claim exactly. The jwks_uri points at the key set, an RFC 7517 JSON Web Key Set. The verifier takes the kid from the token header and finds the key with that kid. It checks that the algorithm is one it accepts, then verifies the signature. Then it checks the claims: iss, aud against the value it expects, exp and nbf against its clock, and the sub prefix. E2B's docs say to verify that the subject starts with spiffe://id.e2b.dev/<project>/, "so tokens minted for other projects are rejected." Substrate's actor JWT guide gives the same order and adds a case. Substrate signs worker-for-actor tokens with the same keys, so a receiver that expects an actor must refuse them. An unknown kid means that the issuer added a key, because a key goes into the set before it signs. Fetch the set again when that happens, and put a rate limit on the fetch.
In Python with PyJWT, the whole check is a few lines. This is the core of the verifier in the demo below:
disc = get(ISSUER + "/.well-known/openid-configuration")
assert disc["issuer"] == ISSUER
header = jwt.get_unverified_header(token)
keys = get(disc["jwks_uri"])["keys"]
key = jwt.PyJWK.from_dict([k for k in keys if k["kid"] == header["kid"]][0]).key
claims = jwt.decode(token, key, algorithms=["ES256"], audience="sts.amazonaws.com",
issuer=ISSUER, options={"require": ["exp", "aud", "sub", "iss"]})
assert claims["sub"].startswith("spiffe://id.e2b.dev/proj_7f3/")
From verify.py in the companion code, with the real project prefix in place of the demo one. The key set is fetched once per unknown kid in a real service, and cached.
Anthropic now runs this receiver as a product. Workload Identity Federation registers an issuer by its URL and a JWKS source. The source can be discovery, an explicit URL, or an inline key set for issuers that the internet cannot reach. A federation rule matches a subject_prefix, an exact audience, or claim values, and maps the match to a service account. The workload posts its JWT to /v1/oauth/token with the RFC 7523 jwt-bearer grant and gets back a short-lived access token. The rule sets the lifetime, from 60 to 86,400 seconds, with 3,600 as the API default. The doc lists SPIFFE among the issuers it accepts, so a sandbox token can reach the Claude API with no static key anywhere. Issuer and JWKS URLs must be HTTPS on port 443 with a public DNS name.
Stop four: the token buys a short credential at STS
A receiver that needs AWS resources does not have to store an AWS key either. E2B's post shows the exchange. You register https://id.e2b.dev as an OpenID Connect provider in your AWS account once, with sts.amazonaws.com as the client ID. Then a role trust policy allows sts:AssumeRoleWithWebIdentity on two conditions. The id.e2b.dev:aud key must equal sts.amazonaws.com. The id.e2b.dev:sub key must match spiffe://id.e2b.dev/<PROJECT_ID>/*. The second condition limits the role to one project. Without it, the provider registration alone would accept any E2B project.
The backend then calls STS with the token as the only credential. The API reference says that the call "does not require the use of AWS security credentials." It accepts RS256, RS384, RS512, ES256, ES384, and ES512 signatures and tokens up to 20,000 characters. The request, in the Query API shape the reference documents:
GET https://sts.amazonaws.com/?Action=AssumeRoleWithWebIdentity
&Version=2011-06-15
&RoleArn=arn:aws:iam::123456789012:role/sandbox-document-reader
&RoleSessionName=sbx_19c
&DurationSeconds=900
&WebIdentityToken=eyJhbGciOiJFUzI1NiIsImtp...
Shape from the STS API reference. The account, role, and session name are placeholders. STS fetches the issuer's key set itself and checks the signature and the trust policy.
The response holds an access key ID, a secret access key, a session token, and an expiration. DurationSeconds runs from 900 seconds to the maximum session duration of the role, which an administrator sets between one and twelve hours. The default is 3,600. E2B's example asks for 900 and reads one S3 object. Two details from the AWS docs matter for the next section. IAM accepts a token for five minutes past its exp to allow for clock skew. And the CloudTrail entry for the call includes the subject of the token, so each credential traces back to one sandbox execution.
Substrate cannot make this exchange from the gateway yet. Its guide lists the reason as a known limitation. A cloud exchange call carries the JWT in a request parameter, and the gateway can only change headers. So the exchange belongs to the backend, as in E2B's example, and the sandbox never sees the AWS credential either.
Run the four stops on a laptop
The companion code plays all four stops with no cloud account. The issuer script creates a P-256 key on first run and serves the discovery document and the key set on loopback. The mint script signs a token with that key for an audience you pick. The verify script fetches the two documents, picks the key by kid, and checks the claims in the order above. The STS script checks the signature and exp with the local key, with the five minutes of clock skew that IAM allows. Then it prints the exchange request and evaluates the two trust policy conditions. It sends nothing. The kid is the base64url SHA-256 of the public key, which is the default Substrate documents.
$ pip install "pyjwt[crypto]"
$ python3 issuer.py
issuer http://127.0.0.1:8787 kid JkxCrq5apIfV4IWLt3etKa2HpSh_BLqYW55vjRoe2ZA pid 68311
$ python3 mint.py sts.amazonaws.com > token.txt
$ python3 verify.py token.txt sts.amazonaws.com
discovery issuer http://127.0.0.1:8787
discovery jwks_uri http://127.0.0.1:8787/jwks.json
jwks kid JkxCrq5apIfV4IWL... matched 1 of 1 keys
signature ok with the matched key
iss ok http://127.0.0.1:8787
aud ok sts.amazonaws.com
exp ok, expires in 899 s
sub ok spiffe://local.test/proj_demo/sbx_7f3/exec_01
accepted execution exec_01 of sandbox sbx_7f3
$ python3 verify.py token.txt reports
rejected aud sts.amazonaws.com is not reports
$ python3 sts.py token.txt
signature and exp checked against the local issuer key, with the 300 s skew IAM allows
request that would be sent, with no AWS credentials attached:
GET https://sts.amazonaws.com/?Action=AssumeRoleWithWebIdentity
&Version=2011-06-15
&RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Fsandbox-document-reader
&RoleSessionName=sbx_7f3
&DurationSeconds=900
&WebIdentityToken=eyJhbGciOiJFUzI1NiIsImtp...Y-uHTBwg
the role trust policy then checks two claims:
StringEquals aud = sts.amazonaws.com -> pass (aud is sts.amazonaws.com)
StringLike sub = spiffe://local.test/proj_demo/* -> pass (sub is spiffe://local.test/proj_demo/sbx_7f3/exec_01)
response fields: AccessKeyId, SecretAccessKey, SessionToken, Expiration (900 s from now)
not sent: this script prints the request and stops
Run on November 7 with Python 3.11 and PyJWT 2.10.1. The key, the kid, and the token differ on every machine. The second verify exits with code 1.
Two things differ from production. The demo issuer speaks plain HTTP on loopback. Every real issuer here is HTTPS, and Anthropic requires HTTPS on port 443 with a public DNS name. And the demo mints with a 900 second lifetime that you can change as the second argument, where a vendor picks the lifetime for you. The rejection line is the one to keep. A token minted for reports and presented to a service that expects sts.amazonaws.com fails before any claim about the sandbox is read.
Where it fails
Audience first, because it is the cheapest check to get wrong. A JWT-SVID with more than one audience can be replayed from one of them to another. The standard's own example is a token for Bob and Chuck, which Chuck can send to Bob as the original workload. So mint one audience per service, and make it the exact value the service checks. E2B keys each token to one audience, and a request to a host gets the token you bound to that rule. Substrate allows up to sixteen audiences per entry and tells you to "use the value the upstream checks the audience against." The STS trust policy makes the same check with StringEquals. Wide values such as production are discouraged by the standard, because one compromised service in that scope can impersonate every workload to every other.
Lifetime and rotation come next, and they have two clocks. The first is the token's. SPIRE defaults to five minutes. Substrate allows 300 to 3600 seconds and reissues when a third is left. E2B reuses a cached token while it is valid and does not publish the lifetime. Anthropic's minted access token runs 60 to 86,400 seconds. The second clock belongs to what the token bought. E2B's docs warn that the AWS credentials "remain valid after the E2B workload identity token expires or the sandbox ends." They tell you to set the shortest role session that fits. The figure below puts both clocks on one ruler.
One sandbox execution on a 90 minute ruler. Each token the proxy mints is a blue bar, and the STS credential that each token bought is a green bar. Set the token lifetime, the reissue policy, the STS session length, and the minute an attacker copies both. The figure computes how long each copy keeps working and how much of that is after the sandbox ended.
Reuse while valid is E2B's documented cache rule. Reissue at one third left is Substrate's gateway rule. The exchange is assumed to happen once per new token, when it first reaches the backend. DurationSeconds has a 15 minute floor in the STS reference. Requests are assumed to be continuous, so a reissue happens as soon as its rule allows.
Rotation is the second half of this clock. The receiver finds keys by kid. So an issuer publishes a new key before it signs with it. It keeps the old key in the set until every token that key signed has expired. Substrate's rotation guide gives the waits. Allow a day for relying parties to refetch the key set, then an hour for tokens in flight, then remove the old key. The guide also names what the design cannot do. You cannot revoke one token. The token of a deleted actor still works until exp. To stop it, you remove its signing key from the set, and that ends every token the key signed, for all actors. Compare the static secret path. OpenKruise rotates a Kubernetes Secret and the next request carries the new value, with no restart, because the proxy resolves it per request.
The proxy can be bypassed, and the right question is what a bypass reaches. With deny-all egress and a host allowlist, a process that avoids the proxy has nowhere to go. That is why E2B's example and Substrate's default both start from deny. Inside that rule, the paths that still change the outcome are narrower. Plain HTTP reaches the allowed host with no stamp. A client that distrusts the proxy certificate fails its TLS handshake, and the error looks like a certificate bug. A browser page in the same sandbox gets the stamp. A root process can remove an in-pod rule. That is why OpenKruise pairs its proxy with NetworkPolicy and host firewalls. It is also why E2B, Substrate, and Agent Compute Engine enforce outside the box. Failure handling differs too. E2B's secret injection fails open for traffic: an unresolved reference omits the header and forwards the request. Substrate's table fails closed with 403, 500, or 503, and OpenKruise's failStrategy: Block does the same.
Six request paths out of one sandbox, all aimed at a host with a rule or at a host without one. Set the controls and see what each path delivers at its destination: a stamped request, a bare one, a refusal, or a TLS failure inside the sandbox. The verdict counts the stamps you did not intend and the bare requests that still reach a host.
Rules from the E2B secret injection doc (host keyed, plain HTTP not injected, trust the proxy CA, the browser caveat), the Substrate credential injection guide (a request without the header is forwarded unchanged, http rules can inject), and the OpenKruise v0.6.0 post (root or NET_ADMIN can bypass in-pod ACLs). The row outcomes are my reading of those rules, not a vendor test.
Replay is the limit the standard admits. A JWT-SVID is a bearer token. The standard's security section says that requiring aud and exp improves the situation but cannot solve it. It recommends an aggressive exp. It does not require validators to track a jti. Anyone with the token can use it at any receiver that takes its audience, up to exp. Substrate's guide adds the path that matters most in a sandbox: "an upstream that echoes request headers hands the token back to the actor." A debugging endpoint that prints the request is enough to put the token inside the box after all. Two receiver-side habits shrink the window. Check sub as well as aud. Then bind the sandbox ID to the job it was started for, as E2B's post suggests for a document task. And keep the STS session as short as the work allows, because an STS credential is an ordinary AWS credential once it exists.
Six vendors, one primitive
The primitive has two halves, and the six split across them. The first half is the addition. An issuer mints a token or resolves a secret outside the sandbox, and a proxy applies it per host. The second half verifies: a receiver checks the token against public keys and decides what the sandbox can do. Three of the six document a signed per-sandbox token. E2B mints a JWT-SVID with an OIDC discovery document and a key set. Substrate mints an actor JWT and serves the same two documents from ate-idp-server, with its own rotation commands. Anthropic sits on the verifying side and accepts any OIDC issuer, SPIFFE included. The other three document the addition without a token. OpenKruise replaces a header from a Kubernetes Secret at its egress gateway. Render says a broker outside the sandbox "adds the right key or token as each request leaves for an allowed domain," in early access. Baseten says its Agent Proxy injects credentials "without the agent ever seeing it," and that the proxy arrives in the coming months.
| Vendor | What leaves the box | Who verifies, and how | Status on Nov 5 |
|---|---|---|---|
| E2B | A JWT-SVID per audience, added by host, cached per execution | Your service or AWS STS, through id.e2b.dev discovery and JWKS | GA on E2B Cloud, supported on BYOC |
| Agent Substrate 1.0 | An actor JWT or a stored secret, replacing a placeholder header | Any relying party, through ate-idp-server discovery and JWKS | 1.0 released, GA on GKE, CNCF Sandbox |
| OpenKruise Agents 0.6.0 | A Kubernetes Secret value, resolved per request at the egress gateway | The upstream API, as a normal key | Released |
| Render Sandboxes | A key or token added by a broker outside the sandbox | The upstream API | Brokering in early access |
| Baseten Sandboxes | A credential injected by the Agent Proxy | The upstream API | Proxy announced for the coming months |
| Anthropic | Any OIDC JWT, exchanged for a short-lived access token | Anthropic, through the issuer's JWKS and a federation rule | WIF documented, IP allowlist in beta |
Only what each vendor's own post or doc states, as read on November 5 and 6, 2026.
The two halves differ when a request is stolen. If your sandbox vendor adds a static secret, you have removed the key from the box. A stolen request still carries a credential that works from anywhere until you rotate it. If it adds a signed token, the receiver decides, and a stolen token works only at one audience until exp. The two halves also meet. A sandbox on E2B can reach the Claude API through Anthropic's federation with no key anywhere. The issuer has to be registered, and a rule has to match the project prefix.
Build it in this order
- Start from deny-all egress with a host allowlist. Every injection rule below assumes that deny-all exists.
- Move the keys you have into the proxy, one rule per host, and remove them from the image, the environment, and the snapshots. OpenKruise's
failStrategy: Blockand Substrate's fail-closed table are the behavior to require. - Where the receiver is yours, replace the key with a signed token. Register the issuer by discovery and verify by
kid. Checkiss, oneaud,exp,nbf, and thesubprefix, in that order. Cache the key set, and refetch it with a rate limit when akidis unknown. - Where the receiver is a cloud, register the issuer there and write the trust policy with both conditions. Ask for the shortest session the work allows, because that credential outlives the token and the sandbox.
- Run the browser, and any untrusted code, in a sandbox with no injection rules. Injection is keyed on the host, and the page gets what the agent gets.
- Log the subject at the receiver and at STS, and bind it to the job that started the sandbox. When a token is replayed, that line is how you find out which execution it came from.
The earlier essays made the case for a box with nothing in it. This week six vendors agreed that the credential joins the request at the proxy. Two of them gave it a signed shape with keys you can fetch. The receiver side is where your work starts, and the demo above is small enough to be the first commit.
Keep reading