Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Security

The posture you are taking on by self-hosting, including the parts that are your job.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

What the system defends, and — more usefully — what it does not.

Authentication

  • Sessions are rows in the database, carried in an httpOnly cookie (orthacms_session). Every request looks the row up, so revocation is immediate.
  • Passwords are bcrypt. Invite and reset tokens are SHA-256 hashed at rest, single-use, with a TTL.
  • An administrator (users:update) can list a member’s sessions, revoke one, or — since 0.13.0 — Sign out everywhere, which ends all of them but the administrator’s own. There is no self-service session list. Each session records the client IP — correct only with TRUST_PROXY set.
  • Self-service password recovery (since 0.13.0, only with a mail backend) gives no enumeration signal: POST /api/auth/password-recovery answers 202 with the same body before any lookup, and the work runs after the response. Only an active member is sent a link; it is limited per client like sign-in, and silently to three messages an hour per address. Without mail the route is a 404.
  • NODE_ENV=production is what marks the cookie Secure.

Single sign-on

OpenID Connect, SAML 2.0 or GitHub OAuth2. See single sign-on.

  • The core mints state, nonce and the PKCE verifier; adapters mint none. Attempt state is a row, not a signed cookie.
  • Every refusal is the same redirect — no account enumeration through the provider.
  • By default SSO creates nobody and promotes nobody. Provisioning, role mapping and invite acceptance are each opt-in; provisioning requires a domain allow-list.
  • Back-channel logout ends live sessions on offboarding. A provider that cannot verify a logout token gets a 404.

Authorization

Three independent axes:

AxisDecides
Global role — Admin, Contributor, ViewerWhich permission keys you hold. The three seeded roles are the only ones
Workspace membershipWhich workspaces you can reach
Content-type grantsWhich types a workspace holds (own grants) or reads from a shared workspace (shared grants)

On top, per entry: publication protection (approvals before publish) and segments (which readers see a published entry).

There are no per-workspace roles

A contributor is a contributor in every workspace they belong to. To edit one workspace and only read another, you need separate accounts.

Full matrix: permissions.

Shared workspaces

New in 0.7.0 — the one read-only exception to workspace isolation.

  • A workspace flagged shared exposes its published, non-deleted records to workspaces that hold an explicit shared grant naming it, per content type.
  • Opt-in on both sides: sharing a workspace exposes nothing until a consumer adds a shared grant.
  • Writes never cross. A foreign id is a 404 on update, publish, unpublish, delete and every bulk route; agent write tools get a 403 saying the record is read-only.
  • Not transitive. An archived or unshared source exposes nothing; links to it are hidden (kept in the database).
  • Reader entitlements still apply to foreign rows, resolved in the consumer workspace — a library record restricted to the library’s own segments is hidden from consumers.

CSRF

State-changing requests from a browser must carry an Origin in ALLOWED_ORIGINS; anything else is 403 Origin not allowed. Login is covered too. Requests with no Origin (non-browser clients) pass.

Rate limiting

Login is rate limited — 10 attempts per 60 s per client IP by default (LOGIN_RATE_LIMIT, LOGIN_RATE_LIMIT_TTL_SECONDS). The same per-client limit covers the other public auth routes: accepting an invite, redeeming a reset, the SSO handshake and, since 0.13.0, asking for a recovery link. The counts live in Postgres, so several instances share one limit.

The copilot can be limited too, when an operator sets it: runs per minute and runs in flight per user (not per IP), and daily token quotas per user and per workspace. All off by default — see usage limits.

Content reads and writes are bounded by page size and, over GraphQL, by the cost limits — not by a request counter. Put general rate limiting in the reverse proxy.

TRUST_PROXY unset makes the login limit useless

Behind a proxy without it, every request has the proxy’s IP. One attacker’s ten requests a minute lock out every user. Set TRUST_PROXY=1 for one proxy.

API tokens

  • Bearer credentials (orthacms_…), hashed at rest, revocable immediately.
  • Scoped read or full, and bucketed to a set of workspaces.
  • Every authentication failure is one bare 401 — unknown, revoked and expired are indistinguishable.
{ "message": "Invalid API token.", "error": "Unauthorized", "statusCode": 401 }

A full token is a broad credential

No per-type, per-entry or per-locale scoping. A full token can create, update, publish and delete every granted type in every workspace of its bucket — and drive MCP when enabled. Protection is the one brake: a protected type refuses a token’s publish unless its rule allows tokens; no token can approve.

Mint one bucket per consumer. Use read unless writing is the point.

Reader tags are trusted input

The public API reads the reader’s segments from X-Reader-Tags (renamed with SEGMENTS_READER_TAGS_HEADER). It is trusted because the token already is:

  • Send it only from the backend that holds the token. A token in a browser lets anyone claim any audience.
  • No header = the anonymous reader: unrestricted entries only.

Media downloads

Asset MIME types are client-declared, so downloads get nosniff, a restrictive CSP, and Content-Disposition: attachment outside a small inline allowlist (SVG excluded). MEDIA_DIRECT_SERVE=signed-url redirects only after authorization, and only on a backend that pins the response headers. See media storage.

Enumeration

Collapsed on purpose, so a caller cannot confirm an id they cannot see:

  • 401 — unknown, revoked and expired tokens alike.
  • 404 — missing, cross-workspace, deleted and unpublished alike. (A shared source’s published record is visible to a granted consumer — that is the point of sharing.)
  • 422 — a relation or media target that is missing, cross-workspace or disallowed.
  • A type the workspace can read only through a shared grant answers 403 on a write, with the reason; an unreachable type stays 404.

Auditing

  • Changes are recorded in the same transaction that makes them, via the outbox — no window where a change exists unrecorded.
  • Reads are not audited.
  • An event kind with no audit mapper is silently unrecorded — see the activity log.

AI surfaces

Both off by default (COPILOT_ENABLED, MCP_ENABLED).

  • A copilot run holds exactly its user’s permissions. Writes prompt first (Don’t allow / Allow for this chat / Allow once). No publish or approve tool at any role.
  • What the copilot may spend is bounded per run by the run ceilings and, if you set them, per day by the usage limits. Without them, a member with copilot:use can spend as much as your model provider allows.
  • An MCP agent holds whatever its token grants — publish and delete included — with no user to prompt.
  • Prompt injection through content is a real, accepted risk — see the authority model.

What is not defended

Your job, not Ortha's

  • TLS. Nothing terminates it. Use a reverse proxy.
  • General rate limiting / WAF. Only the public auth routes, and copilot runs when you set a limit, are counted.
  • Secrets management. Secrets are environment variables; no vault integration. No signing keys exist to rotate, but provider secrets and API keys are yours.
  • Two-factor authentication. None built in. Enforce it at your identity provider via SSO.
  • Password policy. No complexity, expiry or breach check.
  • Account lockout. Rate limiting is per client, not per account.
  • Audit log integrity. Append-only by convention, not by constraint or hash chain.
  • Encryption at rest. Your database and disk provide it.
  • Field-level or per-entry edit permissions. Permissions are per resource type; segments restrict reading, not editing.

Reporting

Report security issues through the repository’s security reporting channel, not a public issue.