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.
Email-bound invites and project claims require users.emailVerifiedAt. Only the
signed Clerk webhook or an explicit boolean email_verified: true JWT claim
may stamp it; missing/false claims cannot overwrite a verified address.
A 24-hour indexed bridge preserves signed webhook proof when user.created
wins the first-sign-in race, then ensureUser consumes it. Dashboard rollout
is tracked in user-task 0181.
Native shells (desktop / iOS) sign in via the system browser. Desktop codes
carry a random jti that Convex atomically consumes before Clerk mints the
single-use ticket, so replay and concurrent exchange fail closed.
User model & identity
usersrows are keyed bytokenIdentifier(the canonical Convex identity from the Clerk JWT), withclerkUserId, name/email/imageUrl, durableemailVerifiedAt, 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.
Per-user advancedEditor capability
The experimental advanced editor is gated by a capability, not a role. A
capabilityGrants row (workspaceId + userId + capability) grants
advancedEditor to one user inside one workspace. Grants are operator-issued
(from the platform admin surface, audited), never self-service, and every
advanced mutation is server-gated by assertAdvancedEditor - the user-facing
settings toggle is UX only and is never consulted server-side. A legacy
workspace-wide labs grant is still honored as a migration fallback; revoking a
user's capability also clears it. Roles (owner/editor/viewer) are
unchanged, and no new public role exists.
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 |
Hittade du inte svaret, eller stämmer något inte? Berätta det för oss.
Senast uppdaterad