Authentication

Applies to: ChengOS v0.2.0+ | Last verified: 2026-09-25 | Source: crates/cheng-api/src/rest/handlers/auth/mod.rs, crates/cheng-api/src/rest/handlers/decision/mod.rs, crates/cheng-api/src/rest/dto/credential_provider_schema.rs, crates/cheng-api/src/middleware/channel_auth.rs, crates/cheng-api/src/services/channel_socket_ticket.rs, crates/cheng-api/src/middleware/workspace_context.rs

ChengOS has four credential types, each with a specific job. Most 401 and 403 confusion comes from using one where another belongs, so it is worth reading the whole table before debugging.

CredentialPrefixAuthenticatesScope
JWT access token—A userThe whole API
Channel keychk_An external channel adapter/api/v1/channel/* only
Socket ticketchtk_One WebSocket upgradeA single connection, once
MCP token—An MCP clientThe MCP server

JWT access tokens

The normal path. Log in, get a token, send it on every request.

curl -X POST http://127.0.0.1:19225/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email": "[email protected]", "password": "…"}'
{
  "token": "eyJhbGciOi…",
  "refreshToken": "eyJhbGciOi…",
  "expiresIn": 3600,
  "user": { "id": "…", "email": "…", "username": "…" }
}
Authorization: Bearer <token>

Claims

ClaimMeaning
subUser id
emailUser email
tenant_idTenant, when applicable
iat / expIssued-at and expiry
token_versionMatches users.token_version

token_version is the revocation mechanism. It is incremented on password change, which invalidates every previously issued token for that user at once. This is what makes "change your password to log out everywhere" actually work — without it, a stolen token would stay valid until expiry.

Lifecycle

Access tokens last one hour (expiresIn: 3600). Exchange the refresh token at POST /api/v1/auth/refresh rather than re-authenticating.

POST /api/v1/auth/logout returns 204 and is client-side only — it tells you to discard the token; it does not revoke it server-side. To actually invalidate tokens, change the password (bumping token_version).

The full auth surface

EndpointPurpose
GET /auth/bootstrap-statusneedsSetup and userCount — whether the installation has any users
POST /auth/registerCreate an account, subject to the deployment's registration policy
POST /auth/loginObtain tokens
POST /auth/refreshExchange a refresh token
POST /auth/logoutClient-side discard
GET /auth/validateCheck a token and nothing else
GET /auth/meThe current user
POST /auth/password/forgot · /reset · /changePassword lifecycle
GET /setup/status · POST /setup/initializeFirst-run initialization
POST /auth/demoDemo deployments only: a session for the configured demo account, without credentials

Workspace authorization

Authentication says who you are; X-Workspace-Id says which tenant you are acting in:

X-Workspace-Id: <workspace-uuid>

The middleware runs after authentication, specifically so it can read the caller's claims and authorize the header against them. A workspace you have no access to is a 403 — never a silent fallback to a default workspace.

Channel keys (chk_)

Channel keys authenticate server-side channel adapters — WhatsApp, Telegram, Slack, DingTalk, WeCom — with no user session involved.

chk_{credential_id_hex_32}_{secret_hex_32}

The embedded credential id is a UUID without hyphens, which gives an O(1) database lookup; the secret half is verified against the encrypted credential record. Keys are stored as credentials with provider = "channel_adapter", and the secret plus workspace metadata are encrypted in the cipher field.

Manage them at /api/v1/workspaces/:workspace_id/channel-keys (create, list, delete).

Do not use a channel key from a browser. It is a long-lived shared secret and client-side code cannot keep it. Browsers use JWTs — see the gateway model.

Socket tickets (chtk_)

A channel key must never appear in a URL, because query strings are logged by proxies and stored in browser and CLI history. So a channel client exchanges its key for a ticket over the authenticated REST surface and presents the ticket once during the WebSocket upgrade:

POST /api/v1/channel/socket-ticket     # authenticated with chk_
# → chtk_…
wss://host/ws/executions?ticket=chtk_…

Ticket properties:

Outstanding tickets are capped in bounded process memory, protecting the server from a client that mints tickets in a loop without consuming them. Set CHANNEL_SOCKET_TICKET_REDIS_URL to hold them in Redis with a TTL and atomic consume instead — which you need for a multi-instance deployment, since an in-memory ticket minted on one instance cannot be consumed on another.

MCP tokens

With MCP_ENABLED=true, the app-level MCP server mounts at /mcp. Two auth modes:

Leave MCP_SERVER_TOKEN unset to use the database-backed path, which supports issuing and revoking individual tokens rather than sharing one secret.

Unauthenticated endpoints

Deliberately open:

EndpointWhy
GET /health · /readyLoad balancers and health checks
GET /metricsStandard Prometheus practice
GET /workflows/templatesA template gallery loads before sign-in
GET /nodes (reads)The node catalog is not sensitive
GET /auth/bootstrap-statusA fresh install must be able to say it needs setup
Webhook routesExternal platforms call them; they carry their own verification

Restrict /metrics at the network layer if your deployment treats operational metrics as sensitive.

Decision provider credentials

Decision providers for ai/decision_branch are not chat LLM providers, and their credentials are a separate type: type_id: "jev" ("TypeSafe Jev").

FieldRequiredMeaning
api_keyYesSent to TypeSafe as a Bearer token; stored encrypted like every credential
endpointNoAn approved HTTPS proxy or test endpoint; empty means https://api.typesafe.ai
modelNoA pinned model for everything using this credential

Test a key without saving it:

POST /api/v1/decision/credentials/test
{ "provider": "jev", "api_key": "…", "endpoint": "…" }

Send credential_id instead of api_key (or a masked placeholder) to re-test a stored key against an edited endpoint. The probe is JWT-authenticated, rate-limited per user and client address, stores nothing, and returns the models the key can use — never the key or a raw provider response. Only credential-based providers can be tested this way; Laya is deployment configuration, not a credential.

A deployment can instead provide one key for everyone with JEV_API_KEY. See Decision Providers & TypeSafe Jev.

Secrets that make all this work

VariableConsequence of changing it
JWT_SECRETEvery issued session becomes invalid
CREDENTIAL_MASTER_KEY_1Existing stored credentials become unreadable

Generate both with openssl rand -hex 32 before exposing an installation, and treat CREDENTIAL_MASTER_KEY_1 as unrecoverable — losing it loses every stored credential. See the environment reference.

Demo mode

With CHENG_DEMO_MODE=true, visitors enter through POST /auth/demo, which signs them in as CHENG_DEMO_USER_EMAIL (never a platform administrator). Login, registration, and password changes are refused, and every write and most sensitive reads return 403 DEMO_MODE_RESTRICTED. That is configuration, not a permission problem with the account. See Public Read-only Demo Mode.

Next steps