OpenClaw Control Plane (OCC) authenticates human controller API clients with user sessions established through email/password sign-in or an administrator-enrolled GitHub, Google or OIDC identity. Programmatic non-Agent automation authenticates with service API keys. Better Auth owns password verification, revocable session cookies, and hashed API-key storage. The selected IAM Driver resolves the authenticated account or service identity to an explicitly provisioned Principal or ServicePrincipal and owns authorization.
For a sign-in procedure, see
human administrator sign-in.
For non-Agent automation, see the service-key procedure.
The platform console at /console/ uses these session endpoints. Public signup, OIDC
provisioning or claim mapping, and bearer credentials are unsupported; generic OIDC
sign-in for enrolled accounts is in OIDC sign-in.
Authentication belongs to one bootstrapped Installation. The controller requires
OCC_AUTH_SECRET and OCC_AUTH_BASE_URL (see settings). An account's immutable Better Auth user ID
and Installation-specific trusted issuer identify its IAM Principal. Neither
email nor display name grants access.
Fresh native-IAM bootstrap creates the first human administrator and one Installation-scoped, non-Agent ServicePrincipal. Both have separate bindings to the same administrator Role. The service identity has no email, password, session, Namespace, or Agent owner; its authority does not depend on the human account remaining present.
Fresh bootstrap also creates the initial default Namespace
under the bootstrap Principal's ordinary Namespace creation permission.
Installation/IAM state, the Namespace, its queued reconciliation, and bootstrap
audit commit together. Worker provisioning is asynchronous.
Bootstrap issues a 30-day service API key named bootstrap-admin and writes its
one-time response to OCC_BOOTSTRAP_SERVICE_KEY_FILE. The JSON contains
data.id, data.servicePrincipalId, data.name, data.expiresAt, data.key,
and meta.installationId.
Better Auth keeps only the hash; there is no server-side plaintext retrieval.
Production also creates the configured OCC_BOOTSTRAP_ADMIN_EMAIL account with
a random password written to OCC_BOOTSTRAP_PASSWORD_FILE. Both paths must be
absolute, distinct siblings on protected operator-owned storage. Output is
exclusive, owner-only (0600), and synced before committing Installation/IAM
state; existing files, symlinks, or unsafe parent directories fail closed.
Credentials never appear in logs, audit, or the bootstrap response. OCC creates no Kubernetes Secret or PVC for delivery.
In Helm, bootstrap.password.claimName selects the existing protected PVC.
Only the initialization Job mounts it; bootstrap.password.fileName and
bootstrap.serviceKey.fileName are written under bootstrap.password.mountPath.
See initial-key retrieval
and bootstrap recovery.
The shared scripts/bootstrap-installation.mjs initializer runs after migration
and before either API or worker startup in Compose and Helm. Development
provisions the configured OPENCLAW_DEV_EMAIL and
OPENCLAW_DEV_PASSWORD on a fresh database, using the defaults in
settings, and
bootstraps the Installation before serving requests. It does not generate a
password output file or rotate an existing account's password. Compose stores
the service-key JSON on the bootstrap-only occ_bootstrap_data volume. The API
and worker do not mount it. Direct development runs the same initializer with
an explicit private key-file path before starting the API or worker.
The quickstart uses the service key for its API check.
An already-bootstrapped Installation receives no new Namespace, identity, grants, key, or output, including installations created before initial-key delivery existed. Restarting does not replace missing files, expired/revoked keys, removed service identities, or removed grants. Use normal issuance/revocation for credential recovery and rotation.
Bootstrap makes one attempt. Any error emits installation.bootstrap-failed
with available non-secret IDs and paths, then exits unsuccessfully. Created
accounts, keys, and files remain, including partial output from a failed write.
Bootstrap does not automatically revoke, delete, retry, repair, or reset them.
The Helm initialization Job uses backoffLimit: 0. Better Auth persistence and the Installation/IAM commit are separate,
so an error leaves the commit outcome unknown; resolve it before manual repair
or reset an identified disposable Installation. See incomplete bootstrap recovery.
File existence alone is not proof of successful initialization.
Cookie-authenticated controller API mutations must include an
Origin matching the origin of OCC_AUTH_BASE_URL. This includes sign-out. A missing,
malformed, or different origin is rejected with 403, and the error message says
that a trusted browser origin is required. If Sec-Fetch-Site is
present, it must be same-origin. Safe reads do not require an Origin.
Sign-in rejects an explicitly untrusted or malformed Origin and also rejects
Sec-Fetch-Site: cross-site when Origin is missing. Command-line sign-in may
omit both headers. For later cookie-authenticated mutations, command-line clients
must provide the configured Origin. An explicitly supplied service API key does
not require Origin, and an invalid key never falls back to a session cookie.
| Operation | Supported behavior |
|---|---|
POST /api/auth/sign-in/email |
Verifies an existing account's email and password and issues a session cookie. The JSON response confirms authentication, and with GitHub enabled returns sessionKey, never a session token. |
GET /api/auth/session |
Returns safe account identity and a noncredential sessionKey, or data: null without a valid session. |
POST /api/auth/sign-out |
Revokes the current session. Protected API requests using that session subsequently return 401. |
Human-session Namespace IAM writes retain assigning authority through their transaction; see Sharing commit guarantees.
The sessionKey names the current session, stays stable across reads, and changes
on each sign-in. It cannot authenticate; the token stays in its HttpOnly cookie.
Sending it back as x-occ-session-key narrows a request to that session: a
foreign, malformed, or duplicated key returns 401, and such a sign-out neither
revokes nor clears the cookie. Without the header, requests are unchanged. Console
pins each tab's key this way.
Password-only sessions expire seven days after sign-in; use does not extend
them. With GitHub, Google or OIDC sign-in enabled, every session expires after
eight hours (session controls).
Password-only has no online way to end another account's session: removing its
IAM bindings and Group memberships refuses permissioned requests from the next
one (native admin WebSockets within about 25 s), and purge-sessions --user <userId> --writers-stopped (stopped maintenance)
ends it.
Sign-in takes {"email": "...", "password": "..."}. The session arrives only
through Set-Cookie.
Sign-in rejects NUL or unpaired-surrogate emails with 400 INVALID_REQUEST
before account or known-device State reads. Both profiles retain denial audits
and budget accounting, including recovery-only and counted 503 audit failures.
Limited attempts remain 429; passwords are unchanged. Stored U+FFFD email is
unsupported: password-only returns 400 FORBIDDEN without a session. Guarded
credential checks can create a session and cookie, but HTTP session inspection
returns 500 INTERNAL_ERROR, not usable-session proof.
In both profiles, after 10 failed sign-ins per minute per email, or 20
per client address with api.trustedProxy,
attempts wait 1–8 s and return 429 with Retry-After, whether or not the email
exists; an Installation administrator's correct password still signs in, and so does
the recovery account's with an external provider. Successful sign-ins spend nothing. Without
one, browsers share the ingress address and startup logs
authentication.sign-in-limit-warning. A successful sign-in within the budget
clears that email's failures, not the address's; an administrator's success in
the slowed lane does not, so pacing lasts until the minute rolls over. The first
slowed attempt per lane each minute logs authentication.sign-in-limited at WARN
with its lane (email, device, address, or untracked when the budget table is
full). email, device, and address events also carry keyHash, a truncated HMAC under
the auth secret; untracked events have no key and no hash. The email and
address are never logged. The bundled Collector
exports the event and lane, not the hash.
Password checks attempt an authentication.login audit: success names the
account's Principal and userId; wrong passwords and unknown emails produce
denied/INVALID_CREDENTIALS without account identity. Audit writes fail closed.
Failed success audits return 503 without a cookie; uncertain session creation
or cleanup may leave a server-side session. Failed denial audits still count
against tracked budgets, returning 503 or, in the slow lane, 429. Untracked
or exhausted lanes are paced without necessarily adding failure entries.
Unconfirmed audit writes have unknown persistence outcomes. Admission 429s
before password checking are unaudited and reported by
authentication.sign-in-limited. Slow-lane administrator checks remain audited;
wrong passwords return 429.
Every successful sign-in, password or external, sets __Host-occ_known_device
(occ_known_device over plain HTTP): HttpOnly, SameSite=Strict, Path=/, no
Domain, 90 days. It holds up to three entries, one per recent account, each an
HMAC under the auth secret over a hash of the account's email, the issue time and
a random nonce, so every sign-in gets its own entry; it never carries the email.
Each entry is also bound to the account's password: its user, password method and
that method's authentication version, which the database bumps on every password
change. An external sign-in marks the browser only when the account has a password.
When fresh account proof is available, a later password attempt with a valid
entry spends that browser's own budget, the size of the email's, and does not
wait for the email's slowed slots. Spending the shared email budget alone cannot
refuse that verified browser. If finite proof capacity is exhausted or the reader
is unavailable, the attempt instead uses shared email/address admission with
signed-device constraints. A nonreserved account can then receive 429 from a
spent shared lane despite its valid cookie. Administrator and recovery accounts
retain their existing paced password checks.
The cookie never authenticates: a wrong password with it is 401 and spends the
browser's lane, and the address lane and global caps still apply. Tampered,
expired, foreign-account, stale, or duplicated cookies are ignored, returning the
attempt to the shared lane with the same answer a new browser gets. Resetting an
account's password, or deleting and recreating the account, revokes every entry
issued before, including a reset that commits while a sign-in with the old password
is in flight: that sign-in's entry is bound to the state read before its password
check. With external sign-in, a disabled account's entries verify
nothing until it is enabled again; reset the password as well to revoke them for
good. The controller reads the account only for an entry issued for the attempted
email, so forged or foreign cookies add no timing signal about which emails exist,
and a completed read that rejects the binding gives no exemption. Each controller
limits proof reads to 30 per signed entry per minute, two active per entry,
600 per minute overall, and 16 active overall. There is no proof cache or waiting
queue. If a read fails or is refused, its signed entry keys also constrain the
ordinary email/address lane: losing proof cannot reopen a spent device allowance.
These keys never grant an exemption. Reserved accounts retain the existing paced
password check. Rotating the auth secret invalidates every entry; the next
successful sign-in issues a new one. A new browser gets no exemption.
The controller configures the Better Auth cookie with the openclaw_occ
prefix; the OpenAPI contract names it openclaw_occ.session_token. Cookies are
HTTP-only, use SameSite=Lax, and cover /. Production enables secure cookies;
the configured base URL is also the trusted origin. Session inspection exposes
only authenticated, sessionKey, and the account's id, email, and name.
Protected requests resolve the current stored session with cookie caching
disabled. A missing, expired, revoked, or forged session is rejected, as is an
Authorization header even alongside a session cookie.
An Installation can let enrolled existing accounts sign in with GitHub, Google or one generic OIDC issuer. External sign-in and account controls defines the single-controller profile, provider flow, session binding, the recovery user, the administrator account API, and sign-in limits.
See session and recovery controls.
Agent native admin UI access starts from an ordinary controller browser session.
With the trusted-operator pilot enabled, the session cookie is scoped to the
explicit nativeAdmin.sharedCookieDomain parent so the console and derived Agent
hosts share one human session. Service API keys cannot open native admin UI.
When native admin is disabled, leftover shared-cookie-domain configuration is
ignored and the host-only openclaw_occ cookie remains.
The explicit parent domain is validated against the console origin and Agent host
suffix on DNS-label boundaries. Public suffixes, malformed domains, and outside
hosts are rejected; OCC never infers a broader parent. Because a domain cookie
cannot use __Host-, the shared session uses prefix openclaw_occ_shared
(__Secure-openclaw_occ_shared.session_token on HTTPS). Successful sign-in and
sign-out clear prior host-only openclaw_occ and openclaw_occ_shared cookie
names so browsers never choose between duplicates.
Native-host requests authenticate the shared OCE session, resolve the exact
Agent represented by the requested host, authorize exact Agent administer, and
validate the selected version and supported native configuration before
proxying. OCC strips browser cookies, Authorization, API and session keys,
forwarded identity, and native scope headers before forwarding upstream, so the native
gateway never receives the OCE session cookie. Native chat or other Agent-host
activity does not renew the console session.
POST /api/auth/accounts requires a human session and administer on the
singleton Installation, and stays available with GitHub sign-in enabled. One
transaction writes the account, Principal, grant, enrollment and an optional
"github":{"subject":"<numeric id>"} identity (409 if GitHub is off or taken).
An optional roleId binds an existing Installation Role (400 if unknown or
Namespace-scoped); without one the account has no grants. It cannot create Roles
or infer grants. Audit records principalId and roleId or grant: "none".
Creation issues no session.
The only Installation Role is the built-in Installation administrator
(role_admin_<uuid>). The API cannot create other Installation Roles, and the
Namespace policy API does not list this one. Omit roleId for everyone except
another Installation administrator, then grant Namespace access with the
returned principalId; see Add a person.
A representative provisioning body is:
{
"email": "operator@example.invalid",
"password": "<generated-random-password>"
}Emails are normalized to lowercase. Passwords must contain 12–128 characters. Provisioning has no public email-verification or signup flow; duplicates are rejected.
For each protected request, OCC resolves the session user or service-key principal through the selected IAM Driver and authorizes the exact resource operation. Each identity lookup and authorization decision loads current IAM policy, so account and permission changes are visible across controller instances. Caller-supplied identity headers and bearer credentials are not authorization evidence.
| Condition | Result |
|---|---|
| Missing or invalid session or service key on a protected route | 401 UNAUTHENTICATED. |
| Valid credential without the required IAM grant | 403 FORBIDDEN. |
| Duplicate account during provisioning | 409 RESOURCE_CONFLICT. |
| Authentication or IAM dependency unavailable | The request fails closed; dependency failures return 503 DEPENDENCY_UNAVAILABLE. |
Anonymous session inspection returns 200 with data: null.
Non-Agent automation uses x-api-key with an existing IAM ServicePrincipal. Service API keys defines issuance, credential precedence, exact scope, revocation, audit behavior, and failures. Keys do not inherit their issuer’s permissions or create human sessions.
The authentication implementation owns sessions; the HTTP routes own endpoint exposure and provisioning authorization.
- Local authentication tests
- Service-key persistence tests
- Service API key flow
- Issue or rotate a service API key
- IAM overview
- Authorization
- Generated API reference
- Controller settings
[keep this for the user to add notes. do not change between edits]
-
2026-09-30 22:10: Qualify known-browser availability when finite account proof is unavailable. (authoring-run/d22560bc-da4b-4470-ab17-83e8ce51825c - a4daf446aef9eb3571c4b43dac986a6f58d86cf0)
-
2026-09-20 08:53: Replaced native-admin launch-code sessions with the shared OCE session cookie boundary and cookie-domain validation. (cody/01a0b7fd-13fa-7dc2-8653-5c5814b59305 - 5e5f12f37842ae7239d73432e00609547627ded8)
-
2026-08-31 22:29: Define single-attempt bootstrap failure handling with retained artifacts, no automatic recovery, and manual operator repair. (01a05a3d-526f-7553-8cd8-070bd1847acb - 94a5440898bf331987148d7733f0075506af64a6)
-
2026-08-31 17:43: Document fresh human/service administrator bootstrap, private key delivery, and operator recovery. (codex/01a05a69-3fbe-7441-9e6d-20394758cf94 - 0797098646028ac00cb26cd4afcbc9b2cf8bcb24)
-
[2026-08-28 20:17]: Allow IAM-authorized service administrators to issue and revoke keys; retain human-session account creation and bootstrap. (codex/01a04927-11d8-7083-a4b7-9f3124559d82 - d4b5b01d02cf68a89965f7c00a0fc7d0dcec18d8)
-
[2026-08-28 17:54]: Reorganize as a current feature reference; move procedural setup to the shared guides. (01a036f4-cf1d-7cc1-bbc1-000879038ac8 - 4270aa29b7015562049f46c6027962fd85b584a9)