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 thePOST /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:
/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.
User accounts
Humans log in with email + password atPOST /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: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’sSECRET_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:
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
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
POST /api/v1/auth/forgot-passwordwith{"email": "..."}— always returns200with the same message whether or not the account exists. Rate-limited to 5 requests per 5 minutes per email.- 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.
- The dashboard validates it via
GET /api/v1/auth/password-reset/{token}(404if missing, expired, or used). POST /api/v1/auth/reset-passwordwith{"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, itsorg_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.
409.
See also
- Self-hosting —
SECRET_KEY,REGISTRATION_TOKEN, and the/setupbootstrap in context crucihil agent— how agents pick upCRUCIHIL_API_KEYand stay connected- MCP Overview — API-key and OAuth modes for AI clients