What the system defends, and — more usefully — what it does not.
Authentication
- Sessions are rows in the database, carried in an
httpOnlycookie (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 withTRUST_PROXYset. - Self-service password recovery (since 0.13.0, only with a mail backend) gives no enumeration signal:
POST /api/auth/password-recoveryanswers202with 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 a404. NODE_ENV=productionis what marks the cookieSecure.
Single sign-on
OpenID Connect, SAML 2.0 or GitHub OAuth2. See single sign-on.
- The core mints
state,nonceand 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:
| Axis | Decides |
|---|---|
| Global role — Admin, Contributor, Viewer | Which permission keys you hold. The three seeded roles are the only ones |
| Workspace membership | Which workspaces you can reach |
| Content-type grants | Which 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
readorfull, 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:usecan 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.