# Server security

> Tokens, TLS and proxies, browser origins, limits and the audit log of zidane-server, and what a client token lets its holder do.

A client token can run commands on the server, as the server's user, and read whatever that user can read. An `admin` token runs any command directly: terminals, `!` commands and `sessions.workspaceExec`. A `chat` token runs any command through the agent, because it approves the agent's calls and sets a session's rules. Treat a token like an SSH key. Run the server under its own user, with only the folders it needs in its workspace roots.

## Threat model

| Threat | What stops it |
| - | - |
| Someone on the network reads a token or a transcript | TLS off loopback. The server refuses to listen in the clear, and clients refuse `ws://` to anything but loopback |
| A stolen or lost device | One token per device. Revoking it drops its connections within 2 seconds |
| A web page uses a token it got hold of | Browser upgrades need their `Origin` on the allowlist, empty by default |
| A peer forges `X-Forwarded-Proto` or `X-Forwarded-For` | `--trusted-proxies`. The server believes only those peers and answers others with a 403 |
| A reconnect storm or a flood | Upgrade rate per address, connection caps per token and per address, request rate per connection |
| A token read from disk | The server stores only SHA-256 hashes and compares them in constant time. Logs name tokens by id and name |
| A client token holder | What its [scope](#scopes) reaches. Both `admin` and `chat` tokens can run any command: `admin` directly, `chat` through the agent. Workspace confinement guards against mistakes, not against a client that wants to read a file |

## Tokens

```bash
zidane-server token create --name laptop        # a zds_ token, printed once on stdout (admin)
zidane-server token create --name web --scope chat
zidane-server token create --name ci --scope machine
zidane-server token list                        # id, name, scope, created, last used, revoked, created by
zidane-server token revoke laptop               # by id (tok_…) or name
zidane-server token generate                    # a fresh token, stored nowhere
```

A token is `zds_` and 43 base64url characters (32 random bytes). The server keeps its SHA-256 in `server-tokens.json` (mode 0600) and its last use in `server-tokens.usage.json`.

A token works until you revoke it. To give it an end, add `--expires`: `zidane-server token create --name laptop --expires 90d`. The server refuses an expired token like a revoked one. It drops the token's connections, then stops the runs and terminals that the token started, as on revoke. The audit log gets `token.expire`, and `token.expire.stopped` with counts when the server stopped something. A token that another token creates (a machine token, the token of a browser from a pairing code) expires no later than that token.

The commands edit those files directly, so they work while the server runs. The server re-reads the file on change and every 2 seconds. When a token is revoked, the server drops its connections. Then it interrupts the runs of the sessions the token drove, cancels their background tasks and closes the terminals the token opened (audited as `token.revoke.stopped`).

| Scope | Opens | Created by |
| - | - | - |
| `admin` (default; `client` is the same) | A window: every method and signal | `token create`, the bootstrap variable, or an admin's `tokens.create` |
| `chat` | A window that reads and drives sessions, answers the agent's questions, plans and tool approvals, sets a session's rules, and clones repositories and creates folders in the workspace roots. It doesn't configure the server | `token create --scope chat`, or an admin's `tokens.create` |
| `machine` | A machine lending its folders and shell: only `ipc.hello`, `$/ping` and `machine.register` | `token create --scope machine`, or an admin's `tokens.createMachineToken` |

### Scopes

Each protocol method declares the lowest scope that may call it ([Protocol](https://zidane.si/docs/server-protocol.md)). `admin` covers configuring the server and a direct shell: credentials, settings, installs, MCP servers, machines, sync, tokens, updates, terminals, and running or writing files in a workspace outside the agent. `chat` covers chatting: sessions, runs, tool approvals, removing "Always allow" entries, a session's rules (looser ones included), integrations of any kind (`channel:` ones too), and orchestration workers with the `interactions` tool. It can also clone a repository into the workspace roots and create a folder there, unless the server turns that off. A call above the token's scope is refused (`-32015`, with the scope it needs) and audited (`forbidden`). The hello lists the methods the connection may call, so a client shows only those.

A scope limits what the client calls directly, not what the agent does. A `chat` token still runs the agent, and the agent's tools run commands as the [approval mode](https://zidane.si/docs/approvals.md) allows. With Full access, a `chat` token is close to a shell. With approvals on, the agent's risky calls wait for a client to answer, and a `chat` client can answer them.

The token file keeps an admin token's scope under its old name, `client`, so a server from before scopes still reads a file of admin and machine tokens. A `chat` token is a scope such a server doesn't know: after a downgrade it refuses the whole file (and keeps the tokens it had loaded, or none at a cold start). Revoke those tokens before you downgrade. A token or pairing code stored with `approve`, the scope between `chat` and `admin` in older releases, reads as `chat`.

A browser can't read why the server refused a WebSocket upgrade. After a failed connect, the browser client (`zidane/ipc/browser`) asks `GET /zidane/auth` with the same token: it answers `{"ok":true,"scope":…}`, or the refusal an upgrade would get (`401`, or `403` with `X-Zidane-Refusal`). It goes through the same origin check, rate limits and audit as an upgrade, and sends CORS headers only to an allowed origin.

An admin client manages tokens with `tokens.list`, `tokens.create` (any scope; the new token is bound to the one that created it, so revoking that one revokes it too) and `tokens.revoke`. Any client revokes its own token with `tokens.revokeSelf`: the web app's sign out does.

A connected client mints machine tokens for its "share this computer" step (`tokens.createMachineToken`). One client token can hold at most 16 live machine tokens. The audit log records which token asked, and each refusal. `token list` shows that token under created by. Revoking a client token revokes the live machine tokens it minted in the same write, and `token revoke` names them.

To rotate, create a token for the device, switch the device to it and revoke the old one. If the server cannot parse the token file, it keeps the last tokens it read and says so on stderr. It never treats a bad file as "no tokens". `create` and `revoke` refuse to write over the file.

### Bootstrap token

For a platform with no shell before the first deploy, put a token from `token generate` in `ZIDANE_SERVER_BOOTSTRAP_TOKEN` (or its path in `ZIDANE_SERVER_BOOTSTRAP_TOKEN_FILE`). At start the server stores its hash as the client token `bootstrap`. A new value replaces it. A revoked value is ignored. The server never prints the token. It removes both variables from its environment, so the commands of the agents do not inherit them.

The inline value stays visible in `docker inspect`, `docker exec` shells and `/proc/1/environ`. The file variable avoids that. Revoke `bootstrap` once your devices have their own tokens. If you only delete the variable, the server keeps the stored token.

### If a token leaks

A client token is shell access as the server's user. Treat a leak like a leaked SSH key:

1. Revoke it. Its connections, runs and terminals stop, and the machine tokens it minted are revoked with it.
2. Restart the server. Processes its commands left in the background (`nohup`, a daemon) end only with the server.
3. Look for what it changed. `grep <token id> server-audit.jsonl` lists its connections and installs: remove the skills, extensions and MCP servers you don't recognize. Check the server user's dotfiles (`~/.bashrc`, `~/.profile`, `~/.ssh/authorized_keys`, `~/.gitconfig`) and your workspaces' git hooks. In the image, `HOME` is `/data`, so they persist on the volume.
4. Rotate what it could read: the provider keys and any deploy key in `~/.ssh`. The other tokens are stored hashed, and a hash of a random 256-bit token can't be turned back into it.

## TLS and proxies

| Mode | Set | Clients use |
| - | - | - |
| Loopback only | Nothing (the default `--host 127.0.0.1`) | `ws://127.0.0.1:4280`, or an SSH tunnel |
| TLS in the server | `--tls-cert` and `--tls-key` | `wss://host:4280` |
| A TLS-terminating proxy | `--behind-proxy`, `--trusted-proxies` | `wss://` through the proxy |

On any other interface, the server refuses to start without one of those. TLS and `--behind-proxy` together is a boot error, and so is `--trusted-proxies` without `--behind-proxy`.

Behind a proxy, every upgrade must carry `X-Forwarded-Proto` with `https` for every hop. Anything else gets a 403, also from loopback. Local tools use the unix socket instead.

With `--trusted-proxies` (addresses, CIDRs or `private`), the server believes only those peers and refuses others with a 403. `private` stands for `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1/128`, `fc00::/7` and `fe80::/10`.

The server takes the client address for the audit log and the per-address limits from `X-Forwarded-For`. It reads the header from the right. It takes the entry that your proxy added and skips the trusted proxies of a chain.

> **Warning.** `private` trusts every private address. So publish the port on `127.0.0.1` only, or not at all. Rootless Docker and Docker Desktop publish ports through a userland proxy, so every client arrives from a private address. In those setups, set the exact address of the proxy. Plain Docker does the same over IPv6: a port published on every interface (`-p 4280:4280`) also listens on the host's IPv6 address. Docker's default bridge has no IPv6, so `docker-proxy` relays those clients from the bridge gateway (`172.17.0.1`), which `private` trusts.

A peer that you trust by mistake can claim `https` and any client address. It can then avoid the per-address limits and fake the address in the audit log. It still needs a valid token.

The clients trust the public certificate authorities and the operating system's own store (the macOS keychain, the Linux CA bundle). For a private CA, an mkcert certificate or a self-signed one, trust it on each client's system, or list it in `NODE_EXTRA_CA_CERTS` when you start `zidane`. The clients never turn certificate checks off.

## Exposure

`exposure` says whether the internet can reach the port of the clients. It is `private` by default. With `public` (`--exposure public`, `ZIDANE_SERVER_EXPOSURE=public`), the server:

- Turns off remote installs and lending itself as a machine. If you turn either one on, the server does not start and names the setting.
- Refuses to start with a bootstrap token.
- Refuses other browser origins next to the web app.
- Ends pairing codes and machine join codes after 10 minutes at most.

At start, the server warns when the settings do not match the exposure.

## Browser origins

Browsers send `Origin` on a WebSocket upgrade. The TUI, the desktop app and the Node client send none. An origin outside `ZIDANE_SERVER_ALLOWED_ORIGINS` (comma-separated, like `https://app.example.com`) gets a 403. The list is empty by default, so no browser can connect.

With the [web app](https://zidane.si/docs/server-install.md#web-app) on (`--web`), the server's own origin is admitted too: the scheme it is reached by (`https` with TLS or behind a proxy), and the `Host` the browser sent (`X-Forwarded-Host` behind a proxy). On a loopback listener that host must be `localhost`, `127.x.x.x` or `[::1]`, so a page on another domain that resolves to 127.0.0.1 (DNS rebinding) is still refused.

## Pairing

A browser logs in with a one-time code instead of a pasted token. On the server's machine:

```bash
zidane-server pair --scope chat --ttl 10m --name laptop
```

It prints a code like `7KQM-3XFD` on stdout. Type it in the web app's pairing screen. The browser posts it to `POST /zidane/pair` and gets a normal device token of that scope, named `browser: laptop`. It keeps the token in its local storage and drops it when you sign out. The token shows in `zidane-server token list` and is revoked like any other.

| Rule | Value |
| - | - |
| Alphabet | 32 symbols: digits 2 to 9 and A to Z without I and O, shown as `XXXX-XXXX` |
| Lifetime | `--ttl`, 30s to 1h, default 10 minutes |
| Use | Once. A second try with the same code gets `used` |
| Wrong guesses | The first half names a code, the second is its secret. Five wrong secrets for one code burn it |
| Attempts | 10 per minute per client address, then 429 |
| Scope | `--scope chat` (default) or `admin`. A [machine join code](#machine-join-codes) has the scope `machine` |
| Storage | `server-pairing.json` in the data dir, mode 0600, holding each code's SHA-256, never the code |

An admin client can issue a code too, with [`tokens.pair`](https://zidane.si/docs/server-protocol.md#tokens) (`{ scope, ttlMs?, name? }` → `{ code, expiresAt }`). The token it mints is bound to the calling token: revoking that one revokes the browser's, and a code whose issuer was revoked before it was used is refused.

`POST /zidane/pair` takes JSON `{"code": "7KQM-3XFD", "name": "Firefox"}` (`name` is used when the operator gave none). It follows the [upgrade policy](#tls-and-proxies) and the origin rules above: a foreign browser origin, a plain-HTTP hop behind a proxy or an untrusted proxy gets a 403. An allowlisted origin gets CORS headers; the server's own origin needs none.

| Status | Body | Cause |
| - | - | - |
| 200 | `{"token": "zds_…", "id": "…", "scope": "chat", "serverName": "box"}` | Paired. `id` is the token's id, the one `tokens.list` and `tokens.revoke` use. `serverName` when the server has a `--name` |
| 400 | `{"error": "invalid"}` | Not JSON, or not a code's shape |
| 401 | `{"error": "invalid"}` | No such code, or a wrong secret |
| 410 | `{"error": "expired"}` | The right code, past its lifetime |
| 410 | `{"error": "used"}` | The right code, already used or burned |
| 429 | `{"error": "rate-limited"}` | Over 10 attempts a minute from this address |
| 403 | `{"error": "forbidden"}` | An origin, proxy or transport the policy refuses |

A browser app of your own pairs with `pairBrowser(baseUrl, code, name?)` from `zidane/ipc/browser`. It posts the code, returns `{ token, id, scope, serverName? }`, and throws a `ServerPairError` whose `reason` is the server's error above, `unavailable` (no pairing route: an older server, or one without auth) or `network` (no answer it understands). Pass `''` as `baseUrl` for the page's own origin; a `ws(s)://` URL is read as `http(s)://`.

```ts
import { createServerClient, pairBrowser, ServerPairError } from 'zidane/ipc/browser'

try {
  const { token } = await pairBrowser('https://box.example', code, 'Firefox on the laptop')
  localStorage.setItem('token', token)
  const client = createServerClient({ url: 'wss://box.example', token })
}
catch (error) {
  if (!(error instanceof ServerPairError))
    throw error
  showError(error.reason) // 'invalid' | 'expired' | 'used' | 'rate-limited' | 'forbidden' | 'unavailable' | 'network'
}
```

Issuing a code is audited as `pair.create`, a redemption as `paired` (token, scope, the code's first half, address), and a failed attempt as `auth.failure.pair` in the failures file.

### Machine join codes

A join code is a one-time code that adds a computer as a machine ([servers and machines](https://zidane.si/docs/servers-and-machines.md)). On the server, `zidane-server machine invite [--name <name>] [--ttl 10m]` prints one. An admin client gets one with `tokens.machineInvite` (`{ name?, ttlMs? }` → `{ code, expiresAt }`). The code follows the rules of the table above, with the scope `machine`.

`zidane machine setup --join <url>#<code>` posts the code to `POST /zidane/pair` with the name of the machine. The server mints a `machine` token and answers `{"token": "zds_…", "id": "…", "scope": "machine", "serverName": "box", "hostId": "…"}`. The machine pins `hostId` before it connects. The token takes the name from `--name`, else the name the machine sent. If a live token has that name, the server adds `-2`, `-3` and so on. If the name is not valid, the server answers 400 `{"error": "name"}` and the code stays unused.

Join codes count against the 16 live machine tokens of their creator. Codes from the command line have no creator, so they share one group with `token create --scope machine`. When that group is full, the server answers 503 and the code stays unused.

## Refused upgrades

The server answers before any JSON-RPC, with a status. A 403 also carries a reason code in `X-Zidane-Refusal`. Clients show a fixed text per code, never the body.

| Status | Body | `X-Zidane-Refusal` | Cause |
| - | - | - | - |
| 401 | `Unauthorized` | | No token, an unknown one or a revoked one (indistinguishable) |
| 403 | `Forbidden: origin not allowed` | `origin` | A browser origin outside the allowlist |
| 403 | `Forbidden: HTTPS required` | `insecure-transport` | Behind a proxy, a hop that was not `https` |
| 403 | `Forbidden: not a trusted proxy` | `untrusted-proxy` | A peer outside `--trusted-proxies` |
| 429 | `Too many requests` | | Over the upgrade rate for this client address |
| 429 | `Too many connections` | | Over the connection cap of the token or the address |
| 503 | `Shutting down` | | The server is stopping |

[Captured answers](https://zidane.si/docs/server-protocol.md#http-on-the-same-port).

## Limits

| Limit | Default | Past it |
| - | - | - |
| Message size (`ZIDANE_SERVER_MAX_MESSAGE_BYTES`) | 32 MiB | A bigger incoming frame closes the connection (1009). A bigger reply becomes a `ResponseTooLarge` error, and the connection stays open |
| Requests per connection (`_RATE_LIMIT`, `_RATE_BURST`) | 50 per second, burst 200 | A `RateLimited` error (-32012). The connection stays open |
| Upgrades per client address (`_UPGRADE_RATE_LIMIT`, `_UPGRADE_RATE_BURST`) | 5 per second, burst 30 | 429 |
| Connections per token (`_MAX_CONNECTIONS_PER_TOKEN`) | 16 | 429 |
| Connections per client address (`_MAX_CONNECTIONS_PER_ADDRESS`) | 32 | 429 |
| Pushed signals waiting for a slow client | 8 MiB | The server drops the client. The client reconnects and resumes |
| Heartbeat (`_HEARTBEAT_MS`) | A ping every 20 s | Two missed in a row drop the connection |
| Streamed `!` commands | 4 at once per connection, 1 MiB of output each | The command is stopped, its exit says `truncated` |

## Audit log

The server writes two JSON Lines files in the data dir, with mode 0600. It appends lines in order and moves a file to `<file>.1` past 5 MiB. Entries name tokens by id and name, never by value.

| File | Holds |
| - | - |
| `server-audit.jsonl` | Server start and stop, connects and disconnects (token, address, client version, reason), token create and revoke (minted tokens revoked along are marked `via: "cascade"`), what a revocation or an expiry stopped (`token.revoke.stopped`, `token.expire.stopped`), calls refused for their scope (`forbidden`: method, scope needed, scope granted), pairing codes issued (`pair.create`) and redeemed (`paired`), settings sync, and every install, update or removal from a client |
| `server-audit.failures.jsonl` | Refused upgrades. The first one per address and minute is written in full. The rest of that minute becomes one `auth.failure.summary`. Failed pairing attempts (`auth.failure.pair`) |

A session from the [protocol capture](https://zidane.si/docs/server-protocol.md):

```text title="server-audit.jsonl"
{"ts":"2026-10-14T09:09:33.813Z","event":"server.start","url":"ws://127.0.0.1:64234"}
{"ts":"2026-10-14T09:09:33.840Z","event":"connect","transport":"ws","tokenId":"tok_8adfcb0e9b854773","tokenName":"docs","remoteAddress":"127.0.0.1","appVersion":"docs"}
{"ts":"2026-10-14T09:09:34.188Z","event":"disconnect","transport":"ws","tokenId":"tok_8adfcb0e9b854773","tokenName":"docs","remoteAddress":"127.0.0.1","reason":"closed by the peer (1000)"}
{"ts":"2026-10-14T09:09:34.390Z","event":"server.stop","reason":"shutdown"}
```

```text title="server-audit.failures.jsonl"
{"ts":"2026-10-14T09:09:33.815Z","event":"auth.failure","remoteAddress":"127.0.0.1","reason":"missing"}
{"ts":"2026-10-14T09:09:34.390Z","event":"auth.failure.summary","remoteAddress":"127.0.0.1","count":2,"reasons":{"invalid":1,"origin":1},"windowMs":60000}
```

The server writes these failure reasons: `missing`, `invalid` (unknown or revoked), `origin`, `insecure-transport`, `untrusted-proxy`, `rate-limited` and `too-many-connections`.

## What runs where

| What | Where |
| - | - |
| The agents' tools, `!` commands, terminals | On the server, as the server's user, in the session's folder |
| Session folders | Inside a workspace root (`--workspace-root`, default `<data dir>/workspaces`) |
| File methods (`sessions.workspaceReadFile`, `sessions.workspaceWriteFile`, `sessions.workspaceListFiles`) and the folder of `sessions.workspaceExec` | Confined to the workspace roots, with symlinks resolved. Never the data dir |
| Provider keys | The server's environment and `credentials.json`. A window can set them, but never read them back |
| OAuth sign-ins | Run on the server. The browser opens on the client. Tokens stay on the server |
| Tool approvals | Asked on the server, answered by a window that can show them (TUI, desktop app) |

The host checks the workspace roots wherever a request names a folder: new sessions, folder changes, orchestration workers, environment and command listings. The check guards against mistakes only. It does not confine the commands that you or the agent run.

[Tool approvals](https://zidane.si/docs/approvals.md) is a server setting. Unless it is Full access, a tool call that needs you waits as an `approval:request` until the TUI or the desktop app answers it. A browser page cannot answer it ([why](https://zidane.si/docs/ui-adapters.md#limits)). With Full access, the server's agents run tools without asking.

| Option | Default | Widens |
| - | - | - |
| `allowRemoteInstalls` (`--no-remote-installs` turns it off) | On | Clients install, update and remove skills, extensions and MCP servers in the server user's scope, audited |
| `allowClone` (`--no-clone` turns it off) | On | Any client token clones a repository into the workspace roots, with the server's git credentials. A `file://` URL must point inside the workspace roots |
| `allowCreateFolders` (`--no-create-folders` turns it off) | On | Any client token creates a folder in the workspace roots |
| `allowAsMachine` (`--allow-as-machine`) | Off | A client can use the server as a machine of its own computer, through the same confined executor. At most 4 per token |

The unix socket (`<data dir>/server.sock`, mode 0600) takes no token, like the socket of the [local daemon](https://zidane.si/docs/daemon.md). File permissions guard it. `--socket off` removes it. `GET /healthz` answers `ok` to anyone and says nothing else.
