Use promo code BETATEST1 for full access
seekrit
← all posts

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:

PlatformWhere the values go
E2BSandbox.create({ envs }), or per command
Modalmodal.Secret.from_dict(...) — built where your code runs, not stored in Modal
DaytonaenvVars at create, updateEnv after
Vercel Sandboxenv at create, or per runCommand
Cloudflare SandboxsetEnvVars() 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 machineInject at start
Run an agent you superviseInject at start, minimal environment
Run code you don't trustPlaceholders 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.