# Private previews

> visibility=private sandboxes, Traefik forwardAuth to /forward-auth, preview tokens (SANDBOXD_PREVIEW_TOKEN_SECRETS), /preview-auth redirect flow, and deny modes.

- 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

- `traefik/dynamic/auth.yml`
- `control-plane/internal/api/forward_auth.go`
- `control-plane/internal/api/preview_auth.go`
- `control-plane/internal/auth/preview_token.go`
- `control-plane/internal/traefik/traefik.go`
- `control-plane/internal/api/handlers.go`

---

---
title: "Private previews"
description: "visibility=private sandboxes, Traefik forwardAuth to /forward-auth, preview tokens (SANDBOXD_PREVIEW_TOKEN_SECRETS), /preview-auth redirect flow, and deny modes."
---

Private previews gate browser traffic to sandboxes created with `visibility: "private"`. Traefik attaches the file-provider middleware `sandbox-preview-auth@file`, which calls `sandboxd` at `GET /forward-auth` on every preview request. Access requires a valid `sandbox_preview` cookie minted by `GET /preview-auth` from an upstream-signed HS256 JWT. Public sandboxes (the default) never attach this middleware; knowing the preview URL is sufficient for them.

## Public vs private

| `visibility` | Default | Preview gate | Traefik middleware |
|---|---|---|---|
| `public` | yes | URL only | none |
| `private` | no | forward-auth + preview cookie | `sandbox-preview-auth@file` on each port router |

Set visibility at create time on `POST /sandbox` or `POST /v1/sandboxes`. Omitting the field defaults to `public`. Any other value returns `400` with `visibility must be 'public' or 'private'`.

<RequestExample>

```bash
curl -s -X POST http://127.0.0.1:9090/sandbox \
  -H 'Content-Type: application/json' \
  -d '{
    "ports": [3000],
    "visibility": "private",
    "external": { "user_id": "user-alice", "project_id": "proj-1" }
  }'
```

</RequestExample>

The v1 create path forwards the same field:

```json
{ "project": { "id": "proj-1", "user_id": "user-alice" }, "visibility": "private" }
```

Private auth ties viewers to `workspace_owner.external_user_id` when that row exists (inserted on create from `external.user_id`). The OSS quickstart defaults `external.user_id` to `"local"` when omitted.

## Traefik wiring

`traefik/dynamic/auth.yml` defines the forward-auth middleware:

```yaml
sandbox-preview-auth:
  forwardAuth:
    address: "http://sandboxd:9000/forward-auth"
    trustForwardHeader: true
    authResponseHeaders:
      - X-Sandbox-External-User-Id
```

When `visibility == "private"`, per-port Traefik labels add:

```
traefik.http.routers.s-<id>-<port>.middlewares=sandbox-preview-auth@file
```

Public sandboxes must not carry that label (enforced in tests). The middleware is inert for the default public workflow but must be present in the dynamic config before any private sandbox is created.

<Note>

`GET /forward-auth` and `GET /preview-auth` are exempt from API bearer-token auth. They validate their own JWTs. `/healthz` and `/readyz` are also exempt.

</Note>

## End-to-end browser flow

```mermaid
sequenceDiagram
  participant Browser
  participant Traefik
  participant sandboxd
  participant Upstream as Upstream app

  Browser->>Traefik: GET https://s-{id}-{port}.preview.{domain}/
  Traefik->>sandboxd: GET /forward-auth (X-Forwarded-Host, X-Forwarded-Uri)
  alt no valid sandbox_preview cookie
    sandboxd-->>Traefik: 302 Location: SANDBOXD_AUTH_REDIRECT_URL
    Traefik-->>Browser: redirect to upstream sign-in
    Upstream->>Browser: redirect with ?token=...&return=...
    Browser->>sandboxd: GET /preview-auth?token=...&return=...
    sandboxd->>sandboxd: Verify HS256 JWS, Set-Cookie sandbox_preview
    sandboxd-->>Browser: 302 to allowlisted return URL
    Browser->>Traefik: retry preview (cookie present)
  end
  sandboxd-->>Traefik: 200 + X-Sandbox-External-User-Id
  Traefik->>Browser: proxied app response
```

<Steps>

<Step title="Configure secrets and redirect URL">

Set `SANDBOXD_PREVIEW_TOKEN_SECRETS` (comma-separated `kid=secret` pairs) and `SANDBOXD_AUTH_REDIRECT_URL` on `sandboxd`. The redirect template uses `{sandbox_id}` and `{return}` placeholders (URL-encoded on substitution). Reload via SIGHUP after editing `SANDBOXD_ENV_FILE` (default `/etc/sandboxed/sandboxd.env`).

</Step>

<Step title="Create a private sandbox">

`POST /sandbox` or `POST /v1/sandboxes` with `"visibility": "private"` and a stable `external.user_id` for the owner.

</Step>

<Step title="Mint a preview token upstream">

Your backend signs an HS256 compact JWS using the shared secret for the chosen `kid`. Claims must include `aud: "sandbox-preview"`, `sandbox_id`, `sub` (viewer id), and a future `exp`.

</Step>

<Step title="Send the user through /preview-auth">

Redirect the browser to your auth UI, then back to sandboxd:

`GET /preview-auth?token=<jws>&return=<https://s-{id}-{port}.preview.{domain}/...>`

On success, sandboxd sets `sandbox_preview` on domain `.preview.{PREVIEW_DOMAIN}` (`HttpOnly`, `Secure`, `SameSite=Lax`) and 302s to `return`.

</Step>

<Step title="Open the preview URL">

Subsequent requests hit `/forward-auth` with the cookie; Traefik forwards `X-Sandbox-External-User-Id` from the validated `sub` claim.

</Step>

</Steps>

## Preview token format

Upstream mints a standard compact JWS: `header.payload.signature` (base64url, no padding).

| JWS header | Required value |
|---|---|
| `alg` | `HS256` |
| `kid` | key id present in `SANDBOXD_PREVIEW_TOKEN_SECRETS` |

| Claim | Role |
|---|---|
| `aud` | must be `sandbox-preview` (`PreviewAudience`) |
| `sandbox_id` | must match the sandbox being viewed |
| `sub` | viewer identity; must match `workspace_owner.external_user_id` when that row exists |
| `exp` | Unix seconds; cookie `Max-Age` derived from remaining lifetime |
| `iss`, `iat` | stored; not used for authorization beyond signature/exp/aud |

Example claims shape (from tests):

```json
{
  "iss": "upstream-prod",
  "iat": 1710000000,
  "exp": 1710003600,
  "aud": "sandbox-preview",
  "sub": "user-alice",
  "sandbox_id": "01HXANYZ000000000000000000"
}
```

<ParamField body="SANDBOXD_PREVIEW_TOKEN_SECRETS" type="string">

Comma-separated `kid=secret` list (whitespace trimmed). Maps JWS `kid` to HMAC secret. Supports rotation by listing multiple kids. Parsed in `auth.ParseConfig`; reloadable on SIGHUP.

</ParamField>

<Warning>

The shipped `docker-compose.yml` does not pass `SANDBOXD_PREVIEW_TOKEN_SECRETS` or `SANDBOXD_AUTH_REDIRECT_URL` by default. Add them to `.env` and extend the `sandboxd` service `environment` block (or `SANDBOXD_ENV_FILE`) before private previews work outside a custom deployment.

</Warning>

## GET /forward-auth

Traefik invokes this on every request to a private sandbox router.

**Inputs (headers):**

| Header | Use |
|---|---|
| `X-Forwarded-Host` | Parsed as `s-<id>-<port>.preview.<PREVIEW_DOMAIN>` |
| `X-Forwarded-Uri` | Rebuilt into `return` on deny |
| `Cookie: sandbox_preview` | HS256 JWS from upstream |

**Outcomes:**

| HTTP | Meaning |
|---|---|
| `200` | Allow; sets `X-Sandbox-External-User-Id` to `claims.Sub` |
| `302` | Deny (default mode): redirect to `SANDBOXD_AUTH_REDIRECT_URL` |
| `401` | Deny (`meta-refresh` mode): `Location` + HTML meta refresh to same target |
| `401` | Unparseable `X-Forwarded-Host` (no redirect) |
| `404` | Sandbox id not found (HTML error page) |
| `500` | Store error |

If the sandbox row exists but `visibility != "private"`, the handler returns `200` as a safety net (public sandboxes should not route through forward-auth).

### Denial reasons

`auth.CheckPreviewAccess` returns a machine-readable reason audited as `preview.access_denied`:

| Reason | Cause |
|---|---|
| `no_cookie` | Missing `sandbox_preview` |
| `bad_signature` | Malformed JWS, wrong alg/kid, bad sig, or wrong `aud` |
| `expired` | `exp` in the past |
| `wrong_sandbox` | `sandbox_id` claim ≠ host-derived id |
| `wrong_user` | `sub` ≠ `workspace_owner.external_user_id` (when owner row exists) |

If `workspace_owner` is absent (legacy private sandbox), signature and sandbox-id checks still apply; the owner check is skipped.

## GET /preview-auth

Landing endpoint after upstream authentication.

<ParamField query="token" type="string" required>

Upstream-signed HS256 JWS (same format as the cookie value).

</ParamField>

<ParamField query="return" type="string" required>

HTTPS URL to redirect after cookie issuance. Allowlisted patterns only:

- `https://s-<id>-<port>.preview.<domain>(/…)?` — `id` must match JWT `sandbox_id`
- `https://api.preview.<domain>(/…)?`

Any other `return` → `400` `return url not allowed for this sandbox`.

</ParamField>

On invalid token, the handler does not expose validation details; it audits `preview_auth_token_invalid` and 302s to `SANDBOXD_AUTH_REDIRECT_URL` (or `401` if redirect URL unset). On success it audits `preview.session_issued` and sets the cookie on `.preview.{PREVIEW_DOMAIN}`.

## Redirect URL template

<ParamField body="SANDBOXD_AUTH_REDIRECT_URL" type="string">

Template for sending unauthenticated users to your sign-in UI. Substitutions: `{sandbox_id}` → query-escaped id, `{return}` → query-escaped full preview URL.

Example from tests:

```
https://app/x?sandbox_id={sandbox_id}&return={return}
```

→ `https://app/x?sandbox_id=sb1&return=https%3A%2F%2Fs-sb1-3000.preview.example.com%2F`

If empty, deny paths return plain `401` without redirect (forward-auth and wake).

</ParamField>

## Deny modes

<ParamField body="SANDBOXD_FORWARD_AUTH_DENY_MODE" type="string">

Default `redirect`. Only other supported value: `meta-refresh`.

</ParamField>

| Mode | forward-auth deny | wake deny (private, HTML) |
|---|---|---|
| `redirect` (default) | `302` to auth URL | `302` to auth URL |
| `meta-refresh` | `401` + `Location` + HTML `<meta http-equiv="refresh">` | same pattern |

Use `meta-refresh` when Traefik builds do not pass `3xx` from the auth service through forward-auth cleanly. Both modes set `Location` to the same `BuildRedirectURL` target.

## Stopped private sandboxes and wake

The catch-all wake path runs the same `CheckPreviewAccess` before `docker start` when:

- the request is HTML (browser preview hit), and
- `visibility == "private"`, and
- the actor is not `service` or `operator` (API bearer / loopback).

Service- and operator-authenticated callers skip the cookie gate so `POST /v1/sandboxes/{id}/tasks` can wake a stopped private sandbox without a browser cookie. End-user preview URLs still require the cookie flow.

Metrics: `sandboxd_preview_access_total{result="allowed|denied"}`, `sandboxd_forward_auth_duration_seconds`, wake counter `auth_denied` when gated.

## Operations checklist

<Check>

- `traefik/dynamic/auth.yml` mounted (compose mounts `./traefik/dynamic`)
- `SANDBOXD_PREVIEW_TOKEN_SECRETS` configured and matches upstream signing `kid`
- `SANDBOXD_AUTH_REDIRECT_URL` points at your token-minting UI
- `external.user_id` on create matches the `sub` you put in preview tokens
- For TLS previews, use `https://` in `return` URLs and cookie `Secure: true` (always set by sandboxd)
- Optional: `SANDBOXD_FORWARD_AUTH_DENY_MODE=meta-refresh` if redirects fail through Traefik

</Check>

<Tip>

Prometheus label `sandboxd_preview_access_total` and histogram `sandboxd_forward_auth_duration_seconds` instrument the forward-auth hot path (phase-9 capacity target: p95 under 50 ms).

</Tip>

## Related pages

<CardGroup>

<Card title="Preview routing" href="/preview-routing">
Host rules, router priority 100, and how Traefik discovers sandbox routers.
</Card>

<Card title="Wake, idle, and pressure" href="/wake-idle-reapers">
Catch-all wake path, private wake gating, and warming-page behavior.
</Card>

<Card title="API authentication" href="/api-authentication">
Service tokens vs preview endpoints exempt from bearer auth.
</Card>

<Card title="Configuration reference" href="/configuration-reference">
Env keys for preview domain, TLS, auth, and forward-auth deny mode.
</Card>

<Card title="Production deployment" href="/production-deployment">
Wildcard DNS, TLS, and hardening when exposing previews on the internet.
</Card>

</CardGroup>
