Authentication
Which credential to send, what it is allowed to reach, and how requests are scoped to a project.
Authentication is deny-by-default: a request without a recognised credential gets
401, and no route under /sandbox/ serves data anonymously.
Issuing credentials is a separate job with a separate privilege — see Provisioning credentials.
export SANDBOX_GATEWAY_URL="${SANDBOX_GATEWAY_URL:-http://localhost:8780}"Which credential you have
| Credential | Header | Scope | Issued to |
|---|---|---|---|
| Team token | Authorization: Bearer … | One tenant:project | External consumers |
| Platform API key | X-Api-Key or Authorization: Bearer … | Any project | First-party callers |
| Keycloak JWT | Authorization: Bearer … | Per client | Service-to-service |
| Admin key | X-Sandbox-Admin-Key | Provisioning only | Operators |
They are not interchangeable. The distinction that matters: a team token is confined to its own namespace, which is why it is the credential to hand to a consumer. A platform API key carries no namespace and may act for any project, which is why it stays internal — never issue one externally.
# Team token
curl -H "Authorization: Bearer $TEAM_TOKEN" "$SANDBOX_GATEWAY_URL/sandbox/v1/catalog"
# Platform API key
curl -H "X-Api-Key: $SANDBOX_API_KEY" "$SANDBOX_GATEWAY_URL/sandbox/v1/catalog"For Keycloak, the gateway must be configured with the matching client settings, and the token needs an allowed audience and client id.
Project scoping
X-Sandbox-Tenant-Id and X-Sandbox-Project-Id select the project a request is
billed and rate-limited against. They are validated against your credential rather
than taken at face value:
- A team token may only name its own namespace. Naming another returns
403. Omit the headers and your own namespace is used. - A platform API key has no namespace of its own, so its headers are accepted as given.
Who can see a job
Authenticating gets you in; it does not get you someone else's run. A job is visible only to a request whose resolved tenancy equals the tenancy it was submitted under. That covers every job-keyed route — status, events, cancel, selective retry-errored-trials, artifact listing, archive download, verifier stdout.
A job you may not see returns 404, identically to a job that does not exist. A
403 would confirm the id is real, which is the thing being protected, so the two
are deliberately indistinguishable.
| Credential | Can read a job belonging to acme:proj1 |
|---|---|
Team token for acme:proj1 | Yes |
| Team token for any other namespace | No — 404 |
Platform key sending X-Sandbox-Tenant-Id: acme and X-Sandbox-Project-Id: proj1 | Yes |
| Platform key sending no tenancy headers | No — 404 |
So a platform key keeps its privilege of acting for any tenant, but it has to say which one — it cannot reach a run by knowing the job id alone. Internal tooling that polls across tenants must send the tenancy headers for the job it is asking about.
Jobs submitted with no tenancy at all are visible only to requests that also send none, which is what keeps single-tenant deployments and the local stack working.
The last row is a change in behaviour, not just a rule. A platform key used to
read any job from its id alone, so a dashboard or script that polls bare job ids will
start seeing 404 for jobs that were submitted with a tenancy — including its own,
if it stamps tenancy on submit but not on poll. The fix is to send the same
X-Sandbox-Tenant-Id and X-Sandbox-Project-Id on the poll as on the submit.
Because the denial is deliberately identical to "no such job", a sudden run of 404s
on ids you believe exist means missing tenancy headers rather than missing jobs.
You never send a model provider key
Your credential authenticates you to the platform. It is not the key the agent uses to call Anthropic, OpenAI or Google, and the platform will not read one out of your payload. The worker leases one per job, scoped to your tenant, and passes it to the agent.
Three consequences worth knowing:
- Spend is attributable to your tenant, because the lease is per tenant rather than one shared key.
- Revocation is a credential-plane operation, not a redeploy.
- A model with no key provisioned is not rejected up front. The job runs and the
agent fails when it tries to call the provider — only the agent knows whether it
needs a key at all, and some (an oracle agent, an air-gapped one) must not have
one. Look for
model_credential_absentin the job logs.
Which providers map to which models, and how to provision the keys: Provisioning credentials.
What is not authenticated
/healthz,/readyz,/metrics— liveness and scrape endpoints/docs— this documentation site/openapi/sandbox-platform.yaml— the API specification
Everything under /sandbox/ needs a credential, including /sandbox/healthz. A
401 from that path is the gate working, not an outage; use /healthz for
unauthenticated health checks.
Local development
The local stack ships a placeholder key (dev-local-key) and static team tokens
(for example dev-token-a → acme:proj-a). Static tokens bypass the credential
database and never expire, so they are ignored unless the stack explicitly enables
them, and they are refused outright on deployed environments.
Next
- Provisioning credentials
- Core concepts — tenancy in context
- Errors and limits