Skip to content

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.md for the wider system map and docs/architecture/TERMINOLOGY.md for canonical naming (this doc follows both without editing either).


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).

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.

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.

Every project- or target-scoped route runs authMiddleware → then projectAccessMiddleware (workers/src/middleware/project-access.ts), which:

  1. Extracts a targetId/projectId from 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 via dom_nodes.targetId, (b2) style-scoped routes via component_styles.targetId, (c) param-less target mounts resolve the bare uuid against targets).
  2. Takes the org-claim fast path when the resolved project equals the caller’s verified active org: no project_users lookup needed, because the JWT already proves membership. This is D220.
  3. Otherwise falls back to the project_users membership projection (personal projects, or a request with no active org).
  4. Sets projectId/targetId/projectRole on 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.

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/owner claim always resolves to admin; 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 (a viewer can read everything). Pass { methods: 'all' } for routers whose reads are as sensitive as their writes — the doc comment names /api/backup’s GET /export as 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 mounting projectAccessMiddleware. It calls resolveProjectRole() first if projectRole isn’t already set on the context.

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 Neon authenticated role, reached by forwarding the request’s verified Clerk JWT as the Neon HTTP driver’s authToken. Postgres then enforces RLS policies via auth.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) if NEON_AUTHENTICATED_DATABASE_URL is unset while RLS_ENFORCED is 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 when RLS_ENFORCED === 'true' AND DEV_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.

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.

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.”

  • 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) and GET /members/google/callback (exchanges the code, verifies the ID token, hands off to the shared establishMemberSession). Why server-side, not a client button: published pages ship script-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 with email_verified is treated exactly like a burned magic link — proof of control of an email — and flows through the same establishMemberSession, so join-mode enforcement (invite refuses unknown emails), blocked status, 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 under MAIL_CREDENTIALS_KEY (see below) — unsealed only transiently for the token exchange, never logged, never returned. Every failure surfaces to the visitor as a constant members_error code; 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.

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.


  • 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.sql and its sidecar RLS migrations listed above.
  • docs/architecture/OVERVIEW.md and ARCHITECTURE.md (not owned by this doc) for how auth fits into the rest of the runtime.