Skip to main content
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 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:
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.
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.

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:
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.
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.

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:
It succeeds (201) only while zero organisations exist — every later call returns 409 Platform already set up. See Self-hosting for where this fits in the bootstrap sequence.

Inviting members

Admins invite by email — from Settings → Team in the dashboard, or directly:
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.
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.

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. 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.
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.

See also

  • Self-hostingSECRET_KEY, REGISTRATION_TOKEN, and the /setup bootstrap in context
  • crucihil agent — how agents pick up CRUCIHIL_API_KEY and stay connected
  • MCP Overview — API-key and OAuth modes for AI clients