01Documentation
Egress Auth
Egress auth rules tell Sandbox0 when to apply outbound authentication for matching traffic.
This configuration is carried on the network surface as network.egress.credentialRules, but conceptually it belongs to the credential system:
credentialBindingsdefine what auth material exists.credentialRulesdefine where and when that material is used.trafficRulesstill decide whether the traffic itself is allowed.
Bindings are configured under the same network object everywhere:
- template
spec.network - sandbox claim
config.network - runtime update
PUT /api/v1/sandboxes/{id}/network
Binding Fields#
| Field | Required | Description |
|---|---|---|
ref | Yes | Stable local name matched by credentialRules[*].credentialRef |
sourceRef | Yes | Credential source name in the sandbox's team and region |
projection | Yes | How the source is shaped for runtime injection |
cachePolicy.ttl | No | Optional ctld network-runtime cache lifetime for resolved material, such as 5m or 1h |
Binding Example#
This binding projects a stored token into an Authorization header:
yamlcredentialBindings: - ref: gh-token sourceRef: github-source projection: type: http_headers httpHeaders: headers: - name: Authorization valueTemplate: "Bearer {{ .token }}"
In this example, {{ .token }} refers to the token key inside a static_headers source's values map. If the referenced key is missing, header rendering fails.
Resolve Cache And Rotation#
Runtime egress auth resolution is cached by the ctld network runtime, not by manager. Manager resolves the current sandbox binding from PostgreSQL and returns an expiresAt timestamp. Ctld reuses that resolved material until expiresAt, then asks manager again.
Credential source rotation actively invalidates cached resolved material in ctld. After PUT /api/v1/credential-sources/{'{name}'}, already-claimed sandboxes should use the new source version on the next matching egress resolve. If realtime invalidation delivery is interrupted by ctld, manager, or database restarts, cache expiry remains the fallback consistency bound.
The default resolved-material lifetime is 5 minutes. Set cachePolicy.ttl on a binding when that binding needs a shorter or longer fallback cache window.
This cache lifetime applies to future credential resolution. Rotation does not
disconnect upstream connections that already authenticated with old material.
Placeholder Substitution#
Use placeholder_substitution when the sandbox process should see only an opaque placeholder, while the ctld network runtime replaces that placeholder with the resolved secret at the egress boundary.
This projection works for HTTP-family rules. For protocol: https or protocol: grpc, the rule must use tlsMode: terminate-reoriginate so the ctld network runtime can inspect and rewrite the HTTP request. Body substitution requires a known Content-Length, an uncompressed body, and a body size of 1 MiB or less.
yamlcredentialBindings: - ref: api-token sourceRef: api-source projection: type: placeholder_substitution placeholderSubstitution: replacements: - placeholder: s0env_api_token_placeholder valueTemplate: "{{ .token }}" locations: - header - query - body
With that binding, a sandbox process can send the placeholder in a header, query parameter, or request body. Matching outbound HTTP requests receive the resolved token value before they are sent upstream.
Runnable Placeholder Example#
The snippets below configure a sandbox so the process can send s0env_api_token in a header, query value, or body, while the egress path replaces it with the token stored in api-token-source.
Set these values before running the snippets:
SANDBOX_ID: sandbox to updateAPI_CIDR: destination CIDR such as203.0.113.10/32API_ORIGIN: destination origin such ashttp://203.0.113.10:8080API_PORT: destination port, defaults to8080API_TOKEN: secret value to inject, defaults todocs-secret-token
Use a reachable test API that accepts the request shown below. The documentation
address 203.0.113.10 is a placeholder, not a hosted test service. Run one SDK,
CLI, or HTTP setup variant; repeating source creation with the same name
returns 409. Use a new SOURCE_NAME or rotate an existing source deliberately.
SDK examples assume client and a handle for SANDBOX_ID already exist.
goapiCIDR := os.Getenv("API_CIDR") if apiCIDR == "" { log.Fatal("API_CIDR is required") } apiPort := int32(8080) if value := os.Getenv("API_PORT"); value != "" { parsed, err := strconv.ParseInt(value, 10, 32) if err != nil { log.Fatal(err) } apiPort = int32(parsed) } apiToken := os.Getenv("API_TOKEN") if apiToken == "" { apiToken = "docs-secret-token" } sourceName := os.Getenv("SOURCE_NAME") if sourceName == "" { sourceName = "api-token-source" } _, err := client.CreateCredentialSource(ctx, apispec.CredentialSourceWriteRequest{ Name: sourceName, ResolverKind: apispec.CredentialSourceResolverKindStaticHeaders, Spec: apispec.CredentialSourceWriteSpec{ StaticHeaders: apispec.NewOptStaticHeadersSourceSpec(apispec.StaticHeadersSourceSpec{ Values: apispec.NewOptStaticHeadersSourceSpecValues(apispec.StaticHeadersSourceSpecValues{ "token": apiToken, }), }), }, }) if err != nil { log.Fatal(err) } _, err = sandbox.UpdateNetworkPolicy(ctx, apispec.SandboxNetworkPolicy{ Mode: apispec.SandboxNetworkPolicyModeBlockAll, Egress: apispec.NewOptNetworkEgressPolicy(apispec.NetworkEgressPolicy{ TrafficRules: []apispec.TrafficRule{ { Name: apispec.NewOptString("allow-example-api"), Action: apispec.TrafficRuleActionAllow, Cidrs: []string{apiCIDR}, Ports: []apispec.PortSpec{ { Port: apiPort, Protocol: apispec.NewOptString("tcp"), }, }, }, }, CredentialRules: []apispec.EgressCredentialRule{ { Name: apispec.NewOptString("api-token"), CredentialRef: "api-token", Protocol: apispec.NewOptEgressAuthProtocol(apispec.EgressAuthProtocolHTTP), Ports: []apispec.PortSpec{ { Port: apiPort, Protocol: apispec.NewOptString("tcp"), }, }, FailurePolicy: apispec.NewOptEgressAuthFailurePolicy(apispec.EgressAuthFailurePolicyFailClosed), }, }, }), CredentialBindings: []apispec.CredentialBinding{ { Ref: "api-token", SourceRef: sourceName, Projection: apispec.ProjectionSpec{ Type: apispec.CredentialProjectionTypePlaceholderSubstitution, PlaceholderSubstitution: apispec.NewOptPlaceholderSubstitutionProjection(apispec.PlaceholderSubstitutionProjection{ Replacements: []apispec.PlaceholderReplacement{ { Placeholder: "s0env_api_token", ValueTemplate: "{{ .token }}", Locations: []apispec.PlaceholderSubstitutionLocation{ apispec.PlaceholderSubstitutionLocationQuery, apispec.PlaceholderSubstitutionLocationHeader, apispec.PlaceholderSubstitutionLocationBody, }, }, }, }), }, }, }, }) if err != nil { log.Fatal(err) }
For raw HTTP, also set SANDBOX0_API_URL and SANDBOX0_API_KEY for the same
team and region as the sandbox.
bash# Requires jq. if [ -z "$API_PORT" ]; then API_PORT=8080; fi if [ -z "$API_TOKEN" ]; then API_TOKEN=docs-secret-token; fi if [ -z "$SOURCE_NAME" ]; then SOURCE_NAME=api-token-source; fi if [ -z "$PLACEHOLDER" ]; then PLACEHOLDER=s0env_api_token; fi curl -fsS -X POST "$SANDBOX0_API_URL/api/v1/credential-sources" \ -H "Authorization: Bearer $SANDBOX0_API_KEY" \ -H "Content-Type: application/json" \ --data "$(jq -n \ --arg name "$SOURCE_NAME" \ --arg token "$API_TOKEN" \ '{ name: $name, resolverKind: "static_headers", spec: { staticHeaders: { values: { token: $token } } } }')" curl -fsS -X PUT "$SANDBOX0_API_URL/api/v1/sandboxes/$SANDBOX_ID/network" \ -H "Authorization: Bearer $SANDBOX0_API_KEY" \ -H "Content-Type: application/json" \ --data "$(jq -n \ --arg apiCidr "$API_CIDR" \ --argjson apiPort "$API_PORT" \ --arg source "$SOURCE_NAME" \ --arg placeholder "$PLACEHOLDER" \ '{ mode: "block-all", egress: { trafficRules: [ { name: "allow-example-api", action: "allow", cidrs: [$apiCidr], ports: [ { port: $apiPort, protocol: "tcp" } ] } ], credentialRules: [ { name: "api-token", credentialRef: "api-token", protocol: "http", ports: [ { port: $apiPort, protocol: "tcp" } ], failurePolicy: "fail-closed" } ] }, credentialBindings: [ { ref: "api-token", sourceRef: $source, projection: { type: "placeholder_substitution", placeholderSubstitution: { replacements: [ { placeholder: $placeholder, valueTemplate: "{{ .token }}", locations: ["query", "header", "body"] } ] } } } ] }')"
After the policy is applied, the sandbox process can send the placeholder without reading the secret:
bashcurl -fsS \ -H "Authorization: Bearer s0env_api_token" \ "$API_ORIGIN/resource?token=s0env_api_token" \ --data "token=s0env_api_token"
The upstream service receives the resolved API_TOKEN value in the header, query parameter, and request body.
Verify those values at your test receiver without reflecting them into the
response sent back to the sandbox. Injection keeps the source out of the
requesting process; it cannot prevent a destination server from disclosing it.
Rule Fields#
| Field | Required | Description |
|---|---|---|
credentialRef | Yes | Binding reference to resolve |
protocol | No | Supported values: http, https, grpc, tls, ssh, socks5, mqtt, redis |
domains | No | Domain match list |
ports | No | Port/protocol constraints |
httpMatch | No | HTTP request-level matcher for http, https, and grpc rules |
tlsMode | No | passthrough or terminate-reoriginate |
failurePolicy | No | fail-closed or fail-open |
rollout | No | enabled or disabled |
HTTPS Trust Material#
When the platform configures a network-runtime MITM CA, Sandbox0 mounts its
public certificate and prepares trust for procd-managed processes. Rules using
protocol: https, protocol: grpc, or protocol: tls with
tlsMode: terminate-reoriginate require this trust setup.
- File:
/var/run/sandbox0/networking/mitm-ca.crt - Env:
SANDBOX0_NETWORK_MITM_CA_FILE=/var/run/sandbox0/networking/mitm-ca.crt
At procd startup, when the CA environment variable is set, Sandbox0 writes a
combined bundle using the first available system CA bundle plus the network
CA. It exports the bundle through SSL_CERT_FILE, NODE_EXTRA_CA_CERTS,
REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, and AWS_CA_BUNDLE.
Clients that honor those variables normally need no extra configuration.
Applications with their own trust store or certificate pinning need explicit
integration; this does not rewrite every application's trust store.
For HTTPS credential injection, the ctld network runtime supports both HTTP/1.1 and HTTP/2 on terminate-reoriginate connections. Downstream and upstream ALPN are negotiated independently, so an HTTP/2 client can still reach an HTTP/1.1-only HTTPS origin through the proxy. Ctld keeps gRPC HTTP/2-only and rejects an upstream connection that cannot negotiate h2.
Example Policy#
This policy allows access to the GitHub API, binds a token source, and injects an Authorization header for matching HTTPS requests. Set tlsMode: terminate-reoriginate so the egress runtime can decrypt the request and inject the header:
yamlmode: block-all egress: trafficRules: - name: allow-github-api action: allow domains: - api.github.com ports: - port: 443 protocol: tcp credentialRules: - name: github-auth credentialRef: gh-token protocol: https tlsMode: terminate-reoriginate domains: - api.github.com ports: - port: 443 protocol: tcp credentialBindings: - ref: gh-token sourceRef: github-source projection: type: http_headers httpHeaders: headers: - name: Authorization valueTemplate: "Bearer {{ .token }}"
Here, {{ .token }} is rendered from the token entry stored in the bound static_headers source. Keep the projection template keys aligned with the source contents.
HTTP Request Matching#
Use httpMatch when a credential should only be injected for specific API requests on a broader allowed destination. Non-matching requests still follow trafficRules, but do not resolve or inject the credential.
For protocol: https and protocol: grpc, request matching requires tlsMode: terminate-reoriginate.
When multiple credentialRules match the same destination, Sandbox0 keeps the destination-matching rules as candidates and evaluates their httpMatch blocks in declaration order after the HTTP request is visible. The first rule with a matching httpMatch is selected. A rule without httpMatch matches any remaining request, so place narrower path, header, method, or query rules before catch-all rules.
yamlmode: block-all egress: trafficRules: - name: allow-github-api action: allow domains: - api.github.com ports: - port: 443 protocol: tcp credentialRules: - name: github-write-auth credentialRef: gh-token protocol: https tlsMode: terminate-reoriginate domains: - api.github.com ports: - port: 443 protocol: tcp httpMatch: methods: - POST - PUT - PATCH - DELETE pathPrefixes: - /repos/ headers: - name: accept values: - application/vnd.github+json credentialBindings: - ref: gh-token sourceRef: github-source projection: type: http_headers httpHeaders: headers: - name: Authorization valueTemplate: "Bearer {{ .token }}"
You can use that ordering to inject different credentials for different paths
on the same host. This policy fragment also needs credentialBindings for
both github-emu and github-cloud, each pointing at the intended source:
yamlmode: block-all egress: trafficRules: - name: allow-github action: allow domains: - github.com ports: - port: 443 protocol: tcp credentialRules: - name: github-emu-auth credentialRef: github-emu protocol: https tlsMode: terminate-reoriginate domains: - github.com ports: - port: 443 protocol: tcp httpMatch: pathPrefixes: - /emu-org/ - name: github-cloud-auth credentialRef: github-cloud protocol: https tlsMode: terminate-reoriginate domains: - github.com ports: - port: 443 protocol: tcp httpMatch: pathPrefixes: - /cloud-org/
SSH Transparent Proxy#
SSH egress auth uses a transparent proxy model. The sandbox-side SSH client authenticates to the ctld network runtime with a fake public/private key pair, and ctld authenticates to the upstream SSH server with the private key stored in a static_ssh_private_key credential source.
The upstream private key is never written into the sandbox. The sandbox only needs the fake private key that matches projection.sshProxy.sandboxPublicKeys.
Create the static_ssh_private_key source through an SDK or raw HTTP; the
current CLI credential-source file parser does not support that resolver kind.
yamlmode: block-all egress: trafficRules: - name: allow-git-ssh action: allow domains: - github.com ports: - port: 22 protocol: tcp appProtocols: - ssh credentialRules: - name: git-ssh-auth credentialRef: git-ssh protocol: ssh domains: - github.com ports: - port: 22 protocol: tcp credentialBindings: - ref: git-ssh sourceRef: github-ssh-key projection: type: ssh_proxy sshProxy: upstreamUsername: git sandboxPublicKeys: - ssh-ed25519 AAAA... sandbox-fake-key knownHosts: - github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
For Git-over-SSH, use the normal Git SSH URL and configure Git or OpenSSH in the sandbox to use the fake private key. The ctld network runtime terminates the sandbox SSH session, verifies the fake key, verifies the upstream host key against knownHosts, and re-originates the session with the stored upstream private key.
SSH does not carry a hostname in the protocol. When credentialRules[*].domains
or hostname-based knownHosts entries are used, ctld can infer a host from the
sandbox's recent DNS responses for that destination IP. The cache is per
sandbox; its lifetime uses the observed TTL bounded to 5 seconds–5 minutes.
Without a usable cached association, the connection is hostless and cannot
match a domain-scoped rule. Connecting by IP does not itself prove that a
connection will be hostless: an earlier DNS response can still associate that
IP with a hostname.
Update Runtime Policy#
/api/v1/sandboxes/{id}/network
go_, err = sandbox.UpdateNetworkPolicy(ctx, apispec.SandboxNetworkPolicy{ Mode: apispec.SandboxNetworkPolicyModeBlockAll, Egress: apispec.NewOptNetworkEgressPolicy(apispec.NetworkEgressPolicy{ TrafficRules: []apispec.TrafficRule{ { Name: apispec.NewOptString("allow-github-api"), Action: apispec.TrafficRuleActionAllow, Domains: []string{"api.github.com"}, Ports: []apispec.PortSpec{ { Port: 443, Protocol: apispec.NewOptString("tcp"), }, }, }, }, CredentialRules: []apispec.EgressCredentialRule{ { Name: apispec.NewOptString("github-auth"), CredentialRef: "gh-token", Protocol: apispec.NewOptEgressAuthProtocol(apispec.EgressAuthProtocolHTTPS), TlsMode: apispec.NewOptEgressTLSMode(apispec.EgressTLSModeTerminateReoriginate), Domains: []string{"api.github.com"}, Ports: []apispec.PortSpec{ { Port: 443, Protocol: apispec.NewOptString("tcp"), }, }, FailurePolicy: apispec.NewOptEgressAuthFailurePolicy(apispec.EgressAuthFailurePolicyFailClosed), }, }, }), CredentialBindings: []apispec.CredentialBinding{ { Ref: "gh-token", SourceRef: "github-source", Projection: apispec.ProjectionSpec{ Type: apispec.CredentialProjectionTypeHTTPHeaders, HttpHeaders: apispec.NewOptHTTPHeadersProjection(apispec.HTTPHeadersProjection{ Headers: []apispec.ProjectedHeader{ { Name: "Authorization", ValueTemplate: "Bearer {{ .token }}", }, }, }), }, }, }, }) if err != nil { log.Fatal(err) }
For supported protocols, auth material is resolved and injected by the egress path. The sandbox process does not need direct access to the raw credential source contents.