Security¶
Clauster's guiding principle is fail closed, never silently: auth gates default to denial, and a configuration that would serve an unauthenticated dashboard to the network is refused at startup rather than quietly accepted.
For what Clauster keeps on disk — and how to purge it — see Privacy & data at rest.
Authentication (fail-closed)¶
The auth foundation lives in auth.py as pure, FastAPI-free functions (the web
wiring — middleware and cookie handling in app.py, route handlers in the
routes/*.py modules — sits above it). It offers
three trust paths:
- Password login → a signed-cookie session (
issue_session/read_session). - Bearer token →
Authorization: Bearer …, checked againstauth.api_token_hashor theapi_tokenstable (verify_token). - Reverse proxy → peer-IP allowlist + an HMAC-signed header (
peer_trusted/verify_proxy_hmac). - Cross-site guard → a strict
Originallowlist (build_allowed_origins/normalize_origin). Unlike the credential paths above, this one is not gated onauth.enabled— see below.
The master switch¶
auth.enabled is the master switch for the credential paths. The runtime
guard gates them on it, so password_required or reverse_proxy.enabled
without auth.enabled: true is a silent open door — the operator sets a
password, but the dashboard is still served to anyone. The config validator
refuses that combination on a non-loopback bind.
The cross-site Origin guard is deliberately outside that switch. It is a
CSRF/WS-hijack defence, not an authentication method: a browser page the operator
visits can reach a loopback-bound service regardless of whether a password is
set, so the allowlist is enforced on every unsafe method and every WebSocket
handshake even when auth.enabled is false. With auth off there is no
credential to exempt, so the gate rejects only a present Origin that isn't
allowlisted — an absent Origin (a CLI/script client, never a browser) still
passes.
build_allowed_origins auto-allows 127.0.0.1/localhost/[::1] at
config.port for a loopback bind, and nothing for a non-loopback bind — so the
default deployment needs no configuration. Note what the auto-allow keys on: the
bind host, not the address the browser used. So a non-loopback bind rejects even
http://localhost:<port> — a published Docker port (the image binds 0.0.0.0) is
the common case — and must list its browser-facing origin in auth.allowed_origins.
A loopback bind needs the entry too whenever the browser arrives by some other route:
a reverse proxy or tunnel (the Origin is the public hostname), or an SSH
port-forward onto a different local port (-L 9000:localhost:7621 →
http://localhost:9000, port mismatch). With auth enabled this was already true; the
change extends the same requirement to auth-off deployments, which previously skipped
the check entirely. It fails closed and visibly — a rejected write with
origin check failed, or a WS that won't connect.
Two startup validators¶
ClausterConfig runs a model validator (_loopback_or_authed) that refuses to
start when either of these holds:
- Non-loopback bind without enforced auth. A
hostoutside{127.0.0.1, ::1, localhost}requiresauth.enabled: truetogether withpassword_required(and a hash), anapi_token_hash, orreverse_proxy.enabled— unless you explicitly opt out withauth.allow_unauthenticated_network. password_requiredwith nopassword_hash. This would lock everyone out (or be silently skipped), so startup is refused with a clear message.
The "is auth actually enforced?" question is answered by a single shared helper
(_missing_enforced_auth) so the config validator and the clauster doctor
diagnostics agree.
Passwords¶
Passwords are hashed with argon2id. Generate a hash with:
Store the resulting $argon2id$… string in auth.password_hash (or
CLAUSTER_AUTH_PASSWORD_HASH). Verification uses a constant-time path even when
no password is configured / the attempt is empty, to avoid a "no password set"
timing oracle.
Login throttle (brute-force friction)¶
Failed logins are rate-limited in two layers, returning 429 with a
Retry-After when blocked:
- Per-key — a distinguishable client (its peer IP, or a reverse-proxy-asserted user) is locked after 5 failures within 5 minutes.
- Global backoff — behind a trusted reverse proxy that asserts no user, every login shares the proxy's socket IP, so a per-IP lock would lock everyone out (one attacker DoS-ing all users). In that shared-IP case the per-key lock is skipped; instead, once failures across all clients cross a ceiling, attempts must wait an exponentially-growing interval (capped). A flood degrades to a delay, not a blanket lockout a legitimate user can never get past.
In-process only — not an account-security boundary
The throttle counters live in memory: they reset on restart and are not shared across workers or replicas. This is brute-force friction, not a durable lockout. For an internet-exposed deployment, put Clauster behind a fronting IdP / IAP (or use the reverse-proxy auth) as the real access control — see Networking.
Sessions & cookies¶
- Sessions are signed cookies (
itsdangerous) with server-side revocation — "log out everywhere" bumps a persistent session epoch; cookies issued before the bump are rejected even if they have not yet expired. (The signing secret itself is constant across logouts.) session_max_age_secondsdefaults to 7 days.cookie_securecontrols theSecureflag:autosets it only over https (or behind a trusted proxy reportingX-Forwarded-Proto=https);alwaysforces it;neverdisables it.- Clauster warns at startup when password auth is on but the cookie would likely
ship without
Secure(plain-http LAN, no TLS proxy). Put Clauster behind https / a TLS proxy, or setauth.cookie_secure: always.
WebSockets & origins¶
WebSocket connections are authenticated before accept and origin-checked —
and the origin check runs even when auth.enabled is false (see
The master switch), because a cross-site page can open a
socket to a loopback service regardless of whether a password is set.
Add the proxy domain or any extra trusted origins to auth.allowed_origins.
HTTP security headers¶
A security_headers middleware (app.py) stamps a set of defence-in-depth
headers on every response — including the auth guard's own 401/403/redirect
replies, not just route responses. They layer behind the primary
Origin/CSRF gate above; they are belt-and-suspenders, not the access control.
- Content-Security-Policy — a per-request, nonce-gated policy. The
baseline is
default-src 'self';script-srclists a fresh per-request'nonce-<value>'(asecrets.token_urlsafe(16), never a process-wide constant) so only the inline<script>blocks carrying the matchingnonce="…"attribute run.'unsafe-inline'is dropped fromscript-srcentirely — that is what blocks an injected inline script that lacks the nonce — and'unsafe-eval'is dropped too: Clauster ships the CSP-friendly@alpinejs/cspbuild (alpine.csp.min.js,'self'-allowed), so Alpine no longer needs anew Function()evaluator.frame-ancestors 'none'andobject-src 'none'round out the clickjacking / plugin surface.style-srcis nonce-gated the same way: its inline<style>blocks carry the matching nonce and'unsafe-inline'is dropped from it too — the only constraint that leaves is that Alpine:stylebindings must use the object form. (If the nonce is ever absent on a degraded path the policy stays stricter, never looser —'unsafe-inline'is omitted from both regardless.) X-Frame-Options: DENY— refuses framing outright (a legacy companion toframe-ancestors 'none').X-Content-Type-Options: nosniff— stops MIME-type sniffing.Referrer-Policy: same-origin— deliberatelysame-origin, notno-referrer. Underno-referrera spec-compliant browser serializes theOriginof a same-origin native<form>POST navigation as the literalnull, which the CSRFOrigingate then rejects — silently403-ing the login/logout forms.same-originkeeps the realOriginon same-origin navigations while still suppressing the referrer cross-origin. (Safe only while no secret rides a same-origin URL — Clauster credentials are all cookie/header-borne.)Strict-Transport-Security— emitted only over HTTPS (reusing the same secure-cookie detection), so a plain-HTTP LAN deployment never pins a browser to a scheme it can't serve. NoincludeSubDomains, to avoid bricking a sibling subdomain on a shared parent domain.
Exposing beyond the LAN¶
Clauster defaults to a loopback bind and assumes trusted host-local infrastructure — the gates above harden that model. But the README pitches phone/remote use, and many will expose Clauster to a hostile network (Tailscale Funnel, Cloudflare Tunnel, a public reverse proxy). Host hardening itself — OS patching, firewalling, and the tunnel/proxy you front with — stays your responsibility; the notes below are the Clauster-side minimums for that case.
Require TLS¶
Never serve the dashboard or its session cookie over plain HTTP off-host.
Terminate TLS at a proxy or tunnel and force the secure cookie with
auth.cookie_secure: always, so the cookie can't ride a downgraded request in
the clear. Clauster already warns at startup when password auth is on but the
cookie would likely ship without Secure — on a hostile network, treat that
warning as a hard stop.
Front with an identity-aware proxy¶
A single shared password is the weakest link for an internet-exposed deployment.
Put an IdP / IAP in front — an SSO/forward-auth proxy (Authelia, Authentik,
Cloudflare Access, Pomerium, oauth2-proxy) or a private overlay (Tailscale,
WireGuard) — so a real identity is checked before a request reaches Clauster.
The reverse-proxy path (peer-IP allowlist + an HMAC-signed user header,
auth.reverse_proxy) is built to trust exactly such a proxy. The proxy's
identity check is admission, not access control: Clauster is single-operator
by design, so whoever the IdP admits acts with the one operator's full host
control, and the config-write audit trail attributes actions to the constant
admin actor rather than the IdP identity. The IdP group is an on/off switch
for the whole host — it should contain exactly one person. Once it is your
primary gate, prefer dropping password_required entirely — the /login
route is deliberately exempt from the auth middleware (so a locked-out operator
can always reach it), which means there is no Clauster-side option to restrict
it to loopback. If you need to keep a password fallback, the fronting proxy must
block or 403 POST /login from non-loopback sources — that is the only
enforcement point.
The login lockout is friction, not a boundary¶
The built-in failed-login throttle (per-key lock + a global backoff, returning
429 + Retry-After) raises the cost of brute force, but its counters are
in-process only — they reset on restart and aren't shared across workers or
replicas, and behind a shared-IP proxy the global path degrades to a bounded
delay rather than a hard lockout. It is brute-force friction, not an
account-security boundary — the fronting IdP/IAP above is. See
Login throttle.
CSRF & sessions on a hostile network¶
- Add the public origin(s) to
auth.allowed_origins: the strictOriginallowlist on unsafe methods (and on WebSocket accept) is the CSRF guard, so an origin you don't list is rejected. Set it to your public URL (e.g.https://clauster.example.com), not the internalhost:bind address — when TLS terminates at a proxy, theOriginClauster sees is the public hostname, so a bind-address value silently403s every request. - Sessions are signed cookies with server-side revocation — logout bumps a
persistent epoch, so "log out everywhere" instantly kills a cookie that may
have leaked. Keep
session_max_age_secondsshort for an exposed deployment. SameSite=Lax+Secure(above) limit cross-site cookie replay; the fronting proxy must not strip or forge theOrigin/ forwarded headers Clauster checks.
Workspace trust¶
A bridge refuses to spawn in an untrusted directory. Trust lives in
~/.claude.json under projects[<resolved-abs-path>].hasTrustDialogAccepted. A
git repository requires its own trust grant: Claude Code 2.1.232 stopped
honoring a parent directory's grant for nested git repos, so a projects-root grant
no longer cascades to the repos under it. A non-repo directory still inherits from a
trusted ancestor. Clauster mirrors this exactly, so the dashboard never shows a repo
as trusted that the CLI would reject at spawn — it fails closed.
Two trust actions, both setting the flag for the project's own key:
- Trust & start — launching a session in an untrusted directory shows a confirm dialog (with a safety checkbox) that trusts the directory, then spawns.
- Trust all — a dashboard banner, shown while any discovered project is untrusted, grants every discovered project its own key at once. It exists to reconcile an install after the 2.1.232 change, where repos trusted only via a parent grant now read untrusted.
Trusted directories show a green shield and start with no prompt.
The claude CLI writes the same file concurrently, so the trust writer
(trust.py) guards it with two layers:
- Atomic replace (temp file +
os.replace) so no reader ever sees a half-written file, and every key Clauster doesn't touch is preserved. - An advisory
flockheld across the whole read-modify-write, taken on a lock file under<state_dir>/locks/keyed by the target's real path — never the target itself (os.replaceswaps the inode) and never a<file>.locksidecar beside it, which for a project.claude/settings.jsonwould land inside your git-tracked tree. This serializes Clauster's own concurrent writers and shrinks the window against the CLI to the gap between the read-under-lock and the replace. POSIX only; on platforms withoutfcntlit degrades to a best-effort no-op (the atomic replace still prevents a torn file). Because the lock file is keyed under one deployment'sstate_dir, the cross-process guarantee covers a single Clauster deployment — the same scope as the config andCLAUDE.mdwriters, which share this lock.
A one-time .bak is taken before the first modification.
Upgrading from 1.0.2 or earlier? Those versions wrote the sidecar beside the target, so you may still have a 0-byte
settings.json.lock/.mcp.json.lock/.claude.json.lockon disk. Clauster no longer creates or uses them and deliberately does not delete them — a file inside your project is yours, and unlinking a lock file is the very race the pattern avoids. Remove them by hand (and, if you committed one,git rmit) once no older Clauster is running.
Auto-enable remote control¶
Before the first spawn, Clauster marks remote control acknowledged
(hasUsedRemoteControl / remoteDialogSeen) in the runtime user's
~/.claude.json. Without this a detached-stdin bridge would block forever on the
one-time interactive "Enable Remote Control? (y/n)" prompt it can never answer.
On by default (claude.auto_enable_remote_control); set false to manage the
flags yourself.
Log redaction¶
The bridge debug log is streamed over a WebSocket and sanitized line by line
(redact.py). Redaction runs against the view a browser renders. That view
has the escape sequences removed, including the OSC, DCS, SOS, PM and
APC string sequences and their payloads. It also has the invisible control
characters removed, and the invisible Unicode characters a browser draws nothing
for: zero-width spaces and joiners, the bidirectional controls, the variation
selectors, the soft hyphen, the byte-order mark and the other code points in the
Unicode Default_Ignorable_Code_Point set. None of these can therefore split an
identifier into fragments too short for the word-boundary-anchored regexes. A
side effect is that these characters are deleted from every redacted view this
code produces, not only the streamed log. sanitize_line also redacts each
transcript turn (the usage view) and the streamed hosted-agent assistant text,
and redact_for_disk redacts instance.error_detail, clone-job errors and agent
result text. Some of those surfaces are conversational prose, so the effect is
visible there: a joiner inside an emoji sequence (a family emoji) renders as its
separate glyphs, and a bidirectional mark in an Arabic or Hebrew turn is dropped.
Each is a redacted view, not the verbatim on-disk log, so this is accepted:
redaction wins over character fidelity in a redacted copy.
Removing those bytes can also delete a word boundary. user<ESC>[32menv_<ULID>
renders as userenv_<ULID>, which reads exactly like ordinary compound text such
as userenv_production. Clauster therefore records where each removal happened
and re-tries the same masks anchored at those positions. Tab, carriage return and
newline are never removed. They are visible separators, and the line structure
depends on them.
Three layers:
- ID redaction (primary guarantee). Masks
env_/session_/cse_identifiers (the prefix is kept readable) — these act as bearer-equivalent credentials in a URL — and bare UUIDs (account / instance identifiers the bridge prints in full; not bearer credentials, but kept off the stream). The live pty-screen view masks these to the neutral<redacted>token instead, with no readable prefix. The<the token adds is itself a word boundary, so it closes an identifier welded to another identifier. - Secret-shape redaction (defense-in-depth). A conservative allow-list of
obvious secret shapes — GitHub tokens (
ghp_/gho_/… ,github_pat_), Clauster API tokens (clauster_pat_…), GitLab PATs (glpat-), AWS access-key IDs (AKIA…), OpenAI/Anthropic-stylesk-…, Slackxox[baprs]-…, andAuthorization: Bearer …headers. - The bridge's own
[REDACTED]output for most secrets — never relied on alone.
Known limitation (by design)
The secret-shape layer is a shape allow-list anchored on word
boundaries. It will not catch a novel/unstructured high-entropy secret —
a bearer value that isn't literally Bearer …, a raw JWT, or a vendor token
whose prefix isn't listed. That is acceptable because it is
defense-in-depth: the primary WebSocket guarantee is the
env_/session_/cse_ + UUID redaction. Add new shapes as they appear
rather than assuming coverage.
Bounded scope: an identifier written mid-token stays visible
A mask needs a word boundary before the identifier, or the position of a
removed escape. An identifier whose preceding characters were written as
plain text (xyzenv_<ULID>) has neither, so it is not masked. That is a
different threat: producing it means already controlling the whole line, and
an attacker who can print arbitrary text beside an identifier does not need
to smuggle it past the mask. The case that matters is the escape weld, where
Clauster's own bridge prints the real identifier and an injected escape only
deletes the boundary. This bounded scope is for the streamed log and the
on-disk mirror. A terminal has already discarded the escape on the live
pty-screen view. So that view also masks a real identifier (the 01 shape)
welded onto the word before it, and an id welded to another id. It also masks
a UUID that a greedy id or secret token welded onto. A ghp_ or
github_pat_ token has no separator, so it eats the UUID's leading hex
digits. An ordinary compound name such as resolve_session_transcript stays
readable. A welded secret, and a welded id without the 01 shape, stay
visible there. The fixed-length AKIA… key stays visible together with the
UUID it welds to, because it cannot backtrack.
Hybrid by default¶
Redaction is hybrid by default: the on-disk log keeps IDs verbatim (for local
debugging), and only the WebSocket stream is redacted. Set
logs.redact_session_url: true to redact the session URL on disk too. ANSI
stripping in the stream is controlled by logs.strip_ansi_in_stream (default
on).
Clone / SSRF guards¶
Project clone URLs are user-supplied and hit the network from the host, so
clone defaults are strict: only https / ssh schemes, private/LAN IP targets
blocked by default (allow_private_hosts: false), a size cap, and a timeout.
Targeted LAN access is an explicit allowed_private_cidrs opt-in — each entry is
validated as a CIDR at load so a malformed allow-list entry fails fast instead of
silently never matching.
Outbound lifecycle webhooks (webhooks.py) are the host's other egress
path, so they carry their own opt-in SSRF deny-list. Set
webhooks.block_private_targets: true (default false) to drop any webhook URL
whose host is — or resolves to — a loopback / link-local / private / CGNAT /
metadata IP, using the same private-range classifier as the clone guard
(it imports provisioning._EXTRA_PRIVATE_NETS). Default-off preserves the
LAN-receiver use case. See
webhooks
in the configuration reference for the field.
bypassPermissions footgun gate¶
A bridge can never be spawned with --permission-mode bypassPermissions unless
the project sets allow_bypass_permissions: true in clauster.yml (the hard
ceiling). The dashboard's per-session typed-confirm is the second layer.
The inherit permission mode (No forced mode in the launch picker) cannot itself
request bypassPermissions, and Clauster refuses to write that value into any
settings.json it manages. But the caveat is structural: because no
--permission-mode flag is passed, an existing permissions.defaultMode in the
project, local, or user settings file takes effect — including a bypassPermissions
one that was hand-edited, committed to the repo, or enterprise-managed, which Clauster
cannot prevent. Before inherit existed, the always-passed flag overrode any such
file; with inherit, that file wins. On a project with
allow_bypass_permissions: false, prefer an explicit mode — or audit the project's
settings files before offering inherit to its operators. The mode is still screened
against the allowed set before any spawn, so no unrecognized string ever reaches the
subprocess, and inherit is not a value this deployment can write into
permissions.defaultMode: it is a Clauster launch-time sentinel, not a claude mode.
Ghost-environment reaper¶
The reaper (clauster reap-environments) defaults to a dry run and fails
closed: if it cannot enumerate the live bridge set it aborts rather than risk
archiving a still-live environment. The destructive dashboard surface is off
by default and gated by reaper.ui_enabled; archive is reversible, force-delete
requires typing DELETE.
It is also scoped to this instance's projects_root. The environment list is
account-wide while the liveness check is instance-scoped — it sees only this OS
user's sessions and this instance's projects — so an environment outside
projects_root is unattributable and never reaped. Without that rail, reaping on
one instance could archive another instance's live environment and tear down
its running session. See
clauster reap-environments
for the scope rules, including the shared-projects_root caveat.