Features Apograph CMS on GitHub

Security

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

An honest account of what the system does, and — more usefully — what it does not.

Authentication

Sessions are database-backed and revocable, carried in an httpOnly cookie. The cookie is unsigned because validity is a per-request database lookup, which is also what makes revocation immediate: there is no window in which a revoked session still works.

Passwords are bcrypt. Invite and reset tokens are SHA-256 hashed at rest and are single-use with a TTL.

A user can list and revoke their own sessions. Each session records the client’s IP — which is only correct if TRUST_PROXY is set. See configuration.

Single sign-on

New in 0.4.0. A deployment can authenticate against an external identity provider over OpenID Connect, SAML 2.0 or GitHub’s OAuth2.

The core mints state, nonce and the PKCE verifier — adapters generate none of them, so CSRF and replay defence is one rule implemented once. Attempt state is a row, not a signed cookie, keyed by the SHA-256 of the browser’s opaque token exactly as sessions are.

Every refusal renders as the same redirect. The caller is anonymous and the provider is not: told apart, these failures would let anyone who can authenticate at a public provider discover which addresses hold accounts here.

By default an SSO sign-in creates nobody and promotes nobody. Three things a deployment can turn on — just-in-time provisioning, role mapping, and accepting an invitation with a work account — are each off until configured, and provisioning refuses to run without a non-empty domain allow-list.

Back-channel logout is what makes offboarding reach a live session. Without it, a session’s own TTL is the only thing that eventually ends access after somebody is disabled in the directory. Verification is the adapter’s: a provider that cannot verify a logout token gets a 404 rather than an unauthenticated way to sign arbitrary people out.

See single sign-on.

Authorization

One global role per user — admin, contributor, viewer — mapping to permission keys. Routes declare what they need; a guard evaluates it against a pure policy.

Workspace membership is a separate axis: an M:N link deciding which workspaces a user can reach.

There are no per-workspace roles

A user’s role is the same in every workspace they belong to. A contributor is a contributor everywhere. If you need somebody to edit one workspace and only read another, the model does not express it — the nearest approximation is separate accounts.

The full matrix is on permissions.

CSRF

State-changing requests pass an origin check against the configured allowed origins, alongside the session cookie. Login is covered too, since login CSRF is a real attack.

Rate limiting

Login is rate limited — for brute-force and for the CPU cost of bcrypt.

Content reads and writes are not. A token that hammers the API is bounded by page size and, over GraphQL, by the cost limits — not by a request counter. If you need general rate limiting, it belongs in the reverse proxy.

TRUST_PROXY unset makes the login limit useless

Without it, Express ignores X-Forwarded-For and every request reports the proxy’s address. The rate limit collapses into a single bucket for the whole deployment: one attacker’s ten requests a minute deny login to every user.

Set it to the hop count — TRUST_PROXY=1 for one proxy.

API tokens

Bearer credentials for the public API, 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, so the endpoint cannot be used to probe which tokens exist.

A full token is a broad credential

There is no per-type, per-entry or per-locale scoping on a token. A full token can create, update, publish and delete every granted type in every workspace in its bucket — and, with MCP enabled, can be driven by an external agent.

Mint one bucket per consumer, and use read unless writing is the point.

Media downloads

Asset MIME types are client-declared and nothing sniffs the bytes, so downloads are treated as hostile: nosniff, a restrictive Content-Security-Policy, and Content-Disposition: attachment for everything outside a small inline allowlist. SVG is excluded from that allowlist deliberately.

Without this, an uploaded HTML or scripted SVG file would be stored XSS on the admin’s own origin. See media storage.

MEDIA_DIRECT_SERVE=signed-url redirects a download to the storage backend instead of streaming it through the app. It runs after authorization, never instead of it, and only a backend that can pin Content-Disposition and Content-Type on the signed URL may do it — a redirect discards the app’s own headers, and an uploaded .html served inline from a bucket is stored XSS on the bucket’s origin. The plugin refuses to boot on the unsupported combination rather than quietly proxying.

Enumeration

Several distinctions are collapsed on purpose so a caller cannot confirm whether an id they cannot see is real:

  • 401 covers unknown, revoked and expired tokens identically.
  • 404 covers missing, cross-workspace, deleted and unpublished identically.
  • A relation or media target that is missing, cross-workspace or disallowed is the same 422.

Auditing

Changes are recorded in the same transaction that makes them, via the transactional outbox — so there is no window in which a change is applied but unrecorded.

Reads are not audited. Nothing records that somebody looked at a record.

An event kind with no audit mapper is silently unrecorded; see the activity log.

AI surfaces

Both are off by default.

A copilot run holds exactly its user’s permissions and has no identity of its own, so a viewer’s copilot is provably read-only. Writes prompt before running, and there is no publish tool at any role.

An MCP agent is different: it holds whatever its token grants, including publish and delete, with no interactive user to prompt.

Prompt injection through content is a real, accepted risk. See the authority model.

What is not defended

Your job, not Apograph's

  • Transport security. Nothing terminates TLS. Put a reverse proxy in front.
  • General rate limiting and WAF. Only login is limited.
  • Secrets management. Secrets are environment variables. There is no vault integration. There are no session or token signing keys to rotate — both are opaque random values checked against a row — but provider client secrets and API keys are yours to manage.
  • Two-factor authentication. None of Apograph’s own. No TOTP and no WebAuthn — a second factor is something to enforce at your identity provider, which since 0.4.0 is a supported way in. Password login alone has no second factor.
  • Password policy. There is no configurable complexity requirement, expiry, or breach check.
  • Account lockout. Rate limiting is per client, not per account.
  • Audit log integrity. Rows are append-only by convention, not by a constraint or a hash chain. Anybody with database access can edit them.
  • Encryption at rest. Provided by your database and disk, not by Apograph.
  • Field-level or per-entry permissions. Permissions are per resource type.

Reporting

Security issues belong in the repository’s own reporting channel rather than in a public issue.