Working with secrets with Docker Sandboxes
- Beginner
- 15 min
- Published
- Verified
What you'll learn
- Where credentials are stored and accessible
- Configure secrets for built-in and custom services (using kits)
- Learn about the credential bindings file that records what you have approved
- Use registry credentials for pulling private template and kit images
An AI coding agent is not much use without credentials. It needs a model API key to think, a GitHub token to open a pull request, and often a token for some service your own team runs. But handing an autonomous agent your credentials is exactly the thing that makes people hesitate to run one at all.
Docker Sandboxes answer that by never giving the agent the credential at all. Your secrets stay on your host, in your operating system’s own credential store. A host-side proxy sits on the sandbox’s only route out and attaches the right credential to matching requests as they leave. Inside the sandbox, the token is a placeholder that authenticates nothing (if it exists at all).
This guide will teach you about the different forms of credentials and help you learn how to use each one.
Services
A credential is only useful if it’s connected to a service. Simply put, a service is a network endpoint that will use a credential. Examples might be GitHub, Anthropic, or your own company-specific API.
Sandboxes support several built-in services,
such as anthropic and google. Custom services can be configured by using
kits.
Beyond that, there are also registry credentials, which are used to authenticate to container registries when pulling templates and kits and can be leveraged by the sandbox itself.
Where your credentials live
The credentials are stored securely by your operating system’s credential manager. Specifically:
| Host | Where the value actually lives |
|---|---|
| macOS | System Keychain |
| Windows | Windows Credential Manager |
| Linux | Secret Service (GNOME Keyring, KDE Wallet) |
| Headless Linux, and some WSL setups | ~/.config/com.docker.sandboxes, mode 0700 |
To list the stored secrets, use the sbx secret ls command:
Host machine
sbx secret ls
SCOPE TYPE NAME SECRET (global) service anthropic (stored)
A few things to note about the secrets based on the output:
- SCOPE —
(global)means every sandbox may use it. A secret can also be scoped to one sandbox. - TYPE — the two types are
serviceandregistry - NAME — the service identifier
- SECRET —
(stored), never the value. There is nosbx secret get.
Note that there is no mechanism to retrieve the secret value itself.
Store a secret
To store a secret, use the sbx secret set command. If you don’t pass the value
through stdin, a prompt will ask for the value, ensuring it doesn’t appear in your
command history:
Host machine
sbx secret set github
Applied global secret to existing sandboxes Saved secret github (global)
A global secret applies to every sandbox, including ones that already exist.
The Applied global secret to existing sandboxes line appears only when you
have some.
You can also pipe the secret through stdin. Because github is already stored
from the previous step, add --force to replace it. Without it, sbx asks for
confirmation, which it can’t do while stdin is a pipe, so it stops with an
error:
Host machine
gh auth token | sbx secret set github --force
Enter secret: Applied global secret to existing sandboxes Saved secret github (global)
If you want to set a secret for only a specific sandbox, add the --sandbox flag to the command:
Host machine
sbx secret set --sandbox my-sandbox github
Applied secret to sandbox my-sandbox Saved secret github (my-sandbox)
Store the instruction, not the value
If a credential already lives somewhere in a vault or another tool (such as 1Password, AWS Secrets Manager, or a
CLI), you can configure sbx with a command on how to retrieve it with the --command flag:
Host machine
sbx secret set github --command 'gh auth token' --refresh 30m
✓ command resolved successfully Applied global secret to existing sandboxes Saved command secret github (global)
sbx now runs gh auth token when it needs
the token and re-runs it every 30 minutes, so a rotated token stays current and
there is no stale second copy to leak. --refresh on-demand re-runs it per
request instead.
Notably, github reads (stored) in the listing, just as the plain value did.
sbx secret ls does not currently distinguish a stored value from a stored
command. The Saved command secret line is your confirmation.
Host machine
sbx secret ls
SCOPE TYPE NAME SECRET (global) service anthropic (stored) (global) service github (stored) my-sandbox service github (stored)
For a secret manager, store a reference. --ref reads 1Password op://
references with the op CLI and AWS Secrets Manager ARNs with the aws CLI,
which must be installed and signed in. sbx resolves the reference once when
you store it, and refuses to store it if that fails:
$ sbx secret set anthropic --ref 'op://Work/Anthropic/credential'
$ sbx secret set openai --ref 'arn:aws:secretsmanager:us-west-2:111122223333:secret:openai-api-key'What the sandbox can see
What the agent can see inside the sandbox can vary. Sometimes, a fake credential is configured to prevent CLIs and other tools from thinking a credential needs to be configured. Other times, no credential is configured at all.
For example, take a look at the GitHub token inside the sandbox:
Inside the sandbox
printenv GH_TOKEN
gho_sbxproxymanaged000000000000000000000
There is a value, but it is worthless. Look at its shape: a gho_ prefix and
then padding. That is deliberate. Plenty of tools check the format of a token
before they will start, and some refuse to run without the variable set at all.
Therefore, sbx supplies something that passes those checks and carries no secret.
If you attempt to get information about the current user, it’ll work as you expect:
Inside the sandbox
curl https://api.github.com/user
{"login":"moby","id":10000001,"type":"User","name":"Moby Dock","company":"@docker","public_repos":42,"created_at":"2013-03-20T00:00:00Z"}A kit brings its own service
Built-in services cover the model providers and GitHub. Everything else, including the APIs your own company runs, needs to be defined using a kit.
A kit is a directory holding a descriptor file named after the directory.
The following kit defines a new service called example-tickets, which will
inject credentials for requests going to api.tickets.example.com. Create it
as kits/example-tickets-api/example-tickets-api.yaml:
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
displayName: Example tickets API
description: Access to the example tickets API, with a proxy-managed token
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- api.tickets.example.com
- type: com.docker.sandbox/credential@1
description: Example tickets API token
config:
service: example-tickets
phase: runtime
apiKey:
name: EXAMPLE_TICKETS_TOKEN
proxyManaged: true
inject:
- domain: api.tickets.example.com
header: Authorization
format: "Bearer %s"The credential@1 capability is the part that matters here. The
network-policy@1 capability lets the sandbox reach the host at all: sbx
refuses a kit that injects a credential for a domain its network policy
doesn’t allow.
Read the credential as a contract between three parties:
- the kit declares the match (
domain) and the header (headerandformat), and no value - you supply the value, by defining a secret for the
example-ticketsservice - the proxy brings the two together, per request
Nothing in that file is a secret, which is what makes a kit safe to commit and hand to a teammate.
Start a new sandbox with Claude Code and the kit:
Host
sbx create --name sandbox-with-kit docker/sbx-kit-claude --kit ./kits/example-tickets-api
KITS
○ ./kits/example-tickets-api — building…
✓ ./kits/example-tickets-api — built (0s)
sbx-kit-src:example-tickets-api-8d4188f631be
This kit wants to use these credentials:
example-tickets: API key sent to api.tickets.example.com (not set)
Approve these credentials?
1. Approve all
2. Review each
3. No start without them
Choice [1-3, default 3]: 1
✓ Binding saved to /home/moby/.config/sbx/credentials.yaml
SANDBOX
Name: sandbox-with-kit
Agent: sbx-kit-claude
Kits: docker/sbx-kit-claude
./kits/example-tickets-api
Workspace: none · no workspace bind mount
Skills: 0 entries · read-only
Source: ~/.local/state/sandboxes/sandboxes/agent-skills
Target: /home/agent/.claude/skills
Image: docker.io/docker/sbx-kit-claude@sha256:c0132e89a89d94ddca3984513afdb36be57bd087fcc75823a4d14064b6a39ea5
CPU: 4
Memory: 8 GiB
IMAGE
✓ image — ready
WARN: no credential resolved for required service; sandbox will start without it
WARN: required credential not found for service
AGENT
assembling kit image from 2 kits
pulling kit image /home/moby/kits/example-tickets-api
✓ assembled kit image (11 layers)
setting 3 environment variables
running 4 install commands
✓ env -i PATH="$PATH" HOME="$HOME" USER="$USER" TERM="$TERM" … (kit=sbx-kit-claude, user=0, 266ms)
✓ env -i PATH="$PATH" HOME="$HOME" USER="$USER" TERM="$TERM" … (kit=sbx-kit-claude, user=0, 98ms)
✓ env -i PATH="$PATH" HOME="$HOME" USER="$USER" TERM="$TERM" … (kit=sbx-kit-claude, user=agent, 45ms)
✓ env -i PATH="$PATH" HOME="$HOME" USER="$USER" TERM="$TERM" … (kit=sbx-kit-claude, user=agent, 6.9s)
registering 3 startup commands, run on every container start
○ sh -c chown -R agent:agent /home/agent/.claude/projects /ho… (kit=sbx-kit-claude, user=0)
○ sh -c command -v apt-get > /dev/null 2>&1 && (apt-get updat… (kit=sbx-kit-claude, background, user=0)
○ sh -c set -e [ -n "$MCP_GATEWAY_URL" ] || exit 0 export PAT… (kit=sbx-kit-claude, user=agent)
view startup log: cat /var/log/sbx-kit-startup.log
✓ Created sandbox sandbox-with-kit
try: sbx run --name sandbox-with-kitBefore it creates the sandbox, sbx asks you to approve the kit’s use of the
example-tickets credential, and on which domain. Choose Approve all. The
secret isn’t stored yet, so the prompt says (not set), and sbx warns that
the sandbox starts without it.
Try accessing the API from inside the sandbox:
Host machine · no credential stored yet
sbx exec sandbox-with-kit -- curl https://api.tickets.example.com/v1/tickets
{"error":{"code":"unauthorized","message":"Missing bearer token"}}The call is rejected. The proxy recognises the domain from the kit’s declaration,
but no credential is injected because there is no stored value for
example-tickets yet.
Set the secret, this time using a 1Password reference:
Host machine
sbx secret set example-tickets --ref 'op://Engineering/example-tickets/token'
✓ ref resolved successfully Applied global secret to existing sandboxes Captured resolver environment: <none> Saved ref secret example-tickets (global)
The secret is now stored! And the request should now work.
Host machine
sbx exec sandbox-with-kit -- curl https://api.tickets.example.com/v1/tickets
{"data":[{"id":"TIX-418","title":"Flaky test in the checkout suite","status":"open"},{"id":"TIX-421","title":"Upgrade the base image","status":"in_progress"}],"total":2}What the kit is allowed to do
A kit that declares a credential injection is asking for something specific: permission to have one of your credentials attached to requests bound for a domain it names. You granted that permission when you chose Approve all, before the secret existed. Storing the secret does not grant it — it is tracked separately, in a file you can read:
Host machine (Mac/Linux)
cat ~/.config/sbx/credentials.yaml
bindings:
anthropic:
apiKey:
domains:
- api.anthropic.com
- mcp-proxy.anthropic.com
oauth:
domains:
- platform.claude.com
example-tickets:
apiKey:
domains:
- api.tickets.example.comThat file — ~/.config/sbx/credentials.yaml, or %APPDATA%\sbx\credentials.yaml
on Windows — lists the mechanisms and domains you have approved, per service.
The built-in kits shipped with sbx, such as the one sbx run claude uses,
need no binding. Any other kit does, including Docker’s published
docker/sbx-kit-claude, so its declared injection has to be bound here before
the proxy will honour it. The anthropic entry is there because you approved
that kit’s use of your Anthropic key the first time you ran it. The
binding for example-tickets covers exactly one host. If that kit later updates
and starts asking for a second domain, the binding it already has does not cover
the new one, and sbx asks you to approve it again.
It is worth reading occasionally for exactly that reason. It answers “what have I allowed, and for which hosts?” on one screen.
That is the whole design. The sandbox is a consumer of authenticated access, never a holder of credentials.
Credentials for pulling images
Service secrets answer “how does the agent call an API?”. Registry credentials
answer a different question: how does sbx pull a private sandbox template or
kit artifact in the first place before there is a sandbox to inject anything
into?
These secrets use the same command, but add the --registry flag:
Host machine
gh auth token | sbx secret set --registry ghcr.io --username moby --password-stdin
Saved registry secret ghcr.io (host only, username moby)
sbx secret ls
SCOPE TYPE NAME SECRET (global) service anthropic (stored) (global) service example-tickets (stored) (global) service github (stored) my-sandbox service github (stored) (host only) registry ghcr.io moby/gho_FA******...******4444
Unlike a service secret, a registry credential’s SECRET column shows the
username and a masked form of the token: its first and last few characters.
Registry credentials are, by default, scoped to only the sbx tool to pull templates and kits. To share the credential with sandboxes, scope it in deliberately:
# Host only
$ gh auth token | sbx secret set --registry ghcr.io --password-stdin
# One specific sandbox
$ gh auth token | sbx secret set --sandbox my-app --registry ghcr.io --password-stdin
# Every sandbox
$ gh auth token | sbx secret set --all-sandboxes --registry ghcr.io --password-stdinRecap
There are three kinds of credential, plus one way to avoid keeping a copy at all:
| What | Configured with | Where the value lives | Reaches the sandbox? |
|---|---|---|---|
| Built-in service | sbx secret set github |
OS credential store | No — header injected |
| Kit-declared service | sbx secret set example-tickets |
OS credential store | No — header injected |
| Either, dynamically | --command '…' or --ref '…' |
Wherever it already lives | No — header injected |
| Registry credential | sbx secret set --registry … --password-stdin |
OS credential store | No, unless scoped in |
Worth carrying forward:
- The sandbox holds no credentials. If there is a “credential” in the
sandbox, it is only a placeholder. Resist the urge to use
.envfiles orexportenvironment variables in the sandbox. - A kit’s declaration is public, its value is yours. The kit names the domain and the header. You supply the secret. Nothing secret goes in the kit.
- Permission and value are separate. A stored secret is not an approval. The bindings file is what says a third-party kit may have your credential attached to a given host.
- Scope narrowly.
--sandboxover global, and global over--all-sandboxes.
Table of contents