The security model
cairn draws trust boundaries around a site: who may open the admin, what each signed-in role may reach once inside, what a save may write to the repo, and what an author’s markdown may render in a visitor’s browser. Each boundary below states the guarantee, the risk it leaves uncovered, and where the full mechanics live when a sibling page owns them.
The boundary table
| Boundary | cairn handles | Your site handles |
|---|---|---|
| Who may edit | Magic-link delivery, single-use tokens, session rows, CSRF on every unsafe request | The allowlist itself (who holds which of your declared roles), and swapping in a different identity provider if you want one |
| What each role may reach | Enforcing the declared access map at the route, and deriving sidebar visibility from the same check | Declaring which roles reach which screens and routes |
| What a save can write | Author/committer separation, branch confinement, path confinement to your declared concepts | Which repo the GitHub App installs on, branch protection on main, who reviews what lands there |
| What an author’s markdown can render | The sanitize floor (scripts, event handlers, dangerous URL schemes stripped before delivery) | Your own render() function and any component registry you add to it |
Who may edit
An editor never has a GitHub account, a password, or anything cairn has to store and hash. Signing in means clicking a link in email, and everything the boundary needs to enforce sits behind that one action.
Requesting a link looks an email up against the D1 allowlist. A match mints a random 256-bit
token, hashes it before it ever touches storage, and stores only the hash with a 10-minute
expiry. Confirming the link consumes the token in one atomic D1 statement: the row is deleted
and the email returned in the same query, so a token that’s already been used or has expired
simply isn’t there to consume, and two confirms racing the same link can’t both succeed. A
confirmed token creates a session row (30-day expiry) and sets a session cookie, __Host-
prefixed on HTTPS, HttpOnly, and scoped so no script on the page can read it. Every admin
request resolves that cookie against the live session row, joined to the editor’s current
role, and maps it to one of the engine’s three capability levels (owner, editor, or none).
Because that happens on every request, a role change or a removed editor takes effect on the
very next one rather than waiting for a stale session to expire. A role your config no longer
declares still authenticates; it just resolves to no content access, and never locks the person
out of signing in.
The request path never confirms or denies whether an email is on the allowlist: an unknown address gets the identical response as a known one, so the login form can’t be used to probe who’s an editor. The one exception is a deliberate trade the design accepts: a repeated request within a minute returns a distinct throttled response, which does reveal membership, in exchange for not flooding a real editor’s inbox.
CSRF sits at the same boundary, because a stolen session cookie is not the whole attack. An
attacker still has to get the editor’s browser to submit a request it never meant to send. cairn
owns CSRF for every unsafe request under /admin, verified before any route handler runs: a form
post carries a double-submit token that has to match a session-scoped, HttpOnly,
SameSite=Strict cookie, and a raw-body request (the media upload, which can’t spare a form
field) proves the same token through a custom request header instead, which a cross-origin page
can’t set without triggering
a CORS preflight it would fail. The admin guard also serves as the one place that decides an
unauthenticated visitor doesn’t get past /admin/login, refuses a deployed request served over
plain HTTP (a magic link only works if the session cookie can be set), and refuses outright if
the auth database binding is missing, rather than rendering a login form that can never resolve.
Every response the guard lets through carries a baseline of hardening headers regardless: no
framing, no sniffing, no referrer, HSTS.
A crafted ?error= link is inert. Every admin load that can receive an ?error= value resolves
it against a small internal vocabulary before it ever reaches a component, and drops anything it
doesn’t recognize; no admin screen puts the raw query string in front of an editor, whether in a
plain alert or an aria-live region a screen reader announces immediately. This closes a
link-crafting surface: eight loads used to read ?error= and render its value verbatim inside a
branded alert, so a link carrying an arbitrary sentence in the query string could put
attacker-chosen text inside the CMS’s own voice, on a page the editor already trusts. The login
and confirm pages carry the two remaining raw ?error= reads; both treat the value as a boolean
flag, showing or hiding one fixed, engine-authored sentence, never the query value itself.
Residual risk. The email account is now the credential. Anyone who reads an editor’s inbox in the ten minutes after a request can claim their session, which is the trade every magic-link system makes in exchange for never asking a non-technical editor to manage a password.
What each role may reach
Authenticating only decides whether a session exists; a site with more than one kind of editor
also needs to decide what that session may do. cairn’s floor is the three capability levels
(owner, editor, none): an owner-capability role reaches everything, a none-capability role
reaches nothing cairn’s own surfaces guard, and an editor-capability role reaches every one of
cairn’s own screens unless the site narrows it further. A site declares that narrowing once, as an
access map: a target, a screen id or one of its own
/admin-prefixed routes, to the role names admitted to it.
The map’s authority is one function, canReach, and every enforcement point and every visibility
check reads it: the engine’s own route gates (a direct URL to a restricted screen answers 403,
never a silent redirect), the requireAccess helper a site’s own custom route calls, and the
sidebar resolver. Reading the same function everywhere is the guarantee, not an implementation
convenience: a system that decides “what renders” and “what’s allowed” in two separately
maintained places is how a hidden menu item quietly becomes an open, unguarded route, a
vulnerability class documented widely enough to have its own name (hide-in-UI-is-not-authorization,
OWASP A01). Nav placement is never the authorization here; the map, checked at the route, is.
Residual risk. The map narrows by role name, not by row: it can say “a club-admin reaches the money screens” but not “this instructor reaches only their own class,” since that needs state the map doesn’t carry (which class, which instructor). A site that needs row-level scoping still owns that check itself, inside the route the map already gated to the right role.
An href target derived from event.route.id matches the deepest path-segment-prefix key the map
declares. Under a dynamic route segment (/admin/money/[report]), a key deeper than the segment
can never literally match it, so a site that keys both /admin/money and a stricter
/admin/money/payroll, meaning the second to override the first, would see every request the
dynamic segment serves resolve against the shallower rule instead: the deeper rule dead code, with
no error at declaration time. requireAccess and createSectionAction both refuse this shape
outright (403, owner included) rather than silently admitting through the shallower key, so the
site’s own misconfiguration surfaces as a lockout to fix, not a quiet overgrant. A site relying on
per-value authorization under a dynamic segment needs a declared target and its own check inside
the route; the map’s key-matching alone can’t express it.
What a save can write
A save is a Git commit, made through a GitHub App rather than a stored personal token. The
commit’s author is the signed-in editor (read from their verified session, never from request
input), and the committer is left to the App, so git log shows exactly who wrote a line and
that cairn is the tool that landed it. Cairn confines every write it makes to the concept
directories your adapter declares, and every save lands on the entry’s own holding branch
(cairn/<concept>/<id>) rather than main, so nothing an editor writes reaches a reader until a
deliberate publish copies it across. The engine’s connection to GitHub only ever reads files,
commits changes, and manages branches; there’s no query surface for it to leak through.
A related guarantee covers the content graph a save writes into: a reference field’s target
has to actually exist, and the build refuses outright rather than silently linking to nothing.
Reference integrity owns how a rename rewrites every pointing field
and why a delete is blocked while anything still references the entry.
Residual risk. The GitHub App’s installation token can write to any path in the repo it’s installed on; the confinement to your concept directories is enforced by cairn’s own code at the call site, not by GitHub’s permission model. Installing the App on a repo that also holds things you don’t want cairn touching widens that risk more than installing it on a dedicated content repo would.
What an author’s markdown can render
Every path from a markdown file to a byte a browser executes runs through the same rendering
pipeline, whether it’s the editor’s live preview or a visitor’s page. That pipeline applies a
sanitize floor built from the same allowlist GitHub uses to render markdown safely: scripts,
inline event handlers, and javascript:/data: URLs are stripped regardless of what a site
adds on top, and a site’s own extensions can only add to that allowlist, never weaken it.
The render sanitize floor documents that guarantee block by block,
including how a site’s own renderer inherits it.
Residual risk. The floor stops an author’s markdown from executing script in a visitor’s browser. It says nothing about what an author is allowed to write in the first place, since any role with editor capability is trusted by cairn’s design, per who may edit; a site that needs to defend against a malicious editor, rather than an accidental one, needs its own review step before publish.
What the operational logs can reveal
cairn emits a structured log record for most events worth diagnosing (a failed send, a rejected guard request, a commit that didn’t land), and every one of them is written to be safe to paste into an incident channel. A record carries an editor’s email for attribution, and never a magic-link token, a session id, or the contents of the link, even when the record is logging a failure and the temptation is to log everything about it. The log events reference is the exhaustive table of what each event fires on and carries.
Trust boundaries, end to end
%%{init: {"theme": "neutral"}}%%
flowchart LR
editor["Editor's browser"] -- magic link, session cookie --> guard["Admin guard (session + CSRF + origin + HTTPS)"]
guard -- verified editor --> commit["Commit pipeline (author = editor, committer = the App)"]
commit -- installation token --> repo["Repo: main + cairn/<concept>/<id> branches"]
repo -- publish copies to main --> deploy["Your site's deploy"]
deploy --> render["Render pipeline (sanitize floor)"]
visitor["Visitor's browser"] -- requests a page --> render
render -- sanitized HTML --> visitorTwo browsers cross into cairn here, and neither is trusted by default. The editor’s browser is gated by the admin guard and writes only to a holding branch; the visitor’s browser never reaches the admin and receives only what the render pipeline already sanitized.
Reporting a vulnerability
Found something this page doesn’t account for? See SECURITY.md for how to
report it privately.