# Security (/docs/architecture/security)



This page lists where Chelabase draws its trust boundaries and what is not a boundary. Self-hosted mode is the development mode; several defaults below are for local use only.

## Trust boundaries [#trust-boundaries]

**The gateway is the only door.** Every API request is authenticated there. Backend services trust only the gateway's internal identity token: an EdDSA JWT valid for 60 seconds, signed for one target service. The gateway never forwards the client's `Authorization` header, a `Chelabase-User` header or a client-supplied identity header, and calling a service's port directly without a valid token is refused. See [Gateway](/docs/architecture/gateway).

**The internal listener is separate.** Machine-to-machine routes (for Neon's proxy, `compute_ctl`, the storage controller, the gateway, `rest`, `auth` and realtime) are on their own listener, bound to loopback by default, each caller with its own bearer token. Nothing on it goes through the gateway. The default bearer values in a development setup are fixed strings and must not be used outside local development.

**Member logins.** Member roles (`cb_m_*`) log in to a compute only from the orchestrator's address, presenting the orchestrator's client certificate and the member's password. The address rule takes its list from `CHELABASE_MEMBER_LOGIN_CIDRS`; an empty value turns the rule off, and the certificates are then the only check. Workbench stacks also admit the test runner's address, next to the orchestrator's. Replication clients can't become synchronous standbys of a compute.

**Management API.** Callers present a member access token: an EdDSA JWT that `auth` signs for at most 15 minutes (5 by default) from a live sign-in session, and that the gateway, the orchestrator and realtime verify locally with the member public key, never fetching keys. Every request still needs an active membership, so removing or suspending someone takes effect on their next request. Another tenant's resources answer exactly like missing ones (404 `not_found`). See [Identity service](/docs/architecture/identity).

**The identity service is internal.** It holds every account and password hash, listens only on the stack's network (no gateway route, not on the compute network), takes a separate bearer from each caller with per-route allow lists, and connects to Postgres as a role that owns only its own pool schemas. Emailed codes are stored only as keyed hashes.

**Data API keys.** Chelabase stores only the SHA-256 of a key and its first 12 characters. A secret key is shown once and Chelabase keeps no copy; the public key is also stored encrypted so Studio can show it again. Keys resolve to a role, and `rest` refuses to run a request as an owner-level role (one that is a superuser or has `CREATEROLE`, `CREATEDB` or `REPLICATION`, or belongs to `neon_superuser`). A role with `BYPASSRLS` alone is allowed: its key reads past row-level security, and the key list marks it. Revocation takes effect within 30 seconds. Unknown-key lookups are rate limited per client address.

**Row-level security is off by default.** A new table is reachable by the roles that hold default privileges on it; Studio warns about every reachable object without RLS. Policies are yours to write. See [Data API](/docs/architecture/data-api#row-level-security).

**Sessions are reset between requests.** A pooled connection starts every request as the `authenticator` role with default settings, so a function that changes the session cannot leak into another request.

**App users.** Access tokens are short-lived, signed by `auth` and checked by the gateway together with a session-liveness check. Refresh tokens rotate on every use, only their SHA-256 is stored, and presenting an old one after a short grace window revokes the session. See [App users](/docs/architecture/app-users).

**Sign-in cookies.** The login API keeps the session and the step state in encrypted, `HttpOnly`, `SameSite=Strict` cookies scoped to `/auth/v1` (`Secure` under https). Only Studio can turn the session into a token: `/auth/v1/token` requires Studio's `Origin` and `X-Chelabase-Studio: 1`. Answers are padded to a minimum response time (1.5 s by default) and rate limited; a wrong password, an unknown email and no active membership answer alike.

**Errors don't leak internals.** A 503 carries fixed text; what happened goes to the log with the request id.

## Service credentials [#service-credentials]

**NATS.** There is no anonymous access. One account has one user per service (the orchestrator, the worker, realtime and the test runner), each with its own publish and subscribe allow lists and its own reply-inbox prefix (`_INBOX_<service>`), so no service reads another's replies. Nobody gets `allow_responses`: the server doesn't check the permissions of a reply subject, so it would let a client publish to any subject it picks. A responder instead publishes only to its callers' inboxes. Realtime may publish its own subjects and use its two key-value buckets, never a job subject or the control stream. The stack stores only bcrypt hashes of the passwords.

**Valkey.** The `default` user is off. Each service has an ACL user with only the key patterns, channels and commands it uses: the proxy (`cancel:*` keys and its cache channel), the control plane (publishing that channel), realtime (`rt:*` keys only, and no `KEYS`, `FLUSH` or `CONFIG`), the test runner, and a `health` user that can only `PING`. The ACL file holds SHA-256 hashes. Valkey over TLS is a platform-mode setting.

**Storage services.** The pageserver, the safekeepers and the storage controller verify a NeonJWT on their page service, WAL service and HTTP APIs, with scoped tokens per caller (see [Neon](/docs/architecture/neon#storage-authentication)).

**Generated secrets.** Every NATS and Valkey password, realtime's Postgres password, ticket key and internal token, the identity service's secret and database password, every internal bearer (the identity service's included) and the member and app-user token key pairs are files under `.chelabase/`, created once and never rewritten: secrets and private keys are 0600 (Valkey's health password 0640, for Valkey's own healthcheck), public keys 0644. Each is mounted only into the services that read it, through a `_FILE` variable, so none appears in a container's configured environment or on a command line. The proxy reads its Valkey password and its control-plane token from files. The storage controller and the pageserver export theirs from the mounted files into their process environment; see [Realtime](/docs/architecture/realtime#known-gaps).

## Compute and storage tokens [#compute-and-storage-tokens]

**compute_ctl's internal API** (`:3081`) listens only on loopback and requires a per-start secret, kept in a file only the postgres OS user can read. The external API on `:3080` needs a JWT.

**Storage tokens expire.** For a compute that is capable of refreshing them, the tenant token it uses with the pageserver and the safekeepers lives 24 hours. The worker refreshes it every hour, without restarting the compute.

**The `metrics` scope is read-only.** It opens metrics and nothing else. Its token file under `.chelabase/` never expires. Re-minting it (`chelabase neon mint-metrics-token`) does not revoke the old token; revoking means rotating the compute-JWT key pair. After a re-mint, recreate the runner.

**Storage servers check scope first.** Every pageserver, safekeeper and storage-controller route checks its token's scope before it reads the request, for routes that name the tenant in the path. A tenant token can't reach another tenant's routes. No route on the pageserver or the safekeeper takes the tenant from the request body: tenant configuration and safekeeper timeline creation are global-scope only, so a tenant token is refused there.

## The compute network [#the-compute-network]

Computes run on their own `<project>_compute` network and not on the stack's default one. Only the pieces a compute needs share it (the pageserver, the safekeepers, the orchestrator's hooks listener) and the clients that connect to computes (the proxy, `rest`, `auth`, the worker, the orchestrator, the test runner and realtime). NATS, Valkey, the metadata and storage-controller databases, the identity service, Mailpit, SeaweedFS and the gateway aren't on it, so a compromised compute can't reach them. Every listener of a member of the network requires a credential or is public by design (health and OpenAPI routes); `scripts/compute-network-e2e.sh` probes each one. Gaps in that rule are named under [Realtime](/docs/architecture/realtime#known-gaps) and in [Neon](/docs/architecture/neon#the-compute-network).

## Realtime sends keys only [#realtime-sends-keys-only]

Realtime never sends a row. A change reaches Studio as the primary key of the row that changed, and Studio reads the row as the role the viewer acts as, so Postgres (grants and row-level security) decides what each viewer sees. Before a key is sent, realtime runs a read-only check as the viewer's own identity and sends the key only when that viewer's read would return the row; a check that fails or times out sends no keys. A viewer may join a table only if the member check allows the role and the role has `SELECT` on the table and its primary-key columns. Every joined channel is re-authorized every 60 seconds, on each token renewal and on access changes.

The replication login `chelabase_realtime` is `NOBYPASSRLS` and not a superuser, and its output plugin emits only keys, never row values. Logins as `chelabase_realtime` are accepted only from realtime's address and the test runner's, and only with realtime's client certificate. An empty `CHELABASE_REALTIME_LOGIN_CIDRS` turns the address rule off. Realtime never holds owner credentials: owner viewers' checks go through the orchestrator. Tickets are single use, last 30 seconds and are sealed in Valkey with a key only realtime holds. Studio sockets end when the member's token expires. See [Realtime](/docs/architecture/realtime).

## What is not a boundary [#what-is-not-a-boundary]

* **The SQL editor's guard** is a confirmation step. A client that sends `confirm: true` skips it, and it looks at statements, not at what they call.
* **The SQL editor's "run as"**, for an Admin, is a preview of a role's view under RLS: the connection is the owner's. A Developer's run is held to the roles their grant lists (see [Roles and schema isolation](/docs/using/roles-and-schema-isolation)).
* **A protected branch** is guarded against schema changes by event triggers in each database: they refuse DDL from the member logins of non-Admins, and from every other login role when the branch's "guard login roles" switch is on, unless the role is the owner, a superuser or exempt by name (an exemption a guarded role could administer isn't honored). Protecting also needs an Admin to acknowledge a review of code owned by roles other than the owner or a superuser that can run in an Admin's session (triggers, rules, SECURITY DEFINER functions and the rest). The guard covers schema changes only, not data writes, and the review lists code without proving it safe. See [Protected branches](/docs/using/branches#protected-branches).
* **`X-Forwarded-For`** is set by the gateway to the one client address it settled on, replacing whatever the client sent; never use it for authorization.

See [SQL editor](/docs/architecture/sql-editor) for the details.

## Local-only defaults [#local-only-defaults]

* Email verification of sign-ups is off in self-hosted mode (`CHELABASE_REQUIRE_EMAIL_VERIFICATION` overrides it); self-sign-up itself is off unless a tenant admin switches it on, except on a fresh install: while a tenant has no member at all, the first sign-up needs the setup code that `init-secrets` printed and becomes the tenant admin, so claim the instance before you expose it ([Install](/docs/self-hosting/install)). The code is a guard, not a substitute for keeping the host private until the claim; of two people racing for the claim only one wins (the other is admitted as `pending`). Sign-ups are limited per client IP. Behind a reverse proxy the gateway sees the proxy's address, so every client would share one bucket until you list the proxy in `CHELABASE_GATEWAY_TRUSTED_PROXIES`: the gateway then believes the forwarded client address only from those peers and ignores it from anyone else ([Behind a reverse proxy](/docs/self-hosting/hosts#behind-a-reverse-proxy)). On a workbench stack (development), `bootstrap/dev-member.json` (`.chelabase/stacks/<slug>/bootstrap/dev-member.json`) holds the dev member's password, a tenant-admin credential: keep it private.
* Everyone who joins Studio shares one workspace.
* Key material under `.chelabase/` is created once and never overwritten.

## The Scalar reference never phones home [#the-scalar-reference-never-phones-home]

The API reference UI is configured with telemetry, default fonts, the request proxy, external URLs, the agent and MCP all off. See [API generation](/docs/architecture/api-generation#api-reference-ui).

## Security notes in the repository [#security-notes-in-the-repository]

Security findings are kept as dated notes in [`docs/security`](https://github.com/chelabase/chelabase/tree/main/docs/security):

* [Neon event-trigger privilege escalation](https://github.com/chelabase/chelabase/blob/main/docs/security/2026-10-01-neon-event-trigger-escalation.md): a database owner who belongs to `neon_superuser` could become a superuser through an event trigger firing while a trusted extension installs. The fix is carried in Chelabase's Neon fork.
