# API authentication

> Service-token auth (SANDBOXD_API_TOKENS, Authorization: Bearer), SANDBOXD_API_AUTH_DISABLED rollback, SIGHUP env reload, loopback exemptions, and LAN exposure of SANDBOXED_API_BIND.

- Repository: tastyeffectco/sandboxes
- GitHub: https://github.com/tastyeffectco/sandboxes
- Human docs: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0
- Complete Markdown: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0/llms-full.txt

## Source Files

- `control-plane/internal/auth/config.go`
- `control-plane/internal/auth/middleware.go`
- `control-plane/internal/auth/token.go`
- `.env.example`
- `control-plane/cmd/sandboxd/main.go`
- `README.md`

---

---
title: "API authentication"
description: "Service-token auth (SANDBOXD_API_TOKENS, Authorization: Bearer), SANDBOXD_API_AUTH_DISABLED rollback, SIGHUP env reload, loopback exemptions, and LAN exposure of SANDBOXED_API_BIND."
---

The `sandboxd` control plane gates programmatic API access with named service bearer tokens (`SANDBOXD_API_TOKENS`), an emergency open-API rollback (`SANDBOXD_API_AUTH_DISABLED`), and a loopback operator path that never checks a token. External callers (Traefik-routed or any non-loopback TCP peer) must present `Authorization: Bearer <secret>` when auth is enabled; on-host scripts hitting `SANDBOXED_API_BIND` on loopback skip the gate.

## Trust model

Two caller classes are wired in `control-plane/internal/auth`:

| Trust class | How requests arrive | Token required |
|---|---|---|
| **Operator** | Direct TCP to the published API bind with a loopback `RemoteAddr` and **no** `X-Forwarded-For` | No |
| **Service (upstream backend)** | Traefik edge (`api.preview.*`), LAN bind (`0.0.0.0:9090`), or any forwarded client | Yes, when auth is enabled |

End users do not call `sandboxd` directly. Browser preview traffic uses the wake catch-all and optional preview JWTs (`/forward-auth`, `/preview-auth`) — a separate model documented on the private previews page.

```mermaid
sequenceDiagram
  participant Op as On-host operator
  participant Svc as Upstream service
  participant Traefik as Traefik
  participant Auth as auth.Middleware
  participant API as api.Server

  Op->>API: GET /sandboxes (loopback, no XFF)
  Note over Op,API: Actor: operator / loopback

  Svc->>Traefik: POST /sandbox + Bearer
  Traefik->>Auth: forwarded (X-Forwarded-For set)
  Auth->>Auth: MatchToken constant-time
  Auth->>API: Actor: service / token name
  API-->>Svc: 200 JSON

  Svc->>Traefik: POST /sandbox (no Bearer)
  Traefik->>Auth: forwarded
  Auth-->>Svc: 401 {"error":"unauthorized"}
```

## Configuration

Auth keys live in `.env` (copied from `.env.example` on install) and are passed into the `sandboxd` container via `docker-compose.yml`.

| Variable | Default (OSS compose) | Effect |
|---|---|---|
| `SANDBOXD_API_AUTH_DISABLED` | `true` | When **true** (or any value other than `false` / `0` / `no` / empty), all external API routes run without a bearer check. Set to `false` for production. |
| `SANDBOXD_API_TOKENS` | *(empty)* | Comma-separated `name=secret` pairs. The **name** is audit metadata; the **secret** is the bearer value. |
| `SANDBOXED_API_BIND` | `127.0.0.1:9090` | Host port published to container `:9000`. Controls who can reach the API on the host network. |
| `SANDBOXD_ENV_FILE` | `/etc/sandboxed/sandboxd.env` | File re-read on `SIGHUP` for token rotation (systemd-style deployments). |

<ParamField body="SANDBOXD_API_TOKENS" type="string">
Comma-separated list of `name=secret` entries. Whitespace around names and secrets is trimmed. Entries without `=` are skipped. Example: `backend=super-secret,ci-runner=other-secret`.
</ParamField>

<ParamField body="SANDBOXD_API_AUTH_DISABLED" type="boolean string">
Parsed case-insensitively. Values `false`, `0`, `no`, or empty → auth **enforced** on external paths. Any other value (including `true`, `1`, `yes`) → auth **disabled** (rollback / local dev).
</ParamField>

<Warning>
With `SANDBOXD_API_AUTH_DISABLED=false` and an empty `SANDBOXD_API_TOKENS`, every external request returns **401**; loopback still works. `sandboxd` logs a startup warning in that configuration.
</Warning>

## Enable service-token auth

<Steps>
<Step title="Set tokens and turn auth on">

Edit `.env`:

```bash
SANDBOXD_API_AUTH_DISABLED=false
SANDBOXD_API_TOKENS=my-backend=replace-with-long-random-secret
```

</Step>

<Step title="Recreate the control plane">

```bash
docker compose up -d sandboxd
```

Compose reads `.env` at container start. Startup logs include `api_tokens`, `preview_secrets`, and `auth_disabled` counts.

</Step>

<Step title="Verify from loopback (no token)">

```bash
curl -s "http://127.0.0.1:9090/healthz"
```

Loopback health checks succeed without a bearer.

</Step>

<Step title="Verify external path requires Bearer">

From a non-loopback client (or simulate Traefik by adding `X-Forwarded-For`):

<RequestExample>
```bash
curl -s -o /dev/null -w "%{http_code}" \
  -H "X-Forwarded-For: 203.0.113.1" \
  http://127.0.0.1:9090/sandboxes
```
</RequestExample>

<ResponseExample>
```
401
```
</ResponseExample>

With a valid token:

<RequestExample>
```bash
curl -s -H "Authorization: Bearer replace-with-long-random-secret" \
  -H "X-Forwarded-For: 203.0.113.1" \
  http://127.0.0.1:9090/sandboxes
```
</RequestExample>

</Step>
</Steps>

## Bearer token format

Send the secret in the standard header:

```
Authorization: Bearer <secret>
```

Matching is case-insensitive on the `Bearer ` prefix. The middleware compares the presented string against every configured token using `crypto/subtle.ConstantTimeCompare` and does not short-circuit on first match, so comparison timing does not leak which token index matched.

On success, the request context carries `Actor{Kind: "service", Name: <token-name>, IP: <client-ip>}`. Handlers use that for audit rows (`auditAction` in the API package).

On failure:

<ResponseExample>
```json
{"error":"unauthorized"}
```
</ResponseExample>

HTTP status **401**. Failed attempts are audit-logged as `auth.token_invalid` with the client IP (no token value is stored).

## Loopback operator path

A request is treated as **operator** (no bearer) when:

1. `X-Forwarded-For` is **absent**, and  
2. `RemoteAddr` parses to a loopback IP (`127.0.0.1`, `::1`, etc.).

<Info>
Traefik always sets `X-Forwarded-For`, so traffic through the edge never qualifies as loopback even if it hits the same host port. That prevents forwarded clients from bypassing service-token auth.
</Info>

Typical local workflows (`curl http://127.0.0.1:9090/...` from the host, `install.sh` examples, `AGENTS.md` runbooks) use this path and need no token while auth is enabled.

## Routes outside service-token auth

These paths are reachable on the **external** path without a bearer (they still require passing the middleware, which assigns `Actor{Kind: "system"}`):

| Path | Why exempt |
|---|---|
| `GET /healthz` | Liveness |
| `GET /readyz` | Readiness |
| `GET /preview-auth` | Sets preview cookie; validates preview JWTs internally |
| `GET /forward-auth` | Traefik forwardAuth for private sandboxes |
| `GET /llm.txt` | Public integrator contract |

`GET /metrics` is the inverse: **loopback only**. Non-loopback callers receive **404 Not Found** (not 401), so Prometheus is not exposed on the Traefik edge.

All other API routes (`/sandbox`, `/v1/sandboxes`, legacy handlers, etc.) require a valid bearer when auth is enabled.

The auth middleware wraps **only** the API mux. Preview wake traffic (`hostDispatch` → catch-all) is not bearer-gated; private sandboxes enforce preview tokens inside the wake handler instead.

## Emergency rollback: `SANDBOXD_API_AUTH_DISABLED`

Set `SANDBOXD_API_AUTH_DISABLED=true` (or `1`, `yes`, or any value other than the “off” literals) to disable bearer checks on external paths. External requests run with `Actor{Name: "auth-disabled"}`.

<Warning>
Use only for break-glass recovery. Every external caller can invoke the full control-plane API while rollback is active.
</Warning>

## Token rotation via SIGHUP

On startup, `sandboxd` loads auth config from the process environment (`auth.ParseConfig(os.Getenv)`). After startup, environment variables in the container are **stale** for rotation purposes.

Sending **SIGHUP** reloads auth atomically from `SANDBOXD_ENV_FILE` (default `/etc/sandboxed/sandboxd.env`):

1. Parse the file (`KEY=value`, `#` comments, optional quotes stripped).  
2. Build a new `auth.Config` via `ParseConfig`.  
3. `authMw.Reload(nc)` swaps the config without restarting the listener.

If the file cannot be read, the previous config is kept and an error is logged.

<Tip>
Docker Compose deployments usually rotate by editing `.env` and running `docker compose up -d sandboxd`. SIGHUP reload is aimed at systemd installs that mount a persistent `sandboxd.env` and signal the daemon (`kill -HUP <pid>`).
</Tip>

## `SANDBOXED_API_BIND` and LAN exposure

Compose publishes the API as:

```
"${SANDBOXED_API_BIND:-127.0.0.1:9090}:9000"
```

| Bind | Exposure |
|---|---|
| `127.0.0.1:9090` (default) | Only processes on the host can open the API port. Loopback `curl` works; remote LAN clients cannot connect. |
| `0.0.0.0:9090` | API listens on all host interfaces — any machine on the LAN can reach `:9090`. |

Inside the stack, Traefik also reaches `http://sandboxd:9000` on the internal network (`traefik/dynamic/api.yml` optional router `api.preview.*`). That path is **external** from the middleware’s perspective (forwarded client IP), so bearer auth applies when enabled.

<Check>
Before binding `0.0.0.0`, set `SANDBOXD_API_AUTH_DISABLED=false`, configure strong `SANDBOXD_API_TOKENS`, and restrict host firewall access to trusted backends only.
</Check>

## Traefik API edge

When `traefik/dynamic/api.yml` is present, the control plane is also reachable at `http://api.preview.<PREVIEW_DOMAIN>` (for example `http://api.preview.localhost`). The same service-token rules apply: enable auth and pass `Authorization: Bearer` from your upstream service. Health, preview-auth, forward-auth, and `llm.txt` stay open as listed above.

## Audit and actor kinds

| `Actor.Kind` | When set |
|---|---|
| `operator` | Loopback, no token |
| `service` | Valid bearer, or auth-disabled rollback |
| `system` | Exempt path |
| `unknown` | Default when context has no actor |

Privileged API actions record `ActorKind`, `ActorName`, and `ActorIP` in the SQLite audit log.

## Related pages

<CardGroup>
<Card title="Configuration reference" href="/configuration-reference">
All compose-backed env keys, including preview domain, idle tuning, and auth-related variables.
</Card>
<Card title="Production deployment" href="/production-deployment">
Wildcard DNS, TLS, and the checklist to enable API auth before exposing the host.
</Card>
<Card title="Private previews" href="/private-previews">
Preview JWTs, `/forward-auth`, and `/preview-auth` — separate from service bearer tokens.
</Card>
<Card title="v1 API reference" href="/v1-api-reference">
Public `/v1/sandboxes` shapes and error envelope once auth succeeds.
</Card>
<Card title="Legacy API reference" href="/legacy-api-reference">
Internal `/sandbox*` routes and health endpoints.
</Card>
</CardGroup>
