Features
Authentication (Clerk)
Vextro provides a Clerk harness at vextro/auth/clerk for projects using Clerk instead of Better Auth. Clerk directly issues JWTs that Convex validates against Clerk's public keys — no token exchange needed.
Prerequisites
- A Clerk account with a Convex integration activated in the Clerk Dashboard
@clerk/astroinstalled in your project- Environment variables:
PUBLIC_CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEY,CLERK_JWT_ISSUER_DOMAIN
pnpm add @clerk/astro Convex auth config
Create convex/auth.config.ts using the factory:
import { createClerkConvexAuthConfig } from "vextro/auth/clerk/convex-config";
export default createClerkConvexAuthConfig({
issuerDomain: process.env.CLERK_JWT_ISSUER_DOMAIN!,
}); | Option | Type | Default | Description |
|---|---|---|---|
issuerDomain* | string | -- | Clerk Frontend API URL. Dev: https://verb-noun-00.clerk.accounts.dev. Prod: https://clerk.<DOMAIN>.com. |
applicationID | string | "convex" | Application ID for the Convex provider entry. |
Middleware setup
Create src/middleware.ts to protect admin routes:
import { createClerkMiddleware } from "vextro/auth/clerk/middleware";
export const onRequest = createClerkMiddleware({
secretKey: import.meta.env.CLERK_SECRET_KEY,
isAuthRoute: (p) => p === "/login",
}); Middleware options
| Option | Type | Default | Description |
|---|---|---|---|
secretKey* | string | -- | Clerk secret key (used to verify tokens). |
isAuthRoute* | (pathname) => boolean | -- | Routes that bypass auth. |
isPublicAsset | (pathname) => boolean | /_astro, /favicon, /assets | Static assets that skip auth. |
isApiRoute | (pathname) => boolean | /api/* (except auth) | API routes return 401 instead of redirecting. |
loginPath | string | "/login" | Redirect target for unauthenticated page requests. |
setAuthToken | (context, token) => void | -- | Callback to store the JWT on context.locals. |
convexUrl | string | -- | When provided, a ConvexHttpClient is created per-request and attached to context.locals.convex. |
jwtKey | string | -- | PEM public key for local JWT verification (alternative to secretKey network calls). |
Auth flow
- A request arrives at a protected route.
- Middleware checks for a
__sessioncookie (orAuthorization: Bearerheader). - The token is verified using the Clerk client.
- Valid tokens are stored on
context.locals.convexAuthToken; the request continues. - Missing or invalid tokens redirect page routes to
loginPathor return401for API routes.
RBAC modes
Vextro supports two ways to resolve user roles with Clerk.
Mode A: Roles in Convex DB
Roles are stored in Vextro's Convex RBAC tables. Clerk's JWT identifies the user by subject; roles are looked up from the database.
import { createClerkVextroGetUserRoles } from "vextro/auth/clerk/get-user-roles";
const getUserRoles = createClerkVextroGetUserRoles({
getUserRolesFromDb: async (ctx, userId) => {
const assignments = await ctx.db
.query("vextroUserRoles")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.collect();
return assignments.map((a) => a.role);
},
}); Mode B: Roles from JWT claims
Roles come directly from the Clerk JWT custom claims. No database lookup required.
publicMetadata mode — reads from metadata.role:
import { createClerkNativeGetUserRoles } from "vextro/auth/clerk/get-user-roles";
const getUserRoles = createClerkNativeGetUserRoles({
claimMode: "publicMetadata",
}); Requires Clerk Dashboard configuration: Sessions > Custom Claims > { "metadata": "{{ user.public_metadata }}" }.
orgRole mode — reads from org_role (e.g. "org:admin" → "admin"):
const getUserRoles = createClerkNativeGetUserRoles({
claimMode: "orgRole",
}); Requires Clerk organizations enabled and org claim mapping configured.
custom mode — dot-separated path into JWT claims:
const getUserRoles = createClerkNativeGetUserRoles({
claimMode: "custom",
roleClaimPath: "app.roles",
}); Current user
Two factories for resolving the current user.
DB lookup
import { createClerkDbGetCurrentUser } from "vextro/auth/clerk/get-current-user";
const getCurrentUser = createClerkDbGetCurrentUser({
getByTokenIdentifier: async (ctx, tokenIdentifier) => {
return await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", tokenIdentifier)
)
.unique();
},
}); JWT-only (no DB)
import { createClerkJwtGetCurrentUser } from "vextro/auth/clerk/get-current-user";
const getCurrentUser = createClerkJwtGetCurrentUser(); Returns { _id: subject, tokenIdentifier, email, name } from the JWT. No database lookup.
Permission guards
The Clerk harness re-exports the same permission guard utilities used by Better Auth:
import { hasAnyPermission, hasAllPermissions } from "vextro/auth/clerk/guards";
const canEdit = hasAnyPermission({
userPermissions: user.permissions,
required: ["posts.edit", "posts.admin"],
}); Environment variables
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
CLERK_JWT_ISSUER_DOMAIN=https://verb-noun-00.clerk.accounts.dev