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 viaClerkProvider→ConvexProviderWithClerkincomponents/ConvexClientProvider.tsx; the middleware isproxy.ts. - Convex trusts Clerk through a JWT template named exactly
convex.convex/auth.config.tsdeclares one provider withapplicationID: "convex"and readsdomainfromCLERK_JWT_ISSUER_DOMAIN.
Auth gotcha: without the
convexJWT 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
usersrows are keyed bytokenIdentifier(the canonical Convex identity from the Clerk JWT), withclerkUserId, name/email/imageUrl, and an admin-UIlocale.- Identity always comes from
ctx.auth.getUserIdentity()(verified by Convex), mapped to theusersrow via theby_tokenindex — never from client args. requireUser(ctx)resolves the verified user; first-sign-in sync happens inconvex/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)
| Helper | Gate | Used by |
|---|---|---|
requireUser(ctx) | signed-in | everything |
requireWorkspaceMember(ctx, workspaceId, userId) | any member | workspace reads |
requireWorkspaceEditor(...) | owner / editor | workspace edits |
requireWebsiteRead(ctx, websiteId) | any member (viewer ok) | reads (e.g. getAssetUrls, /preview) |
requireWebsiteAccess(ctx, websiteId) | owner / editor | the default for edits / publish |
requireWebsiteOwner(ctx, websiteId) | workspace owner | delete site, manage members |
requirePageRead / requirePageAccess(ctx, pageId) | via page.websiteId | page reads / edits |
requireSectionRead / requireSectionAccess(ctx, sectionId) | via section.websiteId | section 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(.*)— Clerkauth.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
| Gap | Severity | Notes |
|---|---|---|
| Preview-link password brute-force — no rate limit | P1 | MVP close-out; add a rateLimits key on unlockPreview. |
| SSRF / DNS-rebinding residual on host lookups | low (architectural) | Mitigated by LIVE-status checks. |
/sign-in first-render hydration mismatch | P2 | Likely useIsNative() SSR; cosmetic. |
| Settings modal / cookie banner focus management | P2 a11y | Keyboard 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.
Auth-related env vars
Names and purpose only (see Environment variables):
| Var | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Vercel / .env.local | Clerk client key |
CLERK_SECRET_KEY | Vercel / .env.local (secret) | Clerk server key |
CLERK_JWT_ISSUER_DOMAIN | .env.local and the Convex deployment | issuer of the convex JWT template |
NEXT_PUBLIC_CONVEX_URL | auto (convex dev / deploy) | Convex client |