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.
| Credential | Prefix | Authenticates | Scope |
|---|---|---|---|
| JWT access token | — | A user | The whole API |
| Channel key | chk_ | An external channel adapter | /api/v1/channel/* only |
| Socket ticket | chtk_ | One WebSocket upgrade | A single connection, once |
| MCP token | — | An MCP client | The 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
| Claim | Meaning |
|---|---|
sub | User id |
email | User email |
tenant_id | Tenant, when applicable |
iat / exp | Issued-at and expiry |
token_version | Matches 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
| Endpoint | Purpose |
|---|---|
GET /auth/bootstrap-status | needsSetup and userCount — whether the installation has any users |
POST /auth/register | Create an account, subject to the deployment's registration policy |
POST /auth/login | Obtain tokens |
POST /auth/refresh | Exchange a refresh token |
POST /auth/logout | Client-side discard |
GET /auth/validate | Check a token and nothing else |
GET /auth/me | The current user |
POST /auth/password/forgot · /reset · /change | Password lifecycle |
GET /setup/status · POST /setup/initialize | First-run initialization |
POST /auth/demo | Demo 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:
- Random and short-lived — a 60-second TTL.
- Single-use — consumed on first use.
- Scope-bounded — carries the key's workspace and optional app/channel scope, which then bounds every subscription on the resulting socket.
- Deliberately distinct prefix —
chtk_rather thanchk_, so a leaked ticket is visibly not a long-lived credential.
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:
- Database-backed tokens (preferred) — the same model as per-workflow dynamic MCP servers.
- Static
MCP_SERVER_TOKEN— a compatibility mode. If set, it is honoured.
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:
| Endpoint | Why |
|---|---|
GET /health · /ready | Load balancers and health checks |
GET /metrics | Standard Prometheus practice |
GET /workflows/templates | A template gallery loads before sign-in |
GET /nodes (reads) | The node catalog is not sensitive |
GET /auth/bootstrap-status | A fresh install must be able to say it needs setup |
| Webhook routes | External 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").
| Field | Required | Meaning |
|---|---|---|
api_key | Yes | Sent to TypeSafe as a Bearer token; stored encrypted like every credential |
endpoint | No | An approved HTTPS proxy or test endpoint; empty means https://api.typesafe.ai |
model | No | A 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
| Variable | Consequence of changing it |
|---|---|
JWT_SECRET | Every issued session becomes invalid |
CREDENTIAL_MASTER_KEY_1 | Existing 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
- REST API overview — the endpoints these credentials reach.
- WebSocket API — where tickets are used.
- Gateway model — which credential a gateway should use.