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.