Passing secrets into a sandbox without baking them into the image
How-to
The rule: a credential goes into a sandbox when the sandbox starts, never when its image is built. An image is an artifact — it gets pushed, cached, shared, and inspected, and everything baked into it goes along. Injection at start time looks like this everywhere, whether "sandbox" means a Docker container or an E2B microVM:
secrets = seekrit.Client(token=os.environ["SEEKRIT_TOKEN"]).resolve()
envs = {k: secrets[k] for k in ("OPENAI_API_KEY", "TAVILY_API_KEY")}
sandbox = create_sandbox(envs=envs) # provider-specific spelling below
Resolve on the host, pick the names the job needs, start the sandbox with them. The rest of this post is the ways baking goes wrong, the per-platform spelling of doing it right, and the stronger option when the code inside is the thing you don't trust.
The three ways keys get baked in
ENV in a Dockerfile. ENV OPENAI_API_KEY=sk-… stores the value in the
image config — visible to docker inspect, to anyone who pulls the image,
and to every container started from it, forever.
COPY .env . Same result via a file: the value is in a layer, and
deleting the file in a later layer doesn't remove it from the earlier one.
ARG as a "temporary" secret. Build args aren't in the final config,
but they land in the image history (docker history) and in any layer where
a command echoed them. ARG is for versions and flags, not credentials.
If the build itself needs a secret — a private registry token for
npm install, say — use BuildKit's mount, which exposes the value to one
command and keeps it out of every layer:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
seekrit run -- docker build --secret id=npm_token,env=NPM_TOKEN .
seekrit run puts NPM_TOKEN in docker's environment; BuildKit hands it
to that one RUN and nothing else.
Runtime injection, per platform
For containers you run yourself, put the ~2 MB static
seekrit-run binary in the image and make it the
entrypoint. The image holds a launcher and no values; the container gets its
secrets at start, from a token you pass at start:
FROM seekritdev/run:latest AS seekrit
FROM gcr.io/distroless/static
COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run
ENTRYPOINT ["seekrit-run", "--"]
docker run -e SEEKRIT_TOKEN my-image node server.js
For sandbox SDKs, resolve on the host and pass the values through the create call — the sandbox guides have full examples per platform:
| Platform | Where the values go |
|---|---|
| E2B | Sandbox.create({ envs }), or per command |
| Modal | modal.Secret.from_dict(...) — built where your code runs, not stored in Modal |
| Daytona | envVars at create, updateEnv after |
| Vercel Sandbox | env at create, or per runCommand |
| Cloudflare Sandbox | setEnvVars() or exec({ env }) |
Two rules that apply on every row:
Pass names, not the whole environment. resolve() returns everything
the token can see, and injecting all of it hands a sandbox that needed one
model key the database password too. Better than filtering in code: give
sandbox jobs their own seekrit environment that only
composes the group they need, so the narrowing
survives someone editing the injection code.
Never pass SEEKRIT_TOKEN into a sandbox running untrusted code. A
service token resolves its whole environment, so handing it over turns "the
sandbox has two keys" into "the sandbox has every key, and can fetch them
again after you rotate." If the sandbox must resolve for itself, mint it a
token bound to its own minimal environment — or a
temporary-access lease that expires.
When the code inside is the threat
Injection at start assumes you trust what runs in the sandbox — your build,
your tests, an agent you supervise. When the sandbox exists precisely
because you don't trust the workload — generated code, an autonomous
agent — a real value in os.environ is a value that code can read and
send somewhere. Cloudflare's own Sandbox docs say it plainly: don't put
live API keys in the sandbox.
The alternative: the sandbox gets placeholders, and the real value is
attached outside it. Run seekrit-proxy on the
host as the sandbox's only route out, and the workload sends
{{seekrit:OPENAI_API_KEY}} while the key is substituted past the
boundary. On Cloudflare this is native — outbound handlers (Sandbox) and an
egress gateway (Computer) run in the Worker, outside the sandbox. Compromise
the sandbox completely and there is still nothing in it to steal.
Choosing
| The sandbox exists to… | Do this |
|---|---|
| Isolate a build/test/notebook from your machine | Inject at start |
| Run an agent you supervise | Inject at start, minimal environment |
| Run code you don't trust | Placeholders inside, proxy outside |
Values are encrypted client-side before seekrit stores them, so the host's
resolve() is also the only place decryption happens —
the service holding the ciphertext can't read it.
Start with the guides: sandboxes,
seekrit-run, and the proxy.