Guide

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

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

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 the acme-api kit adds to a sandbox Inside the sandbox, the agent calls GET /v1/deployments holding only a placeholder token, so the request leaves with no usable credential. On your host, the sandbox's proxy combines two inputs: the acme-api kit, which says which host to allow and which header to set, and your acme-deploy secret, whose value stays in your keychain. The proxy adds the Authorization header and forwards the request to the Acme deploy API at api.acme.example.com, which receives it authenticated. SANDBOX Agent GET /v1/deployments placeholder token YOUR HOST Sandbox proxy allows the host, then adds Authorization: Bearer … acme-api kit which host, which header acme-deploy your token, held in your keychain INTERNAL SERVICE Acme deploy API sees a valid token api.acme.example.com no token + token

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.yaml

You 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:

  1. Network policy: the request reaches the API, but is rejected with a 401.
  2. Credential: the proxy injects the token, and the request succeeds.
  3. Bounded network policy: read requests succeed, and anything else is refused at the boundary.

Prerequisites

  • sbx v0.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. sbx builds 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-before
sbx 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.com

The 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-net
sbx 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. An install credential 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 to api.acme.example.com get an Authorization header, rendered by format with 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-auth

Your 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-readonly
sbx 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:

  1. 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.
  2. 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.
  3. 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 the Authorization header. The API returns 200.

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-deploy token?” 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 sbx prompts before using it. Bounding a host, as you did with network-policy@2, narrows the surface instead.
  • Bounds protect the token, not only the API. With methods and paths, 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

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.

Learning paths

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.