> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Security and access

> What is open, what is closed by default, the two credentials, the production gate, and the agent surface that stays off unless you turn it on.

This page is for the self-hoster: what is closed by default, what has to be
set, and what happens on restart. Three separate gates protect an Avocado
deployment, and they are configured independently. Knowing which one is refusing
you is most of the work of fixing it.

| Gate | Guards | Closed by default in production? |
| - | - | - |
| **The access gate** | the orchestrator's routes — chat, operations, publish | **Yes**, in library mode. The standalone server enforces it only on the agent surface and `GET /editor/credentials` |
| **The publish secret** | the site's own `POST /api/editor/publish` | **Yes** |
| **The agent surface** | `/agent/*`, which runs shell and file tools | **Yes**, and it is off even with a credential unless you opt in |

## The two credentials

Both are read by the orchestrator, and either one opens the access gate.

**`ACCESS_PASSWORD_HASH`** — the SHA-256 hex digest of a password. `POST /auth/verify`
exchanges the password for a bearer token, and the editor prompts for it and
attaches the token as `x-access-token` (and as `?accessToken=` on EventSource,
which cannot send headers). This is the one for people.

**`ORCHESTRATOR_ACCESS_TOKEN`** — a fixed bearer token, presented directly as
`Authorization: Bearer`, `x-access-token` or `?accessToken=`. This is the one
for scripts and CI. The site SDK sends it when it reads drafts over HTTP from a
gated orchestrator, and `avocado-register` and `avocado qa` send it too.

```bash theme={null}
# Generate a hash for a password
printf '%s' 'your-password' | shasum -a 256
```

### What happens on restart

Tokens minted by `/auth/verify` are valid for 12 hours and are held **in memory**.
A restart or a redeploy voids every one of them, so everyone signed in to the
editor is asked for the password again. `GET /auth/status` reports
`tokenValid: false` for a token the server no longer knows, which is how the
editor knows to prompt. `ORCHESTRATOR_ACCESS_TOKEN` is read from the environment
and survives restarts.

## The production gate

**This is the behaviour that surprises people, so it is worth stating plainly.**

`createOrchestrator()` — library mode — gates **every** route. With no `auth`
hook and neither variable set, it **refuses every request under
`NODE_ENV=production`**. An unauthenticated publish endpoint on your own domain
is not a state anyone should be able to reach by forgetting something.

The standalone server is different. It enforces the gate only on the agent
surface and on `GET /editor/credentials`. `/chat`, `/ops`, `/history/*`,
`/telemetry/*` and the draft routes remain open to anyone who can reach the
server. `/publish` checks `x-publish-token` when `PUBLISH_TOKEN` is set, and
nothing else.

<Warning>
  **Do not expose a standalone orchestrator to the open internet and rely on the
  password.** Keep it reachable only by the people who edit, for example on a
  private network or behind a reverse proxy that restricts access. Library mode
  gates every route and is the shape to choose when that is not possible.
</Warning>

A closed mount says so rather than leaving you to infer it from a wall of 401s:

* `GET /auth/status` reports `mode: "closed"` with a `reason` naming both
  variables. The other modes are `token`, `hook` and `open-dev`.
* Every gated route's 401 carries the same `reason`.
* `POST /auth/verify` answers `503` rather than minting a token — a login that
  succeeds against a shut system leaves you holding a credential that opens
  nothing.
* The editor reads `mode` and renders a screen for the state instead of loading
  itself around a site it cannot read.

At boot it logs:

```
[auth] library mode: closed — NODE_ENV=production with no credential —
every request is refused. Set ACCESS_PASSWORD_HASH or
ORCHESTRATOR_ACCESS_TOKEN, or pass config.auth.
```

During `next build` that is a warning; at runtime it is an error, because then
requests really are being refused.

### Opening it

Three things open the gate: `ACCESS_PASSWORD_HASH`, `ORCHESTRATOR_ACCESS_TOKEN`,
or an `auth` hook passed to `createOrchestrator` in code — including
`auth: () => true`, which is a line you type on purpose and cannot arrive at by
omission.

A few paths stay open either way, because they answer before any caller could
hold a credential: `/health`, `/auth/status`, `/auth/verify`, `OPTIONS`
preflights, and `GET /generated-images/*`, which the rendered page loads in
`<img>` tags. With the pre-alpha `SITE_OPS_AGENTS=1`,
`/connections/google/callback` is open too. It acts only on a single-use state
value the orchestrator issued to a signed-in editor.

**Draft reads from the same process skip the gate.** When the site renders in
the process that mounts `createOrchestrator()`, and `ORCHESTRATOR_URL` is unset
or names that mount, the SDK reads `/draft/pages`, `/draft/slugs` and
`/draft/site-config` in-process. Only those three `GET` reads skip the gate.
When the site reads drafts over HTTP instead, it needs
`ORCHESTRATOR_ACCESS_TOKEN` in production. Over HTTP means `ORCHESTRATOR_URL`
names another orchestrator, or the route runs in a separate serverless function.

Local `next dev` needs none of this.

## The editor's credentials

The editor needs the site's draft secret to open the preview in draft mode, and
the publish token to publish. **Neither belongs in the editor's page on a public
URL**, because that page is served before the password prompt.

* `avocado-studio start` writes them into the page only on a loopback `--host`.
* On any other bind, the editor fetches them after sign-in from
  `GET /editor/credentials`. That route returns the orchestrator's own
  `DRAFT_MODE_SECRET` and `PUBLISH_TOKEN`, with `cache-control: no-store`.
* In library mode the route is gated like every other route. On the standalone
  server it checks the access token itself. With no credential configured it
  answers 403 in production.
* `VITE_*` variables are inlined into an editor you build from source, so they
  are public too. Do not build `VITE_SITE_DRAFT_SECRET` or `VITE_PUBLISH_TOKEN`
  into a public editor.

So a hosted editor needs the orchestrator to hold `DRAFT_MODE_SECRET`,
`PUBLISH_TOKEN` and `ACCESS_PASSWORD_HASH`. A library-mode orchestrator already
has the site's own `DRAFT_MODE_SECRET`, because it runs in the site's process.

## The publish secret

`POST /api/editor/publish` replaces the site's content, so it is guarded
separately and with a variable of its own.

Under `NODE_ENV=production` it refuses **every** request while `publishSecret` is
unset — `401`, with a `reason` naming `PUBLISH_TOKEN`. Set that variable on the
site and set the same value in the orchestrator's environment; the orchestrator
sends it as the `x-publish-token` header.

Development stays open *while `publishSecret` is unset*, because publishing to
your own machine is the point. Set the variable and the check runs in
development too.

One refusal applies in both environments: a publish whose `pages` array is empty
is answered with `409` unless the body carries `"allowDelete": true`. What
actually produces an empty array is a client publishing what it thinks it has
after its own state failed to load — losing a site to a failed fetch is not a
decision anyone made. For a tighter bound, pass `maxPagesRemoved` to
`createEditorApiHandler()`. A publish that removes more pages than that is then
refused unless it carries `"allowDelete": true`.

## The agent surface

`/agent/*` and `/sites-agent/*` are not ordinary endpoints. They run open-ended
multi-turn agent loops **with file and shell tools**. The `useCliAgent` variant
spawns the Claude CLI with permissions bypassed and an allowed-tools list
containing Bash, Write and Edit, passing the request body through as the prompt.

That is arbitrary code execution as the orchestrator's process user, by design —
it is what makes site onboarding work. **The surface cannot be made safe by
narrowing what it may run.** It can only be kept off by default and put behind a
credential.

So three decisions, in order:

<Steps>
  <Step title="Is the surface mounted at all?">
    Off in production unless `AGENT_SURFACE=on`. Unmounted means `404` — no
    handler, no stream context, no code path.
  </Step>

  <Step title="May this caller reach it?">
    A valid access token, always, whenever the password gate is configured. In
    development, loopback callers pass without one: a local process that can bind
    a socket on your laptop already has your shell, so a token there guards
    nothing.
  </Step>

  <Step title="May it spawn the CLI?">
    Separately, via `AGENT_CLI=1`, and **off everywhere by default** — that
    variant escapes the SDK's tool boundary onto the host.
  </Step>
</Steps>

It fails closed. `AGENT_SURFACE=on` in production with no password gate and no
static token **refuses to mount** and says why, rather than mounting open:

```
AGENT_SURFACE=on but no credential is configured — set ACCESS_PASSWORD_HASH
or ORCHESTRATOR_ACCESS_TOKEN; refusing to mount an unauthenticated agent
surface
```

<Warning>
  If you do not use the built-in [onboarding agent](/sites/site-agent), leave
  `AGENT_SURFACE` unset. Nothing else in the product needs it, and the editor's
  per-edit chat does not go through it.
</Warning>

## What an edit can and cannot reach

Worth restating alongside the credentials, because it is the guarantee that does
not depend on anyone configuring anything: every edit the chat can make is one
of a fixed set of [typed operations](/concepts) against your content. That vocabulary has no
verb for "change a file". A model driving Avocado at full confidence with every
flag disabled still cannot modify a component, add a dependency, alter a route,
or touch your build.

The agent surface above is the deliberate exception, which is why it is gated
three times over and off by default.

## Cross-origin

| Variable | Whose origins |
| - | - |
| `EDITOR_CORS_ORIGINS` | which origins the site's editor routes accept |
| `ORCHESTRATOR_CORS_ORIGINS` | which origins the orchestrator accepts |
| `NEXT_PUBLIC_EDITOR_ORIGIN` | where the editor lives, for the site's answer |
| `ORCHESTRATOR_PUBLIC_ORIGIN` | the orchestrator's address, as the browser sees it |

`localhost` and `127.0.0.1` are treated as the same address wherever either is
written. A CORS answer a browser rejects is indistinguishable, from inside the
browser, from a server that is down — which is why "the editor cannot reach a
server that answers `200` to `curl`" is almost always this.

## Checklist for a production deployment

1. Set `ACCESS_PASSWORD_HASH` **or** `ORCHESTRATOR_ACCESS_TOKEN`.
2. Set `PUBLISH_TOKEN`, on both the site and the orchestrator.
3. Set `DRAFT_MODE_SECRET` on the site and on the orchestrator, which hands it to
   the signed-in editor.
4. Set `NEXT_PUBLIC_SITE_URL` to the real origin.
5. Leave `AGENT_SURFACE` and `AGENT_CLI` unset unless you specifically want the
   onboarding agent.
6. Leave `SITE_OPS_AGENTS` unset. Those features are pre-alpha and off by
   default.
7. On the standalone server, restrict who can reach it at the network level.
8. In library mode, confirm with `GET /auth/status`. It should not say
   `mode: "closed"`.

Every variable above: [environment reference](/reference/environment).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.