Security & Privacy
This page describes the security and privacy features built into Chatto today.
Authentication
Section titled “Authentication”Password storage
Section titled “Password storage”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.
Sessions and tokens
Section titled “Sessions and tokens”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:
| Option | Value |
|---|---|
HttpOnly | true |
SameSite | Lax |
Secure | true when webserver.url is https:// |
Path | / |
MaxAge | 90 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.
Email verification
Section titled “Email verification”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.
Password reset
Section titled “Password reset”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.
Chatto frontend OAuth redirects
Section titled “Chatto frontend OAuth redirects”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.
Authorization
Section titled “Authorization”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:
Initial ownership
Section titled “Initial ownership”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.
The privacy boundary
Section titled “The privacy boundary”Owners and admins can see operational metadata but cannot see user content:
| Owners and admins can see | Owners and admins cannot see |
|---|---|
| User list (login, email, avatar) | Message content |
| Room names, member counts | Direct messages |
| NATS / JetStream metrics | File attachments |
| System configuration | User 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.
Encryption
Section titled “Encryption”Message bodies at rest
Section titled “Message bodies at rest”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_STATEstorage, and records only the content-key ref and wrapping metadata in the user event stream as aUserDEKGeneratedEvent. - 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.
Crypto-shredding
Section titled “Crypto-shredding”When a user is deleted, Chatto:
- Retracts visible message bodies the user authored across every room.
- Shreds the user’s app-owned DEK refs and KMS wrapping-key refs, rendering remaining encrypted body bytes and durable PII bytes permanently unreadable.
- Removes profile fields, the avatar object, push-notification subscriptions, email index entries, and the server membership.
- 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.
Transport (TLS)
Section titled “Transport (TLS)”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.2is 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.
Backup security
Section titled “Backup security”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:
chatto backup -c chatto.toml --encrypt --include-keysThis 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.
Account deletion
Section titled “Account deletion”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.
API surface
Section titled “API surface”Introspection and the playground
Section titled “Introspection and the playground”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.
Query limits
Section titled “Query limits”Two protections apply to every GraphQL request:
| Limit | Value | Purpose |
|---|---|---|
| Maximum query depth | 12 | Bounds recursion through nested field resolvers |
| Fixed complexity limit | 500 | Bounds the total cost of any single query |
| Room event pagination limit | 500 per field | Bounds 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 and WebSockets
Section titled “CORS and WebSockets”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.
HTTP security headers
Section titled “HTTP security headers”Every frontend response sets:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-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 and key storage
Section titled “Secret and key storage”| Secret | Storage | Backed up? |
|---|---|---|
| Per-user KEK records | ENCRYPTION_KEYS (NATS KV) | No (export separately) |
| Wrapped app DEK records | RUNTIME_STATE (dek.* protobuf records) | Yes (unusable without KEKs) |
| Bearer/session token verifiers | RUNTIME_STATE (HMAC-keyed, with TTL) | Yes (not raw tokens) |
| Account-flow code/link verifiers | RUNTIME_STATE (HMAC-keyed, with TTL) | Yes (not raw codes/links) |
| Core secret key | chatto.toml (core.secret_key) | n/a |
| Cookie signing secret | chatto.toml (webserver.cookie_signing_secret) | n/a |
| Cookie encryption secret (optional) | chatto.toml (webserver.cookie_encryption_secret) | n/a |
| TLS certificates | Disk cache directory (autocert) | n/a |
Reporting a security issue
Section titled “Reporting a security issue”For details on how to report security vulnerabilities, see the project README.