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/astro installed 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!,
});
OptionTypeDefaultDescription
issuerDomain*string--Clerk Frontend API URL. Dev: https://verb-noun-00.clerk.accounts.dev. Prod: https://clerk.<DOMAIN>.com.
applicationIDstring"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

OptionTypeDefaultDescription
secretKey*string--Clerk secret key (used to verify tokens).
isAuthRoute*(pathname) => boolean--Routes that bypass auth.
isPublicAsset(pathname) => boolean/_astro, /favicon, /assetsStatic assets that skip auth.
isApiRoute(pathname) => boolean/api/* (except auth)API routes return 401 instead of redirecting.
loginPathstring"/login"Redirect target for unauthenticated page requests.
setAuthToken(context, token) => void--Callback to store the JWT on context.locals.
convexUrlstring--When provided, a ConvexHttpClient is created per-request and attached to context.locals.convex.
jwtKeystring--PEM public key for local JWT verification (alternative to secretKey network calls).

Auth flow

  1. A request arrives at a protected route.
  2. Middleware checks for a __session cookie (or Authorization: Bearer header).
  3. The token is verified using the Clerk client.
  4. Valid tokens are stored on context.locals.convexAuthToken; the request continues.
  5. Missing or invalid tokens redirect page routes to loginPath or return 401 for 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
Previous
Authentication
Next
Security