Sajt · Docs

Auth & permissions

How Clerk identity reaches Convex, the require* capability gates and role resolution, protected vs public routes, and known gaps.

How identity, ownership, and authorization work. Core files: convex/lib/authz.ts, convex/auth.config.ts, proxy.ts, convex/users.ts.

Can user A access user B's site by changing an id? — No (verified)

Every website-scoped Convex function takes the client-supplied id, then immediately calls a require*Access helper that loads the row and verifies the verified user's membership before using the id. Cross-workspace access is blocked and tested (convex/crossWorkspace.test.ts, convex/security.test.ts). Client ids are never trusted directly — this is the single most important security property of the app.

Clerk → Convex

  • Auth provider is Clerk (@clerk/nextjs), wired via ClerkProviderConvexProviderWithClerk in components/ConvexClientProvider.tsx; the middleware is proxy.ts.
  • Convex trusts Clerk through a JWT template named exactly convex. convex/auth.config.ts declares one provider with applicationID: "convex" and reads domain from CLERK_JWT_ISSUER_DOMAIN.

Auth gotcha: without the convex JWT template, client sign-in works but Convex never receives the identity, so the app shows the signed-out state. Local keyless dev mode lacks the template — claim the Clerk app to add it. This is the first thing to verify after deploying; see Deployment.

Native shells (desktop / iOS) sign in via the system browser and hand a single-use ticket back through /desktop/handoff + sitebuilder:// deep links.

User model & identity

  • users rows are keyed by tokenIdentifier (the canonical Convex identity from the Clerk JWT), with clerkUserId, name/email/imageUrl, and an admin-UI locale.
  • Identity always comes from ctx.auth.getUserIdentity() (verified by Convex), mapped to the users row via the by_token index — never from client args.
  • requireUser(ctx) resolves the verified user; first-sign-in sync happens in convex/users.ts.

Ownership & membership

The ownership chain is users → workspaces → websites → pages/sections.

  • workspaces — one per owner (ownerUserId); also the billing + credit container. The word "workspace" is hidden from users ("company" / "hemsida").
  • workspaceMembers — access to all sites in a workspace. Roles: owner | editor | viewer.
  • websiteMembers — access to one site only. Roles: editor | viewer (no owner — ownership is always via the workspace).
  • invites — pending shares (workspace or website scope), email-bound, single-use, expiring; copy-link delivery today.

Role resolution (resolveWebsiteRole): workspace membership wins; otherwise fall back to direct websiteMembers; otherwise FORBIDDEN.

Capability gates (convex/lib/authz.ts)

HelperGateUsed by
requireUser(ctx)signed-ineverything
requireWorkspaceMember(ctx, workspaceId, userId)any memberworkspace reads
requireWorkspaceEditor(...)owner / editorworkspace edits
requireWebsiteRead(ctx, websiteId)any member (viewer ok)reads (e.g. getAssetUrls, /preview)
requireWebsiteAccess(ctx, websiteId)owner / editorthe default for edits / publish
requireWebsiteOwner(ctx, websiteId)workspace ownerdelete site, manage members
requirePageRead / requirePageAccess(ctx, pageId)via page.websiteIdpage reads / edits
requireSectionRead / requireSectionAccess(ctx, sectionId)via section.websiteIdsection reads / edits

Each returns a WebsiteCtx ({ user, website, role, … }) so callers operate on the verified row. The standard editor pattern:

const { user, website, role } = await requireWebsiteAccess(ctx, args.websiteId);

Defense in depth: when a function attaches one entity to another (e.g. an asset to a section), the helper verifies the section, then the function re-checks that asset.websiteId === section.websiteId before use. The AI chat tools re-check tenancy on every tool call (convex/applyTool.ts, convex/applyTool.test.ts) — see the AI system.

Protected vs public routes (proxy.ts)

  • Protected matcher: /dashboard(.*), /onboarding(.*), /preview(.*) — Clerk auth.protect() on the web, or a redirect to sign-in for native shells.
  • Public: marketing, /create, /blog/**, the published /site/**, and the token pages (/p/<token>, /invite/<token>, /invoice/<token>, /offert/<token>), which carry their own opaque-token gates.
  • robots.txt blocks /dashboard/, /onboarding, /preview/, /api/.

See the full table in Routes.

Public write endpoints (no account)

Published sites POST to Convex HTTP endpoints (convex/http.ts): /lead, /booking, /track. These are public by design but rate-limited transactionally (convex/lib/rateLimit.ts, the rateLimits table) keyed by slug / IP, and they write only into runtime tables (leadSubmissions, bookings, analyticsEvents) for the resolved website. The token pages (/invoice, /offert, /p) gate on a 256-bit opaque token.

Known auth / security gaps

GapSeverityNotes
Preview-link password brute-force — no rate limitP1MVP close-out; add a rateLimits key on unlockPreview.
SSRF / DNS-rebinding residual on host lookupslow (architectural)Mitigated by LIVE-status checks.
/sign-in first-render hydration mismatchP2Likely useIsNative() SSR; cosmetic.
Settings modal / cookie banner focus managementP2 a11yKeyboard trapping not enforced.

Not a gap (verified): core authz and draft↔published isolation are solid and tested. Adding a new function? Add the require* gate first, plus a fail-closed test that an unauthorized caller is rejected.

Names and purpose only (see Environment variables):

VarWherePurpose
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYVercel / .env.localClerk client key
CLERK_SECRET_KEYVercel / .env.local (secret)Clerk server key
CLERK_JWT_ISSUER_DOMAIN.env.local and the Convex deploymentissuer of the convex JWT template
NEXT_PUBLIC_CONVEX_URLauto (convex dev / deploy)Convex client

On this page