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

# Authentication & access

> Credentials, roles, JWTs, and org isolation on the CruciHiL control plane

The control plane — self-hosted or the hosted instance at `app.crucihil.io` — authenticates three kinds of principals: **rig agents**, **human users**, and **read-only viewers**. All three end up holding a short-lived JWT; they differ in how the JWT is obtained and what it is allowed to do.

## The three credential types

|                     | Rig API key                              | User account                 | Viewer key                                           |
| ------------------- | ---------------------------------------- | ---------------------------- | ---------------------------------------------------- |
| Who uses it         | Agents on bench machines, the MCP server | People in the dashboard      | CI jobs, read-only integrations                      |
| Format              | `crucihil-rig-` + 48 hex chars           | Email + password             | `crucihil-viewer-` + 48 hex chars                    |
| Issued by           | `POST /api/v1/rigs` (shown once)         | `/setup` bootstrap or invite | `POST /api/v1/rigs/{rig_name}/viewers` (shown once)  |
| Stored as           | SHA-256 hash                             | bcrypt hash (12 rounds)      | SHA-256 hash                                         |
| Becomes a JWT via   | `POST /api/v1/auth/token`                | `POST /api/v1/auth/login`    | `POST /api/v1/auth/token`                            |
| JWT `type` / `role` | `rig` / `admin`                          | `user` / `admin` or `member` | `viewer` / `viewer`                                  |
| Access              | Read + write (machine admin)             | Per role, org-scoped         | Read only                                            |
| Revocation          | Delete the rig                           | Remove the user              | `DELETE /api/v1/rigs/{rig_name}/viewers/{viewer_id}` |

### Rig API keys

Every rig gets exactly one API key at registration. It is returned once in the `POST /api/v1/rigs` response and stored server-side only as a SHA-256 hash — there is no way to retrieve it later, only to delete the rig and re-register.

Registering a rig requires either the static `REGISTRATION_TOKEN` (used by `setup.sh` and `install-agent.sh`) or an admin JWT. When a user JWT does the registering, the rig is assigned to that user's org.

Agents pick the key up from the `CRUCIHIL_API_KEY` environment variable (or the `[rig.cloud]` section of the rig TOML — the env var wins) and exchange it for a JWT:

```bash theme={null}
curl -X POST https://your-server.example.com/api/v1/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"api_key":"crucihil-rig-..."}'
```

```json theme={null}
{ "access_token": "eyJ...", "token_type": "bearer", "expires_in": 3600 }
```

The agent refreshes automatically 5 minutes before expiry, and the same JWT authenticates its WebSocket connection (`/ws/agent?token=<jwt>` — rig tokens only; viewer tokens are rejected there). The MCP server uses the identical exchange: its client caches the JWT and re-exchanges the API key once the token is within 5 minutes of expiring.

<Warning>
  A rig API key yields an **admin-role machine token** — it can create runs, sync results, and register further rigs. Treat it like a password: `install-agent.sh` writes it to `/etc/crucihil/<name>.env` with mode 600 for this reason.
</Warning>

### User accounts

Humans log in with email + password at `POST /api/v1/auth/login` and get a user JWT the dashboard sends as a `Bearer` header. Passwords are bcrypt-hashed; login is rate-limited to **5 attempts per 60 seconds per email**, and a wrong password and an unknown email return the same `401` (with matched response timing) so accounts can't be enumerated.

Accounts are created only two ways: the one-shot `/setup` bootstrap, or an admin invite — there is no open registration endpoint.

### Viewer keys

Viewer keys are per-rig, read-only API keys for CI pipelines and dashboards that should see results but never trigger anything. An admin creates one with a label:

```bash theme={null}
curl -X POST https://your-server.example.com/api/v1/rigs/bench_01/viewers \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H 'Content-Type: application/json' \
  -d '{"name":"github-actions"}'
```

The response contains `viewer_id`, `name`, and the one-time `viewer_key`. The key goes through the same `POST /api/v1/auth/token` exchange but yields a `role: viewer` JWT that passes every read endpoint and is refused (`403`) by every write endpoint. Revoking (`DELETE /api/v1/rigs/{rig_name}/viewers/{viewer_id}`) flags the key; revoked keys are rejected at the token exchange, so existing JWTs age out within the hour.

## JWT reference

All tokens are **HS256**, signed with the server's `SECRET_KEY`, and expire after **`JWT_EXPIRY_SECONDS` (default 3600 — 1 hour)**. There are no refresh tokens: machines re-exchange their API key; users log in again.

| Claim         | User token          | Rig token               | Viewer token            |
| ------------- | ------------------- | ----------------------- | ----------------------- |
| `type`        | `user`              | `rig`                   | `viewer`                |
| `sub`         | user ID             | rig name                | rig name                |
| `role`        | `admin` or `member` | `admin`                 | `viewer`                |
| `org_id`      | user's org          | rig's org (if assigned) | rig's org (if assigned) |
| `org_slug`    | ✓                   | —                       | —                       |
| `rig_id`      | —                   | ✓                       | ✓                       |
| `iss`         | server URL          | —                       | —                       |
| `iat` / `exp` | ✓                   | ✓                       | ✓                       |

<Info>
  In MCP OAuth mode the control plane also acts as an OAuth 2.1 + PKCE authorization server (`/.well-known/oauth-authorization-server`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`). The end result is the same user JWT described above, scoped to the logged-in user's org. See [MCP Overview](/mcp/overview).
</Info>

## User lifecycle

### Bootstrap the first admin — `POST /api/v1/setup`

One call creates the first organisation and its admin, and returns a JWT plus the org slug:

```bash theme={null}
curl -X POST https://your-server.example.com/api/v1/setup \
  -H 'Content-Type: application/json' \
  -d '{"org_name":"Acme","admin_email":"you@company.com","admin_password":"strong-password"}'
```

It succeeds (`201`) only while **zero organisations exist** — every later call returns `409 Platform already set up`. See [Self-hosting](/guides/self-hosting) for where this fits in the bootstrap sequence.

### Inviting members

Admins invite by email — from **Settings → Team** in the dashboard, or directly:

```bash theme={null}
curl -X POST https://your-server.example.com/api/v1/orgs/acme/invites \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H 'Content-Type: application/json' \
  -d '{"email":"teammate@company.com","role":"member"}'
```

`role` is `member` (default) or `admin`. Invite token semantics:

* **256-bit URL-safe token**, embedded in the accept link; only its SHA-256 hash is stored
* **Expires after 72 hours**
* **Single-use** — cleared the moment the invite is accepted
* Re-inviting an address with a pending (never accepted) invite re-issues a fresh token; inviting an address that already has an account, or is pending in another org, returns `409`

The invitee opens the link, the dashboard validates it via `GET /api/v1/auth/invite/{token}` (returns email + org name; `404` if expired or used), then sets a password (min 8 characters) through `POST /api/v1/auth/invite/accept`, which returns a JWT — they land in the dashboard logged in.

<Info>
  Invite and password-reset emails are sent through Resend when `RESEND_API_KEY` is set. When it isn't (local dev, CI), the server logs the full link to stdout instead — grab it from the server logs and open it by hand.
</Info>

### Roles and what they can do

Two user roles exist: **`admin`** and **`member`**. Machine tokens map onto the same checks — rig tokens act as admin, viewer tokens as read-only.

| Action                                                           | `viewer` | `member` | `admin` (user or rig token) |
| ---------------------------------------------------------------- | -------- | -------- | --------------------------- |
| List rigs, runs, results; view run detail, reports, live streams | ✓        | ✓        | ✓                           |
| Create and cancel runs                                           | —        | ✓        | ✓                           |
| Register rigs                                                    | —        | —        | ✓                           |
| Delete rigs; create / revoke viewer keys                         | —        | —        | ✓                           |
| Sync results (agent)                                             | —        | —        | ✓ (rig token)               |
| Invite, list, remove org members                                 | —        | —        | ✓ (user token only)         |

Two guardrails on member management: it requires a human admin JWT (machine tokens are refused), and an admin cannot remove their own account (`409`).

### Password reset

1. `POST /api/v1/auth/forgot-password` with `{"email": "..."}` — always returns `200` with the same message whether or not the account exists. Rate-limited to **5 requests per 5 minutes per email**.
2. If the account exists, a reset link is emailed (or logged to stdout — see above). The token is 256-bit, stored as a SHA-256 hash, and **expires after 1 hour**.
3. The dashboard validates it via `GET /api/v1/auth/password-reset/{token}` (`404` if missing, expired, or used).
4. `POST /api/v1/auth/reset-password` with `{"token": "...", "password": "..."}` sets the new password (min 8 characters), consumes the token, and returns a fresh JWT.

## Org scoping

Every resource hangs off an organisation. When a request carries a **user JWT**, its `org_id` claim filters everything:

* **Rigs** — listing returns only your org's rigs; fetching, deleting, or managing viewer keys on another org's rig returns `404`, indistinguishable from a rig that doesn't exist.
* **Runs, results, reports, signal traces** — reachable only through org-scoped rigs, so they inherit the same isolation.
* **Rig registration with a user JWT** assigns the new rig to your org.

Email addresses are globally unique — **one email belongs to exactly one org**. Inviting an address that exists anywhere on the instance returns `409`.

<Warning>
  Rigs registered with the static `REGISTRATION_TOKEN` (rather than a user JWT) have no org and are visible to **every** org on the instance. On a single-team self-hosted server this is fine; on a shared instance, register rigs from the dashboard or with a user JWT so they land in your org. Machine tokens (rig/viewer) are likewise not org-filtered — scoping is enforced on human sessions.
</Warning>

## See also

* [Self-hosting](/guides/self-hosting) — `SECRET_KEY`, `REGISTRATION_TOKEN`, and the `/setup` bootstrap in context
* [`crucihil agent`](/cli/agent) — how agents pick up `CRUCIHIL_API_KEY` and stay connected
* [MCP Overview](/mcp/overview) — API-key and OAuth modes for AI clients
