Skip to content

Security & Privacy

This page describes the security and privacy features built into Chatto today.

Passwords are hashed with bcrypt at the library default cost. Chatto enforces a minimum length of 8 characters and a maximum length of 128 bytes so bcrypt never silently truncates user input.

Password hash changes are durable user events in the EVT stream and are read through the user projection. Raw passwords are never written to storage, audit events, logs, or live subscriptions. Password changes and resets also advance the user’s auth generation, so older first-party session credentials, bearer tokens, and OAuth authorization codes are rejected.

Login comparison runs bcrypt against a dummy hash for unknown users and OAuth-only users so the response time is identical regardless of whether the account exists. This blocks the most basic form of user enumeration via timing.

Chatto issues opaque bearer tokens, not JWTs. Bearer-token and same-origin cookie-session handles are stored as typed runtime credentials in RUNTIME_STATE under HMAC-derived session.{hmac} keys, with a sliding TTL: every successful validation re-puts the entry, extending its expiry. Revocation is a single KV delete — there is no signed-but-still-valid window to worry about. Deprecated legacy cookie-session records remain readable for now so upgrades do not sign out existing browser sessions.

Browser sessions use signed cookies with the following options:

OptionValue
HttpOnlytrue
SameSiteLax
Securetrue when webserver.url is https://
Path/
MaxAge90 days

Session cookies are always signed with webserver.cookie_signing_secret. They are additionally encrypted if webserver.cookie_encryption_secret is set. When the encryption secret is missing, Chatto logs a startup warning — running chatto init on a fresh server generates both secrets automatically.

New browser session cookies store only an opaque runtime credential handle. The user ID, source, auth generation, and fresh-auth metadata live in the server-side session.{hmac} runtime credential record.

Registration sends a six-digit verification code with a 15-minute TTL. An email address must be verified before it can be used for password reset or for matching against the owners.emails config (see Authorization below). Email lookup is case-insensitive — addresses are normalized and indexed by SHA-256 hash for the lookup bucket.

The registration endpoint always returns 200 OK regardless of whether the email is already claimed, so it cannot be used as an oracle to enumerate registered addresses.

Reset tokens have a 1-hour TTL and expire automatically via NATS KV TTL — no scheduled cleanup is required. A reset request for an unknown address returns the same response as a successful one.

When a Chatto frontend connects to another Chatto server, it uses /oauth/authorize with PKCE to mint a bearer token for that remote server. The remote server only redirects authorization codes back to trusted frontend origins.

For a known hosted frontend, list the exact callback origin:

[webserver]
oauth_redirect_origins = ["https://app.example.com"]

You can also allow any valid HTTPS Chatto frontend to connect:

[webserver]
oauth_redirect_origins = ["*"]

Use * only when you intentionally want open connectivity. It allows any HTTPS origin to start a Chatto OAuth authorization request. PKCE prevents passive interception of the code, and Chatto still shows the user a consent screen for each redirect origin, but a malicious site can initiate its own flow and ask a logged-in user to approve it. If the user approves, that site receives an authorization code it can exchange for a bearer token.

Chatto enforces access control at the API boundary: ConnectRPC services require authentication by default unless they are explicitly public, and public operation models check permissions before touching domain state.

Chatto uses a role-based permission system with four built-in roles (everyone / moderator / admin / owner), custom roles, room-specific overrides, and per-user grants and denies. The full model is covered on its own page:

The first owner is designated via owners.emails in chatto.toml. The match runs against verified emails only, so claiming an address you can’t receive mail at won’t grant you ownership. When a matching email is verified, the user is auto-promoted to the owner role — no restart required.

Owners are effective-owner overrides: they are allowed for every normal RBAC permission, and their permissions are virtual rather than stored as editable grants. Roles still have display order, but Chatto does not use role position as an authorization rank. See the permissions guide for the full resolution model.

The dedicated administrative APIs expose account and operational data; they do not provide a special endpoint that returns every conversation. This application boundary is not a cryptographic boundary. Someone who controls the running server, its storage, and its encryption keys has more access than an in-app administrator.

See Encryption at Rest and Data Erasure for the exact field-level encryption coverage and threat model.

Chatto encrypts message text and selected durable user-PII fields before writing them to storage. Attachments, avatars, event metadata, and many other records are outside that field-level envelope. Account deletion attempts to crypto-shred the protected fields’ keys and separately remove user-owned asset bytes.

Chatto has built-in Let’s Encrypt support via Go’s autocert package — no Caddy or external reverse proxy required for HTTPS. When webserver.tls.enabled = true:

  • Certificates are obtained and renewed automatically for the configured domain.
  • On Unix, the autocert cache directory must be owned by the Chatto process user and is restricted to 0700 at startup, including when the directory already exists. The cache directory and its immediate parent cannot be symlinks; the parent must be owned by the process user or root and cannot be group- or world-writable.
  • MinVersion: TLS 1.2 is enforced.
  • Plain HTTP requests are 301-redirected to HTTPS (except ACME challenges).

TLS is not required. Chatto can also run plain HTTP, which is useful for local development or when you terminate TLS at your own proxy. If you terminate TLS upstream, make sure webserver.url starts with https:// so the Secure cookie flag is set correctly.

webserver.url is also the trusted public origin for generated absolute API and asset URLs. Chatto does not implicitly trust X-Forwarded-Proto or similar proxy headers for public URL generation; reverse-proxy deployments should set webserver.url to the external HTTPS origin.

Forwarded host and client-IP headers are ignored unless the request’s direct peer matches webserver.trusted_proxies. Add only the IP address or narrow CIDR that Chatto actually sees as its reverse-proxy peer:

[webserver]
url = "https://chat.example.com"
trusted_proxies = ["172.20.0.0/24"]

The proxy must overwrite X-Forwarded-For, X-Real-IP, and X-Forwarded-Host at the trust boundary. Do not use broad ranges that include untrusted workloads or clients. Proxies that preserve the original HTTP Host do not need this setting for same-origin WebSocket connections, but they do need it if audit records should attribute the originating client IP instead of the proxy IP.

Backups encrypt with age when you pass --encrypt. Passing --include-keys also includes Chatto’s built-in key-encryption-key bucket and makes the archive capable of restoring protected message text and user PII:

Terminal window
chatto backup -c chatto.toml --encrypt --include-keys

Without --include-keys, the ENCRYPTION_KEYS bucket is excluded. You can export those keys separately with chatto keys export. Treat either form as sensitive and align old data and key retention with your account-deletion policy.

The Operator API is disabled by default. Operators can enable it with [operator_api] / CHATTO_OPERATOR_API_* configuration for trusted local automation such as chatto operator user create and password/bootstrap workflows. It listens on a Unix socket, not TCP, and uses filesystem permissions as its access boundary.

The default socket path is Docker-friendly:

[operator_api]
enabled = true
socket_path = "/tmp/chatto/operator.sock"

Chatto serves only chatto.operator.v1 on the operator socket, and the public web listener never mounts the Operator API. The inverse is also true: the operator socket does not serve public or admin ConnectRPC services. The socket mode is fixed at 0600, and the parent directory must be private to the Chatto process user. This is intentional defense in depth, so normal public reverse-proxy routing for Chatto’s web UI and public APIs cannot accidentally expose root-grade bootstrap access.

The chatto operator ... CLI reads the socket path from --operator-socket, CHATTO_OPERATOR_API_SOCKET_PATH, or chatto.toml, falling back to /tmp/chatto/operator.sock. In Docker, run the command inside the container with docker exec or mount the socket path into a trusted sidecar/container with matching filesystem permissions. See Operator CLI for command examples.

Operator actions are attributed to Chatto’s system actor, not to an in-app user or RBAC role. Keep the socket path private to the Chatto process and explicitly trusted automation; for password workflows, prefer --password-stdin and --password-file over direct secret flags.

The public request/response API is ConnectRPC under /api/connect, with protobuf definitions published in the API reference. Authorization is enforced by the API handlers and shared operation models, so sensitive operations remain permission-gated even though the API shape is documented.

If you want a private server where no unauthenticated traffic reaches Chatto, place Chatto behind an authenticating reverse proxy.

ConnectRPC requests have explicit read limits:

LimitValuePurpose
General protobuf request body1 MiBBounds non-upload request size
Upload request bodyConfig-derivedAllows configured attachment uploads plus protobuf overhead
Room event pagination limit500 per fieldBounds historical room event windows and jump-to-message reads

There is no per-IP or per-user rate limiting at the application layer. If you expose Chatto on the public internet, a request-rate limiter (e.g. Caddy rate_limit, Cloudflare, or an upstream WAF) is recommended.

CORS allows the configured webserver.url, the local listen port (for development), and any explicit entries in webserver.allowed_origins. Credentials (cookies) are only attached for explicit origins; wildcard mode omits credentials, forcing token-based auth in that case.

Realtime WebSocket connections perform the same origin check before the upgrade is accepted.

OAuth redirect callbacks use a separate trust list, webserver.oauth_redirect_origins. See Chatto frontend OAuth redirects.

Every frontend response sets:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin

Original attachment responses also set X-Content-Type-Options: nosniff. Uploaded active document formats such as HTML, XHTML, SVG, and XML are served with Content-Security-Policy: sandbox; S3-backed files of those types stream through Chatto instead of redirecting directly to object storage so that policy is preserved.

For HSTS, CSP, and other policy headers, add them at your reverse proxy.

Secret or key materialStorageBacked up by chatto backup?
Per-user KEK recordsENCRYPTION_KEYS (NATS KV)No (export separately, or use --include-keys)
Per-call LiveKit E2EE keysENCRYPTION_KEYS (NATS KV, short-lived)No (active calls are not recoverable without them)
Wrapped app DEK recordsRUNTIME_STATE (dek.* protobuf records)Yes (unusable without KEKs)
Runtime credential verifiersRUNTIME_STATE (session.{hmac}, HMAC-keyed, with TTL)Yes (not raw bearer tokens or cookie handles)
Account-flow and OAuth-code verifiersRUNTIME_STATE (HMAC-keyed, with TTL)Yes (not raw codes or links)
Core secret keychatto.toml / CHATTO_CORE_SECRET_KEYn/a
Cookie signing secretchatto.toml / CHATTO_WEBSERVER_COOKIE_SIGNING_SECRETn/a
Cookie encryption secret (optional)chatto.toml / CHATTO_WEBSERVER_COOKIE_ENCRYPTION_SECRETn/a
Asset URL signing secretchatto.toml / CHATTO_CORE_ASSETS_SIGNING_SECRETn/a
S3 access key ID and secret access keychatto.toml / CHATTO_CORE_ASSETS_S3_ACCESS_KEY_ID, CHATTO_CORE_ASSETS_S3_SECRET_ACCESS_KEYn/a
NATS embedded/client credentialschatto.toml / CHATTO_NATS_EMBEDDED_AUTH_TOKEN, CHATTO_NATS_CLIENT_TOKEN, CHATTO_NATS_CLIENT_PASSWORD, CHATTO_NATS_CLIENT_CREDENTIALS_FILE, or CHATTO_NATS_CLIENT_NKEY_SEEDn/a
OAuth/OIDC provider client secretschatto.toml [[auth.providers]] / CHATTO_AUTH_PROVIDERS_<index>_CLIENT_SECRET or legacy CHATTO_AUTH_OIDC_CLIENT_SECRETn/a
SMTP passwordchatto.toml / CHATTO_SMTP_PASSWORDn/a
Web Push VAPID key pairchatto.toml / CHATTO_PUSH_VAPID_PUBLIC_KEY, CHATTO_PUSH_VAPID_PRIVATE_KEYn/a
LiveKit API and webhook secretschatto.toml / CHATTO_LIVEKIT_API_KEY, CHATTO_LIVEKIT_API_SECRET, CHATTO_LIVEKIT_WEBHOOK_API_KEY, CHATTO_LIVEKIT_WEBHOOK_API_SECRETn/a
TLS certificatesDisk cache directory (autocert)n/a

core.secret_key is not a message-encryption KEK. Rotating it invalidates bearer tokens, cookie-session handles, OAuth authorization codes, and pending account-flow credentials, but it does not rotate or invalidate the KEK records in ENCRYPTION_KEYS.

For details on how to report security vulnerabilities, see the project README.