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 cookie sessions, 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. Token verifiers are stored in RUNTIME_STATE under HMAC-derived 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.

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.

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: GraphQL fields require authentication by default unless they are explicitly public, and resolvers check permissions before calling into the core domain layer. The core itself is pure business logic and assumes its caller is authorized, which lets internal callers reuse domain functions without redundant permission checks.

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. Owner is the highest-ranked role and holds every server-scope permission explicitly granted; see the permissions guide for what that means in practice.

Owners and admins can see operational metadata but cannot see user content:

Owners and admins can seeOwners and admins cannot see
User list (login, email, avatar)Message content
Room names, member countsDirect messages
NATS / JetStream metricsFile attachments
System configurationUser passwords

This boundary is intentional and enforced in the resolvers under Query.admin — admin queries do not fetch message bodies. If your community needs message moderation, that has to be designed as a separate, auditable feature; “log in as admin and read DMs” is not supported.

Message bodies are encrypted before being written to JetStream. New messages use a compact versioned envelope:

  • Chatto generates a per-user, purpose-scoped message-body DEK epoch, stores the wrapped key in app-owned RUNTIME_STATE storage, and records only the content-key ref and wrapping metadata in the user event stream as a UserDEKGeneratedEvent.
  • The body is encrypted with XChaCha20-Poly1305 using the author’s active message-body DEK epoch.
  • Each DEK is wrapped through Chatto’s KMS boundary, stored by the application, and addressed by an opaque content-key ref.
  • The encrypted body is authenticated with its event context — event ID, room ID, author ID, message-body DEK epoch, and message-body event type — as Additional Authenticated Data (AAD).

The built-in KMS runs in-process and stores KEKs as protobuf UserKeyEncryptionKey records in a dedicated ENCRYPTION_KEYS NATS KV bucket under opaque kek.* references, with read compatibility for older raw 32-byte kek.* records. App-owned protobuf UserDataEncryptionKey wrapped DEK records live in RUNTIME_STATE under opaque dek.* content-key references. Raw user.* records are legacy direct-key compatibility only. DEK events record their purpose, epoch, content-key ref, wrapping key ref, and wrapping metadata, which keeps Chatto’s durable event shape close to external KMS systems such as Vault, AWS KMS, or HSM-backed providers without making immutable EVT history or the KMS API a permanent DEK registry. Authentication tags are verified on every read; tampered ciphertext or ciphertext copied into the wrong event context is rejected with ErrDecryptionFailed.

This means an attacker who exfiltrates the JetStream data files alone cannot recover message text without also obtaining the KMS KEKs from ENCRYPTION_KEYS or an external KMS.

Older message bodies encrypted directly with the user’s per-user ChaCha20-Poly1305 key remain readable.

Durable user events also encrypt login, display name, and verified email fields with a separate user-PII DEK epoch. Legacy KV user records are imported by emitting encrypted user events, and projections decrypt those fields while the key exists to derive the in-memory login/email indexes used by the API.

When a user is deleted, Chatto:

  1. Retracts visible message bodies the user authored across every room.
  2. Shreds the user’s app-owned DEK refs and KMS wrapping-key refs, rendering remaining encrypted body bytes and durable PII bytes permanently unreadable.
  3. Removes profile fields, the avatar object, push-notification subscriptions, email index entries, and the server membership.
  4. Publishes member-deleted events so other clients refetch and show “Deleted User” in place of any orphaned references.

Once the user’s key is gone, any encrypted ciphertext that happens to survive elsewhere (for example, in long-lived event streams) can never be decrypted again.

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.
  • 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.

Backups encrypt with age (passphrase-based scrypt) when you pass --encrypt. For small self-hosted servers, use a monolithic encrypted backup that includes encryption keys:

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

This is the easiest backup to restore correctly when you do not run a separate KMS or key-management process.

Without --include-keys, the ENCRYPTION_KEYS bucket is deliberately excluded from backups for security reasons:

  • ENCRYPTION_KEYS — keeps backup archives unable to decrypt their own message bodies or durable user PII if leaked.

RUNTIME_STATE is included in backups so active sessions and pending registration, email-verification, password-reset, account-deletion, and OAuth-code flows can survive a restore. Credential entries in that bucket use HMAC-derived keys, so backup archives do not contain redeemable raw bearer tokens, verification codes, links, or OAuth codes. Restoring with a different core.secret_key intentionally invalidates those credentials.

Encryption key records can also be exported and imported separately with chatto keys export / chatto keys import, again age-encrypted. Use this advanced split-backup flow only when you intentionally want data archives and key material stored or retained separately. Restores need the data backup, including the wrapped DEK records in RUNTIME_STATE, plus the KEKs from the separate key export to rebuild encrypted profile indexes as well as message bodies. The same passphrase format means a standalone age CLI can inspect or re-encrypt any Chatto archive.

Backups are outside the live account-deletion flow. Restoring an old backup that includes keys, or restoring an old data backup together with an old key export, can make data deleted after that backup readable again. Set retention on backup material, and use lifecycle rules for remote backup buckets, storage-provider snapshots, and versioned object storage.

Users can delete their own account via the GraphQL API. Deletion is comprehensive (see Crypto-shredding above) and does not leave plaintext message bodies behind. There is no soft-delete or grace period — deletion is immediate and irreversible.

GraphQL introspection and the /api/playground UI are enabled by default for everyone, including production deployments. This is intentional: letting people explore the API is part of the product. Authorization is enforced in the resolvers themselves, so there’s no need to hide the schema.

If you want a private server where the schema is not browsable, place Chatto behind an authenticating reverse proxy.

Two protections apply to every GraphQL request:

LimitValuePurpose
Maximum query depth12Bounds recursion through nested field resolvers
Fixed complexity limit500Bounds the total cost of any single query
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 GraphQL 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.

WebSocket subscriptions 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.

SecretStorageBacked up?
Per-user KEK recordsENCRYPTION_KEYS (NATS KV)No (export separately)
Wrapped app DEK recordsRUNTIME_STATE (dek.* protobuf records)Yes (unusable without KEKs)
Bearer/session token verifiersRUNTIME_STATE (HMAC-keyed, with TTL)Yes (not raw tokens)
Account-flow code/link verifiersRUNTIME_STATE (HMAC-keyed, with TTL)Yes (not raw codes/links)
Core secret keychatto.toml (core.secret_key)n/a
Cookie signing secretchatto.toml (webserver.cookie_signing_secret)n/a
Cookie encryption secret (optional)chatto.toml (webserver.cookie_encryption_secret)n/a
TLS certificatesDisk cache directory (autocert)n/a

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