Connect to services without exposing credentials
- Intermediate
- 20 min
- Published
- Verified
What you'll learn
- Open a network path to a custom API endpoint with a kit's network policy
- Declare a proxy-managed credential so the proxy injects the token for that endpoint
- Supply the token on the host and approve the kit's credential binding
- Bound which requests can carry the credential with HTTP method and path rules
Your team runs an internal deploy API at api.acme.example.com. You’d like
your agent to check deployment status before it opens a pull request, but the
API wants a bearer token, and pasting that token into the agent’s environment
is exactly what a sandbox is meant to avoid.
Docker Sandboxes already handle this for services they know about, such as GitHub and the model providers. A host-side proxy holds the real credentials and attaches them to matching requests as they leave the sandbox. Your internal API isn’t on that list, and the proxy can’t guess which host it lives on or which header it expects.
A kit provides these details. In this guide, you write a small kit that extends the built-in proxy to a new service: it opens a network path to your API, declares how the token is presented, and narrows which requests may carry it. The token itself never appears in the kit or the sandbox.
What you’ll build
A content-free mixin kit: a single descriptor file with no Dockerfile. It installs nothing. It only asks the sandbox for network access and a credential.
acme-api/
└── acme-api.yamlYou add it to Docker’s published shell workload, prove the API is unreachable without it, and then build it up one capability at a time:
- Network policy: the request reaches the API, but is rejected with a
401. - Credential: the proxy injects the token, and the request succeeds.
- Bounded network policy: read requests succeed, and anything else is refused at the boundary.
Prerequisites
sbxv0.45 or later. Kits v3 need at least v0.45. This guide was written against v0.47.0. See Install Docker Sandboxes.- Docker with Buildx, to validate the descriptor.
sbxbuilds a local kit directory itself, so this is optional.
The examples use a fictional Acme deploy API at api.acme.example.com with a
GET /v1/deployments endpoint. Substitute your own host, path, and token, and
expect your API’s responses to differ from the ones shown here.
See the request fail
Start with no kit at all. Create a sandbox from Docker’s v3 shell workload:
Host machine
sbx create docker/sbx-kit-shell:1.0.0 --name acme-before
SANDBOX
Name: acme-before
Agent: sbx-kit-shell
Kit: docker/sbx-kit-shell:1.0.0
Workspace: none · no workspace bind mount
Image: docker.io/docker/sbx-kit-shell996272ff6de
CPU: 16
Memory: 32 GiB
IMAGE
✓ image — ready
✓ Created sandbox acme-before
try: sbx run --name acme-beforesbx exec acme-before -- curl -s https://api.acme.example.com/v1/deployments
Approval required for api.acme.example.com:443. Review and respond with: sbx policy approval ls
This failure is expected. A sandbox denies egress by default, and nothing has asked for this host. The request stops at the host-side proxy and never reaches your API.
Open a path to the endpoint
In the directory you’ll run sbx from, create a directory named acme-api.
Inside it, create a file named acme-api.yaml with the following contents:
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
displayName: Acme deploy API
description: Access to the Acme deploy API
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- api.acme.example.comThe first line selects the kit build frontend, and schemaVersion: "3"
declares the v3 format. kind: mixin means this kit adds to a workload
rather than replacing it. A mixin may carry no content at all, so this kit is
just a list of capabilities — requests the sandbox answers when it starts.
network-policy@1 asks for egress. Its runtime block applies while the
agent runs. A separate install block, which this kit doesn’t need, applies
only while setup hooks run, and closes before the agent starts.
Create a new sandbox with the kit. A sandbox keeps the kit configuration it was created with, so each change in this guide gets a new sandbox name:
Host machine
sbx create docker/sbx-kit-shell:1.0.0 --name acme-net --kit ./acme-api
KITS
○ ./acme-api — building…
✓ ./acme-api — built (4s)
sbx-kit-src:acme-api-842053f1cc56
SANDBOX
Name: acme-net
Agent: sbx-kit-shell
Kits: docker/sbx-kit-shell:1.0.0
./acme-api
Workspace: none · no workspace bind mount
Image: docker.io/docker/sbx-kit-shell996272ff6de
CPU: 16
Memory: 32 GiB
IMAGE
✓ image — ready
AGENT
assembling kit image from 2 kits
pulling kit image /home/moby/acme-api/acme-api
✓ assembled kit image (3 layers)
✓ Created sandbox acme-net
try: sbx run --name acme-netsbx exec acme-net -- curl -s https://api.acme.example.com/v1/deployments
{"error":"unauthorized","message":"Missing bearer token"}This is progress. The request left the sandbox and reached the API, which rejected it because nothing authenticated it. Allowing a host is a different grant from authenticating to it. A network rule says where traffic may go. It never attaches a credential.
Declare the credential
Open acme-api/acme-api.yaml and add a credential@1 capability below the
network policy:
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
displayName: Acme deploy API
description: Access to the Acme deploy API
capabilities:
- type: com.docker.sandbox/network-policy@1
config:
runtime:
allow:
- api.acme.example.com
- type: com.docker.sandbox/credential@1
description: Acme deploy API token
config:
service: acme-deploy
phase: runtime
apiKey:
name: ACME_API_TOKEN
proxyManaged: true
inject:
- domain: api.acme.example.com
header: Authorization
format: "Bearer %s"Each field answers one question the proxy has:
service: acme-deploy: which secret to use. This is the name the value is stored under on the host. You are inventing a new service here, and there’s no separate registration step. The name in the kit and the name the user stores the secret under are the same string.phase: runtime: when it applies. A runtime credential is available to the agent. Aninstallcredential is available only to setup hooks, and is withdrawn before the agent starts.apiKey.name: ACME_API_TOKEN: the environment variable tools inside the sandbox read. It holds a placeholder, never the real token.proxyManaged: true: the real value stays on the host, and the proxy writes it into outbound requests.inject: which requests get it, and how. Requests toapi.acme.example.comget anAuthorizationheader, rendered byformatwith the token in place of%s.
Some APIs want a different header. Change header and format to match, for
example header: x-api-key with format: "%s". Write the header out
explicitly: in v3 kits, sbx doesn’t use the scheme shorthand.
Notice what’s missing: the token. Nothing in this file is secret, so you can commit it, review it, and hand it to the whole team. The kit says what is needed. Each user supplies the value.
Tell the agent about the API
The proxy now knows the API, but the agent doesn’t. Add an agent-context@1
entry to the end of the capabilities list. A content-free kit has no files
to stage, so the instructions go inline under content:
- type: com.docker.sandbox/agent-context@1
config:
content: |
## Acme deploy API
Query deployment status at https://api.acme.example.com/v1/.
Authentication is proxy-managed: send
`Authorization: Bearer $ACME_API_TOKEN`. The variable holds a
placeholder, so never print it, and never ask the user for a token.The workload decides which instructions file its agent reads, and a mixin’s content is added to it. Without this, an agent that finds the API might ask you for a token, which is exactly the habit this guide is meant to break.
Supply the token and approve the binding
Two things must happen on the host before the proxy uses a third-party kit’s credential: the value must be stored, and you must approve the kit’s use of it. These are deliberately separate steps.
Store the token under the service name the kit declares:
Host machine
sbx secret set acme-deploy
Saved secret for service "acme-deploy" in scope "(global)"
The value goes into your operating system’s credential store. To resolve it from a password manager or a CLI instead of typing it, see Working with secrets with Docker Sandboxes.
Then create a sandbox with the updated kit. Run this in an interactive
terminal. Because acme-api is not one of Docker’s built-in kits, sbx
stops and asks you to approve its request to use the acme-deploy credential
on api.acme.example.com. Approve it.
Host machine
sbx create docker/sbx-kit-shell:1.0.0 --name acme-auth --kit ./acme-api
KITS
○ ./acme-api — building…
✓ ./acme-api — built (2s)
sbx-kit-src:acme-api-6d8e65d12a50
SANDBOX
Name: acme-auth
Agent: sbx-kit-shell
Kits: docker/sbx-kit-shell:1.0.0
./acme-api
Workspace: none · no workspace bind mount
Image: docker.io/docker/sbx-kit-shell
CPU: 4
Memory: 16 GiB
IMAGE
✓ image — ready
AGENT
assembling kit image from 2 kits
pulling kit image /home/moby/acme-api/acme-api
✓ assembled kit image (3 layers)
setting 1 environment variable
✓ Created sandbox acme-auth
try: sbx run --name acme-authYour approval is recorded in the credential bindings file,
~/.config/sbx/credentials.yaml (%APPDATA%\sbx\credentials.yaml on
Windows):
Host machine (Mac/Linux)
cat ~/.config/sbx/credentials.yaml
bindings:
acme-deploy:
apiKey:
domains: [api.acme.example.com]The binding holds no value. It records that you allowed this service’s API
key to be attached to requests for api.acme.example.com and nowhere else.
If a later version of the kit asks for another domain, the existing binding
doesn’t cover it, and sbx asks again.
Verify the agent never holds the token
Check what the sandbox can see:
Host machine
sbx exec acme-auth -- printenv ACME_API_TOKEN
proxy-managed
That’s a placeholder. Tools that refuse to start without the variable get something to read, and anyone who copies it gets nothing they can use.
Now call the API the way the agent would, sending the placeholder in the header:
Host machine
sbx exec acme-auth -- sh -c 'curl -s -H "Authorization: Bearer $ACME_API_TOKEN" https://api.acme.example.com/v1/deployments'
{"deployments":[{"service":"checkout","environment":"production","version":"2026.10.3","status":"healthy"},{"service":"search","environment":"production","version":"2026.10.1","status":"degraded"}]}The request left the sandbox carrying Bearer proxy-managed. The proxy
matched the destination to the kit’s inject rule, read the acme-deploy
secret from your host, and wrote the real token into the Authorization
header on the way out. The API authenticated a request the sandbox could not
have authenticated by itself.
Narrow what the token can do
The kit now works, but it’s broader than it needs to be. The agent only reads
deployment status, yet any request to api.acme.example.com, including a
POST that triggers a rollback, gets the token attached.
network-policy@2 keeps the same install and runtime blocks, and lets an
entry bound a host to particular HTTP methods and paths. In
acme-api/acme-api.yaml, replace the network-policy@1 entry with this one. A kit declares one version or the
other, never both:
- type: com.docker.sandbox/network-policy@2
config:
runtime:
allow:
- hosts: [api.acme.example.com]
methods: [GET, HEAD]
paths: [/v1/**]A bounded entry grants matching HTTP requests, not the host. Anything else to
api.acme.example.com is refused at the boundary, so the injected token can
only travel on read requests under /v1/. Methods must be uppercase, and
stating paths requires stating methods.
The complete acme-api/acme-api.yaml now looks like this:
# syntax=docker/sandbox-kit:3
schemaVersion: "3"
kind: mixin
displayName: Acme deploy API
description: Read access to the Acme deploy API, with a proxy-managed token
capabilities:
- type: com.docker.sandbox/network-policy@2
config:
runtime:
allow:
- hosts: [api.acme.example.com]
methods: [GET, HEAD]
paths: [/v1/**]
- type: com.docker.sandbox/credential@1
description: Acme deploy API token
config:
service: acme-deploy
phase: runtime
apiKey:
name: ACME_API_TOKEN
proxyManaged: true
inject:
- domain: api.acme.example.com
header: Authorization
format: "Bearer %s"
- type: com.docker.sandbox/agent-context@1
config:
content: |
## Acme deploy API
Query deployment status at https://api.acme.example.com/v1/.
Authentication is proxy-managed: send
`Authorization: Bearer $ACME_API_TOKEN`. The variable holds a
placeholder, so never print it, and never ask the user for a token.Before you create another sandbox, build the descriptor with Buildx. The kit frontend validates it strictly, so an unknown field, a misspelled capability, or a lowercase method fails the build instead of reaching a sandbox:
Host machine
docker buildx build -f acme-api/acme-api.yaml -t acme-api-kit acme-api/
=> [internal] load build definition from acme-api.yaml => => transferring dockerfile: 1.11kB => resolve image config for docker-image://docker.io/docker/sandbox-kit:3 => docker-image://docker.io/docker/sandbox-kit:3@sha256:e6a397771e865625047cf84256a914c7974b3e749d858259b5bc9c653ddb6f0d => => resolve docker.io/docker/sandbox-kit:3@sha256:e6a397771e865625047cf84256a914c7974b3e749d858259b5bc9c653ddb6f0d => [internal] load kit descriptor acme-api.yaml · sandbox-kit 3.0.0-m.8 (129be2ff) => => transferring dockerfile: 35B => [kit] stage sources /usr/share/sandbox/kit/acme-api => exporting to image => => exporting layers => => exporting manifest sha256:80478183844050d9a0303cd9cd5a08389a50eb4c21f1ee6ac74cf0abb15c7d1a => => exporting config sha256:530782aae606a028a91fd6886e47e057a5ec88c79ff3bc4262e9527044e18c67 => => exporting attestation manifest sha256:1f825a267fcb564ec5f0cff0919f77720d5a216abfece4c2c5fbfd1059019542 => => exporting manifest list sha256:06f255eeb15836bd73c62b7c8301e81ce6a2ce0bd6d252da5c79255307e8bc09 => => naming to docker.io/library/acme-api-kit:latest => => unpacking to docker.io/library/acme-api-kit:latest
Create a sandbox with the bounded kit and try both sides of the boundary. The domain hasn’t changed, so the binding you approved still covers it:
Host machine
sbx create docker/sbx-kit-shell:1.0.0 --name acme-readonly --kit ./acme-api
KITS
○ ./acme-api — building…
✓ ./acme-api — built (1s)
sbx-kit-src:acme-api-711cb488a995
SANDBOX
Name: acme-readonly
Agent: sbx-kit-shell
Kits: docker/sbx-kit-shell:1.0.0
./acme-api
Workspace: none · no workspace bind mount
Image: docker.io/docker/sbx-kit-shell
CPU: 4
Memory: 16 GiB
IMAGE
✓ image — ready
AGENT
assembling kit image from 2 kits
pulling kit image /home/moby/acme-api/acme-api
✓ assembled kit image (3 layers)
setting 1 environment variable
✓ Created sandbox acme-readonly
try: sbx run --name acme-readonlysbx exec acme-readonly -- sh -c 'curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $ACME_API_TOKEN" https://api.acme.example.com/v1/deployments'200
sbx exec acme-readonly -- sh -c 'curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Authorization: Bearer $ACME_API_TOKEN" https://api.acme.example.com/v1/deployments/checkout/rollback'403
The 403 comes from the boundary, not from your API. The POST never left
your machine, so the token was never attached to it. That status is what
tells you a rule refused the request, rather than the API answering 404 or
405 by itself. Run sbx policy log on the host to see which rule matched.
Understand what happened
Step through the same request at each stage of the kit:
- No kit. The request carries no token and matches no allow rule. The proxy blocks it under the default-deny policy, so it never reaches the API.
- Network policy only. The kit’s allow rule lets the request through,
but no inject rule matches, so it reaches the API unauthenticated and gets
a
401. - Network policy and credential. The agent sends the placeholder from
ACME_API_TOKEN. The proxy matches the inject rule, confirms your binding approves it, reads the real token from your host’s credential store, and writes it into theAuthorizationheader. The API returns200.
Three parties each own one piece, and none of them can complete the request alone:
| Who | Supplies | Where it lives |
|---|---|---|
| The kit | Which hosts, which requests, which header | acme-api.yaml, safe to share |
| The user | The token, and approval for these domains | OS credential store and bindings |
| The proxy | Combines the two, per request, on the way out | On the host, outside the sandbox |
That split is what makes the kit shareable. A teammate who runs it brings their own token, approves the same domains, and gets the same behavior. The agent sees a placeholder the whole time.
A few properties follow from that design:
- Egress and credentials are separate grants. A network rule never authenticates anything, and a credential never opens a connection. That’s why the kit needs both, and why the inject domain must be allowed.
- The bindings file is your approval record. It answers “which hosts may
carry my
acme-deploytoken?” on one screen. Storing a secret approves nothing by itself. - Widening asks again. A kit’s network rules and credentials are its
permission surface. A new inject domain isn’t covered by your binding, so
sbxprompts before using it. Bounding a host, as you did withnetwork-policy@2, narrows the surface instead. - Bounds protect the token, not only the API. With
methodsandpaths, the credential can only ride the requests you meant it for, even if the agent is talked into trying something else.
Where to go next
- Store secrets from a password manager, scope them to one sandbox, or set up registry credentials: Working with secrets with Docker Sandboxes.
- New to writing kits? Start with Build your first Docker Sandbox kit.
- Install the CLI that calls your API in the same kit: Add tools and runtimes to a sandbox.
- Let each team point the kit at its own endpoint: Make your Sandbox kits configurable.
- Share the finished kit: Publish and share your Docker Sandbox kits.
For every field, see the
credential@1,
network-policy@1,
and
network-policy@2
capability pages in the Docker Sandbox Kit Specification, and
Manage credentials
in the Docker Docs. The
GitHub CLI example kit
pairs a bounded network policy with a deny rule and a credential in the
same way.
Part of a learning path
This works on its own and is also included in the paths below. Open a path to see the complete route.
Learning path
Build and customize Docker Sandbox environments
Step 4 of 6
- Step 1: Build your first Docker Sandbox kit
- Step 2: Add tools and runtimes to a sandbox
- Step 3: Give your agents reusable skills
- Step 4: Connect to services without exposing credentials (this page)
- Step 5: Make your Sandbox kits configurable
- Step 6: Publish and share your Docker Sandbox kits
Table of contents