Auth & tenancy
Status: current — 2026-09-13 · Owner: platform
Two separate identity systems live in this codebase and must not be confused: Clerk authenticates the people who build sites (builders, teammates, agencies), and the first-party Member/Visitor Auth system (D73/VA-3) authenticates the end users of a published site (subscribers, customers, gated-content readers). A Clerk session never reaches a published page; a member session never reaches the editor API. This doc covers both, plus the RLS layer that sits under the Clerk side. See also
ARCHITECTURE.mdfor the wider system map anddocs/architecture/TERMINOLOGY.mdfor canonical naming (this doc follows both without editing either).
Part 1 — Clerk: builders and projects
Section titled “Part 1 — Clerk: builders and projects”Identity provider
Section titled “Identity provider”Clerk is the sole identity provider for the editor. The frontend wraps the app
in ClerkProvider (frontend/src/main.tsx), exposes /sign-in//sign-up
outside AuthGuard, and attaches the Clerk session token as a Bearer header
on every API call (frontend/src/lib/api/client.ts). There is no
authStore — components read identity via Clerk’s own hooks.
On the Worker side, authMiddleware (workers/src/middleware/auth.ts) is the
first gate on every authenticated route: it calls @clerk/backend’s
verifyToken(token, { secretKey: c.env.CLERK_SECRET_KEY }), and on success
sets userId (the JWT sub), userEmail/userName (from JWT claims, when
Clerk’s session token is configured to carry them), and the active
organization claim: v2 session tokens carry o: {id, rol}, v1 tokens
carry org_id/org_role — the middleware reads both shapes and stashes
orgId/orgRole. DEV_BYPASS_AUTH=true short-circuits the whole thing to a
fixed user_dev_bypass identity for local dev (never set in a deployed env).
The org IS the project
Section titled “The org IS the project”Under the B2B2C / Split-Glass pivot (landed 2026-07-03), the Clerk
organization is the project — there is no separate project-membership
concept to keep in sync by hand. users.id/projects.id are Clerk text ids
(user_…/org_…). POST /api/webhooks/clerk — Svix-verified — syncs Clerk
users/orgs/memberships into the local users/projects/project_users
projections so the app has a local join target without re-calling Clerk on
every request.
Split-Glass hierarchy
Section titled “Split-Glass hierarchy”projects (tenant boundary — the Clerk org) └── targets (a deployable site, formerly "sites" — see naming-drift note in CLAUDE.md)targets is the unit that gets published, has its own domains, its own CMS
collections, its own design system. projects is the unit that has members,
roles, billing and API keys.
Middleware chain
Section titled “Middleware chain”Every project- or target-scoped route runs authMiddleware → then
projectAccessMiddleware (workers/src/middleware/project-access.ts), which:
- Extracts a
targetId/projectIdfrom the URL param, query string, request body, or (for node/style routes) resolves it from the row itself — see the file’s own comments for the full fallback ladder ((a) JSON-body tenant column on writes, (b) node-scoped routes resolve viadom_nodes.targetId, (b2) style-scoped routes viacomponent_styles.targetId, (c) param-less target mounts resolve the bare uuid againsttargets). - Takes the org-claim fast path when the resolved project equals the
caller’s verified active org: no
project_userslookup needed, because the JWT already proves membership. This isD220. - Otherwise falls back to the
project_usersmembership projection (personal projects, or a request with no active org). - Sets
projectId/targetId/projectRoleon the context for downstream handlers.
userCanAccessTarget/userCanAccessProject (exported from the same file) are
the same two checks as standalone functions, for handlers that need to
re-verify access without re-running the whole middleware — deliberately
checking the org-claim fast path FIRST for the reason stated in the code: the
local membership projection can lag (the 2026-07/08 webhook outage is cited
directly), so a handler that only checked the projection could 403 a user the
middleware had just admitted.
Hybrid role resolution (D:hybrid-roles)
Section titled “Hybrid role resolution (D:hybrid-roles)”Five ProjectRole tiers, defined in packages/schema/src/rbac.ts — a
capability bundle, not a strict linear hierarchy:
| Role | Capabilities |
|---|---|
viewer |
none (read-only) |
content-editor |
content:write, publish — ships CMS content, no structure |
editor |
content:write, structure:write — full design/structure, no publish |
publisher |
editor + publish, domains:manage, members:manage |
admin |
everything + admin (backup-restore, team/roles, keys, billing, delete) |
The file’s own comment explains why a content-editor can safely publish
without a design review gate: “the publish pipeline only ever ships
already-published nodes — it can never push a designer’s unreviewed draft.”
The five tiers can’t all live in Clerk — the free Clerk plan ships only two
built-in org roles (org:admin, org:member). So the source of truth is
split:
- Clerk owns membership + the admin bit. A Clerk
org:admin/ownerclaim always resolves toadmin; no app-stored value can grant or revoke it (fail-safe against a stale projection). - The app owns the four granular non-admin tiers in
project_users.role.
resolveEffectiveRole(clerkOrgRole, storedRole) is the one function that
reconciles them (see the extensive doc comment in rbac.ts for the exact
precedence, including the case where a stored admin is capped at
publisher when Clerk actively claims a non-admin member — the app side
never escalates a demoted member back to admin). projectAccessMiddleware’s
orgScopedRole() and middleware/rbac.ts’s resolveProjectRole() both call
it, so there is exactly one ladder, not two that can drift.
normalizeRole() is the single place stored/claim role strings get
canonicalized: it maps Clerk role keys (org:content_editor → content-editor),
legacy projections (owner→admin, member→publisher for backward
compatibility), and unknown/empty → viewer (safe default).
toClerkRole(role) is the exact inverse, used when assigning a Clerk custom
role — admin maps to Clerk’s built-in org:admin; the other four must be
defined as custom roles in the Clerk dashboard before they can be assigned
there.
Enforcement: requireCapability / assertCapability
Section titled “Enforcement: requireCapability / assertCapability”workers/src/middleware/rbac.ts layers capability gates on top of the role
projectAccessMiddleware resolved:
requireCapability(cap)— router-level middleware. Enforces only on mutating methods (POST/PATCH/PUT/DELETE) by default, so GET/HEAD stay open to any project member (aviewercan read everything). Pass{ methods: 'all' }for routers whose reads are as sensitive as their writes — the doc comment names/api/backup’sGET /exportas the reason this option exists (a full-site data dump).assertCapability(c, cap, projectId)— the inline form for routers that authorize by hand instead of mountingprojectAccessMiddleware. It callsresolveProjectRole()first ifprojectRoleisn’t already set on the context.
RLS (Row-Level Security)
Section titled “RLS (Row-Level Security)”RLS_ENFORCED is "true" today in workers/wrangler.jsonc. The DB client
(workers/src/db/client.ts) exposes three connections:
getServiceDB— the owner/BYPASSRLS role. Used for legitimately cross-tenant work: provisioning/org-bootstrap, publish, import, webhook sync, MCP service writes.getTenantDB(env, authToken)— the Neonauthenticatedrole, reached by forwarding the request’s verified Clerk JWT as the Neon HTTP driver’sauthToken. Postgres then enforces RLS policies viaauth.user_id()(Neon Authorize /pg_session_jwt). Not memoized — the token varies per request. It throws loudly (never silently falls back to the service connection) ifNEON_AUTHENTICATED_DATABASE_URLis unset whileRLS_ENFORCEDis claimed — the comment calls this out explicitly as a “must fail loudly, not quietly widen access” design choice, citing the 2026-08-22 audit’s P0-3 finding.getScopedDB(c)— the route-facing chooser: returns the tenant connection whenRLS_ENFORCED === 'true'ANDDEV_BYPASS_AUTH !== 'true'AND a JWT is present on the context, else the service connection. This makes the RLS cutover a single env flip.
The policy baseline is db/migrations/20260703_clerk_rls_baseline.sql,
which replaced the old Neon-Auth-era policy set wholesale (a drop/recreate,
not an incremental patch) for the Clerk pivot: projects.id/users.id are
Clerk text ids, so policies are direct string compares, no uuid casts, no
per-request org_members join. It defines clerk_user_id() (deny-closed:
NULL with no JWT session) and clerk_org_id() (reads both the v2 o.id and
v1 org_id claim shapes — the same dual-shape read authMiddleware does).
is_project_member() is the org-claim fast path OR the project_users
membership projection, mirroring the app-layer logic above at the database
layer too — defense in depth, not a second source of truth for the
decision.
Sidecar migrations extended RLS to tables added after the baseline, each one
scoped to the table(s) it names: 20260709_target_domains_rls.sql,
20260710_forms_email_rls.sql, 20260713_marketing_rls.sql,
20260713_visitor_auth_rls.sql, 20260720_backups_rls.sql,
20260720_symbols_rls.sql, 20260721_audit_events_rls.sql,
20260727_font_metrics_rls.sql, 20260817_token_sets_rls_grants.sql,
20260822_optimizer_runs_rls.sql, 20260824_collection_access_rls.sql,
20260902_site_auth_providers_rls.sql. New tenant-scoped tables need the same
treatment in the same PR that creates them — see db/MIGRATIONS.md.
Part 2 — Member/Visitor Auth: the site’s own end users
Section titled “Part 2 — Member/Visitor Auth: the site’s own end users”This is a completely separate system from Clerk — it authenticates the
people who sign in to a published Matriz site (as a member, subscriber, or
gated-content reader), not the people building it. Read
workers/src/middleware/visitor-auth.ts and
workers/src/lib/member-session.ts directly — both files carry doc comments
the code review already called “verified accurate and thorough”; the
summaries below quote and compress them rather than replace them.
The session: first-party, not Clerk
Section titled “The session: first-party, not Clerk”Under every provider option, the session layer is ours: a first-party,
per-host, HttpOnly __matriz_member cookie carrying an HS256 JWT the Worker
verifies networklessly on every gated request (lib/member-session.ts). The
signing key is the MEMBER_SESSION_SIGNING_KEY secret — secret-gated:
absent, signMemberSession returns null (redeem 503s) and
verifyMemberSession returns null (every request is treated as anonymous;
gated content stays locked). It fails closed, never throws.
Claims (MemberSessionClaims): sub = visitor id (site_visitors.id), tid
= target id (per-site isolation — a session for one target is not valid for
another, defense in depth), mid = member id (site_members.id), gen = a
session_generation snapshot compared against the live row to support
“sign out everywhere.” Sessions last 30 days (SESSION_TTL_SECONDS) with a
sliding refresh once the token is older than a day (maybeSlideSession,
called from visitorAuthMiddleware on every wrapped route).
Contract worth quoting directly from the file’s header comment:
verifyMemberSession returns a non-null result for any valid, non-blocked
session — including pending members (a join-approval site signs them in
but hasn’t granted access yet). It proves identity, not access. Gated
content and role checks must additionally call memberMeetsGate(member, requiredRole) — a valid, non-active session is not the same as being let in.
visitorAuthMiddleware
Section titled “visitorAuthMiddleware”workers/src/middleware/visitor-auth.ts populates the visitor identity from
the cookie when present and valid, and is non-blocking by design: an
anonymous request passes through with no visitor context set, and the
specific route decides what that means (/me returns
{authenticated:false}; a gated route can refuse). It runs after
modulePublicMiddleware (which sets targetId) and needs no Clerk auth — it
verifies the first-party HS256 session, not a Clerk JWT.
The edge gate (VA-4, inside servePublishedAsset, the static-serving
path) does not use this middleware — it calls verifyMemberSession
directly against the served Response — but both call sites share the one
verifier so session semantics can never fork between “serving a gated
published page” and “an API route checking who’s signed in.”
Providers: magic link + Google OIDC
Section titled “Providers: magic link + Google OIDC”- Magic link — the original provider; a burned link is proof of control of an email.
- Google OIDC (
D:member-google-oidc,workers/src/routes/modules/members-oauth.ts) —GET /members/google/start(302 → Google with PKCE + signed state + nonce) andGET /members/google/callback(exchanges the code, verifies the ID token, hands off to the sharedestablishMemberSession). Why server-side, not a client button: published pages shipscript-src 'none', so Google’s own JS button can never run there — the Worker is the OAuth client and the visitor only ever follows two redirects. A Google ID token withemail_verifiedis treated exactly like a burned magic link — proof of control of an email — and flows through the sameestablishMemberSession, so join-mode enforcement (inviterefuses unknown emails),blockedstatus, and seeded rows behave identically for both providers. - The site’s OAuth client id/secret live in
site_auth_providers(db/migrations/20260902_site_auth_providers_rls.sql), the secret sealed underMAIL_CREDENTIALS_KEY(see below) — unsealed only transiently for the token exchange, never logged, never returned. Every failure surfaces to the visitor as a constantmembers_errorcode; provider error bodies go to the Worker log only.
MAIL_CREDENTIALS_KEY sealed-box (workers/src/lib/sealed-box.ts)
Section titled “MAIL_CREDENTIALS_KEY sealed-box (workers/src/lib/sealed-box.ts)”A base64-encoded 32-byte AES-256-GCM master key. Format:
v1:<iv_b64>:<ciphertext_b64> — a fresh random 12-byte IV per seal() call
(GCM requires a unique IV per key). unseal() throws SealedBoxError on any
malformed input, wrong key, or tampered ciphertext (the GCM auth tag fails to
verify) — it never returns partial or garbage plaintext. This one key seals
two unrelated kinds of secret: BYO email provider API keys
(email-settings.ts) and per-target Google OAuth client secrets for member
sign-in (site_auth_providers). Rotating it invalidates every sealed row of
both kinds at once. It also signs the HMAC state for the Google OAuth
round-trip together with MEMBER_SESSION_SIGNING_KEY — both secrets must be
set for Google sign-in to work, not just one.
Rules enforced by callers (stated in the module’s own header, worth repeating
here because they’re security-critical): the plaintext credential is never
logged, never stored anywhere but transiently in the request that seals it,
and never returned by any route response — only the ciphertext plus a
last-4-chars hint (hintOf) are persisted for UI display.
Join modes
Section titled “Join modes”A site’s settings.join_mode is one of three values (surfaced in
frontend/src/components/cms/MembersPane.tsx):
| Mode | Effect |
|---|---|
open |
anyone can join |
approve |
joins as pending — signed in, not yet granted access (see the identity-vs-access distinction above) |
invite |
invite only — an unknown email is refused at sign-in, for both magic-link and Google |
The data-member-visible grammar (D:member-auth-blocks)
Section titled “The data-member-visible grammar (D:member-auth-blocks)”A whitespace-separated, AND-combined token list; an unknown token HIDES
(fail-closed). Defined once, canonically, as MEMBER_RUNTIME_SOURCE in
packages/site-render/src/interactions-runtime.ts, and — because the editor
canvas has no live runtime to load — mirrored by hand in
frontend/public/preview.html’s applyMemberPreview() function.
Recognized tokens:
| Token | Meaning |
|---|---|
authed / signed-in |
a valid session exists |
anon / signed-out |
no valid session |
active |
signed in AND member.status === 'active' |
pending |
signed in AND member.status === 'pending' |
role:<r> |
signed in AND member.role === r |
provider:google / provider:magic-link |
that sign-in provider is enabled for this site (reads the /members/config echo) |
join:open / join:invite / join:approve |
the site’s current join mode |
provider:/join: tokens are always visible in the editor canvas — there
is no /members/config fetch on the canvas, and hiding a provider-gated
control (e.g. a Google sign-in button) while editing would make it
unselectable. The published runtime does the real check.
This grammar has exactly one canonical definition and one hand-mirror.
Any change to the token list, the AND-combination rule, or the fail-closed
default must change both files (interactions-runtime.ts’s
MEMBER_RUNTIME_SOURCE and preview.html’s applyMemberPreview) in the same
change, and keep members-library.verify.test.ts (which validates every
authored value against the grammar) green. There is no shared module between
them — the published runtime is a plain-JS string template injected into
static HTML (no bundler, no import), and the canvas preview is a separate
plain-JS mirror — so a one-sided edit silently forks editor preview from
published behavior with no compiler or type system to catch it.
Where to look next
Section titled “Where to look next”workers/src/middleware/{auth,project-access,rbac,visitor-auth}.ts— the four middleware files this doc summarizes; read their doc comments directly for the exact fallback ladders.packages/schema/src/rbac.ts— the pure role/capability model, shared by the Worker (enforcement) and the frontend (hiding/disabling controls a role can’t use).workers/src/lib/member-session.ts,workers/src/lib/sealed-box.ts— the Visitor Auth session and credential-sealing primitives.db/migrations/20260703_clerk_rls_baseline.sqland its sidecar RLS migrations listed above.docs/architecture/OVERVIEW.mdandARCHITECTURE.md(not owned by this doc) for how auth fits into the rest of the runtime.