LLM Reference
LLM Reference: Authentication
This reference covers Vextro’s authentication configuration and permission utilities for apps built with Vextro.
Choosing an Auth Provider
Vextro ships two auth harnesses. Both set context.locals.convexAuthToken identically — the admin module does not care which one is used.
| Better Auth | Clerk | |
|---|---|---|
| Hosting | Self-hosted | Managed service |
| Setup | More setup (database adapter, plugin) | Faster setup (publishable key + secret key) |
| Control | Full control over schema and sessions | Clerk controls session lifecycle |
| Vendor dependency | None beyond your own infra | Clerk vendor lock-in |
| Import prefix | vextro/auth/better-auth | vextro/auth/clerk |
Both harnesses expose the same context.locals.convexAuthToken value that Vextro’s SSR pages consume. Switching between them only requires changing middleware and convex/auth.config.ts.
VextroAuthConfig
Configures the login page UI and OAuth flow parameters. Passed as the auth field of VextroConfig.
type VextroAuthConfig = {
providerId?: string;
callbackPath?: string;
authPath?: string;
title?: string;
description?: string;
buttonLabel?: string;
};
| Field | Type | Default | Description |
|---|---|---|---|
providerId | string | — | OAuth provider identifier. |
callbackPath | string | — | OAuth callback route path. |
authPath | string | — | Base path for auth API routes. |
title | string | — | Login page title. |
description | string | — | Login page description text. |
buttonLabel | string | — | Login button text. |
Example
import { createVextroClient } from "vextro";
const client = createVextroClient({
convexUrl: import.meta.env.PUBLIC_CONVEX_URL,
config: {
brandName: "My Admin",
auth: {
providerId: "okta",
callbackPath: "/api/auth/callback/okta",
authPath: "/api/auth",
title: "Sign in to Admin",
description: "Use your company Okta account.",
buttonLabel: "Sign in with Okta",
},
},
});
Better Auth Harness
createAuthMiddleware
Astro middleware that handles JWT token exchange, route classification, and ConvexHttpClient attachment.
import { createAuthMiddleware } from "vextro/auth/better-auth";
AuthMiddlewareArgs
| Field | Type | Default | Description |
|---|---|---|---|
getConvexSiteUrl | () => string | required | Returns the Convex site URL for token exchange. |
isAuthRoute | (pathname: string) => boolean | required | Returns true for auth API routes (skips token fetch to avoid loops). |
isPublicAsset | (pathname: string) => boolean | Starts with /_astro, /favicon, /assets | Returns true for static assets. |
isApiRoute | (pathname: string) => boolean | Starts with /api/ but not /api/auth/ | Returns true for API routes (returns 401 instead of redirect). |
loginPath | string | "/login" | Path to redirect unauthenticated users. |
setAuthToken | (context, token) => void | — | Callback to store token on context.locals. |
getTokenFn | Function | getToken from better-auth | Custom token exchange function. |
convexUrl | string | — | When provided, creates ConvexHttpClient per-request on context.locals.convex. |
Sign-out routes expire the normal and __Secure- variants of the Better Auth session and Convex JWT cookies, plus Vextro’s warm-client cookie. Include those routes in isAuthRoute so cleanup always runs.
Example
// src/middleware.ts
import { sequence } from "astro:middleware";
import { createAuthMiddleware } from "vextro/auth/better-auth";
const auth = createAuthMiddleware({
getConvexSiteUrl: () => import.meta.env.PUBLIC_CONVEX_URL,
isAuthRoute: (p) => p.startsWith("/api/auth") || p === "/login",
loginPath: "/login",
convexUrl: import.meta.env.PUBLIC_CONVEX_URL,
setAuthToken: (ctx, token) => {
(ctx.locals as any).authToken = token;
},
});
export const onRequest = sequence(auth);
createConvexAuth
Factory for creating a better-auth instance configured for Convex.
import { createConvexAuth } from "vextro/auth/better-auth";
CreateConvexAuthArgs
| Field | Type | Default | Description |
|---|---|---|---|
appName | string | required | Application name. |
baseURL | string | required | Base URL for auth endpoints. |
basePath | string | "/api/auth" | Auth API path prefix. |
secret | string | required | Auth secret key. |
trustedOrigins | string[] | [] | Trusted origins for CORS. |
account | { storeStateStrategy? } | — | Account configuration. |
database | (ctx) => unknown | required | Database adapter factory. |
plugins | Plugin[] | required | better-auth plugins (including the Vextro plugin). |
Returns a function (ctx) => BetterAuthInstance.
Example
import { createConvexAuth, createVextroAuthPlugin } from "vextro/auth/better-auth";
import { genericOAuth } from "better-auth/plugins";
const auth = createConvexAuth({
appName: "My Admin",
baseURL: "https://admin.example.com",
basePath: "/api/auth",
secret: process.env.AUTH_SECRET!,
trustedOrigins: ["https://admin.example.com"],
database: (ctx) => convexAdapter(ctx),
plugins: [
genericOAuth({ providerId: "okta", /* ... */ }),
createVextroAuthPlugin({ auditLog: true }),
],
});
createVextroAuthPlugin
A better-auth plugin that fires Vextro-style lifecycle hooks on auth events.
import { createVextroAuthPlugin } from "vextro/auth/better-auth";
VextroAuthPluginOptions
| Field | Type | Default | Description |
|---|---|---|---|
hooks | VextroAuthHooks | {} | Auth lifecycle hooks. |
auditLog | boolean | false | Automatically log auth events. |
VextroAuthHooks
| Hook | Type | Trigger |
|---|---|---|
afterLogin | Array<(args: { user, session, ctx }) => Promise<void> | void> | After successful login. |
afterLogout | Array<(args: { userId?, ctx }) => Promise<void> | void> | After logout. |
afterSignup | Array<(args: { user, ctx }) => Promise<void> | void> | After new user registration. |
afterSessionRefresh | Array<(args: { session, ctx }) => Promise<void> | void> | After session refresh. |
Each hook type accepts an array of functions. They execute in registration order. Hook errors are non-critical — they are caught and swallowed to prevent auth flow interruption.
Example
import { createConvexAuth, createVextroAuthPlugin } from "vextro/auth/better-auth";
const auth = createConvexAuth({
appName: "My Admin",
baseURL: "https://admin.example.com",
secret: process.env.AUTH_SECRET!,
database: (ctx) => convexAdapter(ctx),
plugins: [
genericOAuth({ providerId: "okta", /* ... */ }),
createVextroAuthPlugin({
hooks: {
afterLogin: [
async ({ user }) => {
console.log("User logged in:", user.email);
},
],
afterSignup: [
async ({ user }) => {
// Assign default role to new users
},
],
},
auditLog: true,
}),
],
});
Better Auth Permission Guards
Pure functions for checking user permissions. Synchronous, no side effects.
import { hasAnyPermission, hasAllPermissions } from "vextro/auth/better-auth";
hasAnyPermission
function hasAnyPermission(args: {
userPermissions?: string[] | null;
required: string[];
}): boolean;
Returns true if the user has at least one of the required permissions. Returns true if required is empty (no permissions needed).
hasAllPermissions
function hasAllPermissions(args: {
userPermissions?: string[] | null;
required: string[];
}): boolean;
Returns true if the user has all of the required permissions. Returns true if required is empty (no permissions needed).
Example
import { hasAnyPermission, hasAllPermissions } from "vextro/auth/better-auth";
const userPerms = ["posts.read", "posts.write", "media.read"];
// True -- user has "posts.write"
hasAnyPermission({
userPermissions: userPerms,
required: ["posts.write", "posts.delete"],
});
// False -- user lacks "media.write"
hasAllPermissions({
userPermissions: userPerms,
required: ["media.read", "media.write"],
});
// True -- no permissions required
hasAnyPermission({ userPermissions: null, required: [] });
hasAllPermissions({ userPermissions: null, required: [] });
Clerk Auth Harness
Subpath Exports
| Import path | Exports |
|---|---|
vextro/auth/clerk | createClerkMiddleware, createClerkConvexAuthConfig, all role/user getters, guards |
vextro/auth/clerk/middleware | createClerkMiddleware |
vextro/auth/clerk/convex-config | createClerkConvexAuthConfig |
vextro/auth/clerk/get-user-roles | createClerkVextroGetUserRoles, createClerkNativeGetUserRoles |
vextro/auth/clerk/get-current-user | createClerkDbGetCurrentUser, createClerkJwtGetCurrentUser |
vextro/auth/clerk/guards | hasAnyPermission, hasAllPermissions |
createClerkMiddleware
Astro middleware that verifies Clerk JWTs and sets context.locals.convexAuthToken.
Token resolution order:
Authorization: Bearer <token>header__sessioncookie
If neither is present or verification fails, non-API routes are redirected to loginPath; API routes receive a 401 JSON response.
import { createClerkMiddleware } from "vextro/auth/clerk/middleware";
CreateClerkMiddlewareArgs
| Field | Type | Default | Description |
|---|---|---|---|
secretKey | string | required | Clerk secret key (CLERK_SECRET_KEY). |
isAuthRoute | (pathname: string) => boolean | required | Returns true for routes that should skip auth (e.g., /login). |
isPublicAsset | (pathname: string) => boolean | Starts with /_astro, /favicon, /assets | Returns true for static assets (skipped entirely). |
isApiRoute | (pathname: string) => boolean | Starts with /api/ but not /api/auth/ | Returns true for API routes (returns 401 instead of redirect). |
loginPath | string | "/login" | Redirect destination for unauthenticated page requests. |
setAuthToken | (context, token) => void | — | Additional callback to store the token (e.g., on a custom local). |
convexUrl | string | — | When provided, creates a per-request ConvexHttpClient on context.locals.convex. |
jwtKey | string | — | Optional PEM public key override for local JWT verification without a network call. |
Example
// src/middleware.ts
import { createClerkMiddleware } from "vextro/auth/clerk/middleware";
export const onRequest = createClerkMiddleware({
secretKey: import.meta.env.CLERK_SECRET_KEY,
isAuthRoute: (p) => p === "/login",
loginPath: "/login",
convexUrl: import.meta.env.PUBLIC_CONVEX_URL,
});
createClerkConvexAuthConfig
Creates the Convex auth provider configuration for convex/auth.config.ts when using Clerk.
import { createClerkConvexAuthConfig } from "vextro/auth/clerk/convex-config";
ClerkConvexAuthConfigArgs
| Field | Type | Default | Description |
|---|---|---|---|
issuerDomain | string | required | Clerk Frontend API URL. Dev format: https://verb-noun-00.clerk.accounts.dev. Prod format: https://clerk.<YOUR_DOMAIN>.com. |
applicationID | string | "convex" | Convex application identifier used by Clerk’s JWT template. |
Example
// convex/auth.config.ts
import { createClerkConvexAuthConfig } from "vextro/auth/clerk/convex-config";
export default createClerkConvexAuthConfig({
issuerDomain: process.env.CLERK_JWT_ISSUER_DOMAIN!,
});
createClerkVextroGetUserRoles
Returns a Convex query/mutation context function that resolves user roles from Vextro’s RBAC tables in the database. The user is identified by their Clerk JWT subject (sub claim).
Use this when you want your role assignments managed inside Convex rather than in Clerk’s dashboard.
import { createClerkVextroGetUserRoles } from "vextro/auth/clerk/get-user-roles";
Args
| Field | Type | Description |
|---|---|---|
getUserRolesFromDb | (ctx, userId: string) => Promise<string[]> | Async function that queries Vextro’s RBAC tables and returns role slugs for the given Clerk subject. |
Return value
A function with signature (ctx) => Promise<string[]>. Pass this as getUserRoles to createStrictRbacGuard or to the admin module factory.
Example
import { createClerkVextroGetUserRoles } from "vextro/auth/clerk/get-user-roles";
export const getUserRoles = createClerkVextroGetUserRoles({
getUserRolesFromDb: async (ctx, clerkSubject) => {
const membership = await ctx.db
.query("user_roles")
.withIndex("by_clerk_subject", (q) => q.eq("clerkSubject", clerkSubject))
.first();
return membership?.roles ?? [];
},
});
createClerkNativeGetUserRoles
Returns a Convex context function that resolves user roles directly from Clerk JWT claims — no database lookup.
import { createClerkNativeGetUserRoles } from "vextro/auth/clerk/get-user-roles";
Args
| Field | Type | Description |
|---|---|---|
claimMode | "publicMetadata" | "orgRole" | "custom" | required — How to read roles from the JWT. See modes table below. |
roleClaimPath | string | Dot-separated path into the JWT payload. Only used when claimMode is "custom". |
Claim Modes
| Mode | JWT path | Clerk setup required | Notes |
|---|---|---|---|
"publicMetadata" | metadata.role | Clerk Dashboard → Sessions → Custom Claims: { "metadata": "{{ user.public_metadata }}" } | Role(s) come from the user’s public metadata. Supports string or string array. |
"orgRole" | org_role | Clerk Organizations enabled; org claim mapping configured | Strips org: prefix from the value (e.g., "org:admin" → "admin"). |
"custom" | roleClaimPath (dot-separated) | Any custom JWT template that sets the desired path | Most flexible; provide roleClaimPath to navigate nested claims. |
Example
import { createClerkNativeGetUserRoles } from "vextro/auth/clerk/get-user-roles";
// Using Clerk public metadata
export const getUserRoles = createClerkNativeGetUserRoles({
claimMode: "publicMetadata",
});
// Using Clerk organizations
export const getUserRoles = createClerkNativeGetUserRoles({
claimMode: "orgRole",
});
// Using a custom JWT claim path
export const getUserRoles = createClerkNativeGetUserRoles({
claimMode: "custom",
roleClaimPath: "app.roles",
});
createClerkDbGetCurrentUser
Returns a Convex context function that loads the current user from the database by their Clerk tokenIdentifier.
import { createClerkDbGetCurrentUser } from "vextro/auth/clerk/get-current-user";
Args
| Field | Type | Description |
|---|---|---|
getByTokenIdentifier | (ctx, tokenIdentifier: string) => Promise<MinimalUser | null> | Async function that looks up the user record by Clerk token identifier. |
MinimalUser shape
type MinimalUser = {
_id: GenericId<any>; // Real Convex document ID
tokenIdentifier?: string;
email?: string;
name?: string;
[key: string]: unknown;
};
Example
import { createClerkDbGetCurrentUser } from "vextro/auth/clerk/get-current-user";
export const getCurrentUser = createClerkDbGetCurrentUser({
getByTokenIdentifier: async (ctx, tokenIdentifier) => {
return ctx.db
.query("users")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", tokenIdentifier))
.unique();
},
});
createClerkJwtGetCurrentUser
Returns a Convex context function that constructs a minimal user object from the JWT claims — no database lookup. The returned _id is the Clerk subject, not a real Convex document ID.
Use this when you do not sync Clerk users into your own database and only need identity information for the current request.
import { createClerkJwtGetCurrentUser } from "vextro/auth/clerk/get-current-user";
Example
import { createClerkJwtGetCurrentUser } from "vextro/auth/clerk/get-current-user";
export const getCurrentUser = createClerkJwtGetCurrentUser();
// In a query:
const user = await getCurrentUser(ctx);
// user._id === Clerk subject (string cast as GenericId), NOT a real Convex _id
Security Utilities
Import from vextro/convex/security.
import {
createStrictRbacGuard,
assertCollectionAccess,
createWebhookSecretGuard,
validateDocumentPayload,
} from "vextro/convex/security";
createStrictRbacGuard
Wraps a getUserRoles function with error handling. By default the guard fails closed: any error thrown by getUserRoles is re-thrown, denying access. This is the correct behavior for mutations and sensitive reads.
function createStrictRbacGuard<DataModel>({
getUserRoles,
failOpen,
}: {
getUserRoles?: (ctx) => Promise<string[]>;
failOpen?: boolean;
}): (ctx) => Promise<string[] | null>;
| Arg | Type | Default | Description |
|---|---|---|---|
getUserRoles | (ctx) => Promise<string[]> | — | Role resolution function. When omitted, the guard always returns null (RBAC bypassed). |
failOpen | boolean | false | When true, errors from getUserRoles return null instead of throwing. Security warning: Only use for non-sensitive, read-only queries where RBAC is advisory. |
Return value: A function (ctx) => Promise<string[] | null>.
string[]— resolved role slugsnull— RBAC not configured (nogetUserRolesprovided, orfailOpencaught an error)
Downstream utilities treat null as “RBAC not configured; grant access.”
Example
import { createStrictRbacGuard } from "vextro/convex/security";
import { getUserRoles } from "./auth";
const rbacGuard = createStrictRbacGuard({ getUserRoles });
// In a mutation:
export const deletePost = mutation({
args: { id: v.id("posts") },
handler: async (ctx, args) => {
const roles = await rbacGuard(ctx); // throws if getUserRoles fails
assertCollectionAccess({
collectionSlug: "posts",
operation: "delete",
userRoles: roles,
accessMap,
});
await ctx.db.delete(args.id);
},
});
assertCollectionAccess
Throws if the current user lacks the required role for an operation on a collection. When userRoles is null (RBAC not configured) or no access config exists for the collection, access is granted silently.
function assertCollectionAccess({
collectionSlug,
operation,
userRoles,
accessMap,
}: {
collectionSlug: string;
operation: "read" | "create" | "update" | "delete";
userRoles: string[] | null;
accessMap: Map<string, {
read?: string;
create?: string;
update?: string;
delete?: string;
}>;
}): void;
Throws: Error("Access denied: \"<operation>\" on \"<slug>\" requires role \"<role>\"").
Example
import { assertCollectionAccess } from "vextro/convex/security";
const accessMap = new Map([
["posts", { create: "editor", delete: "admin" }],
["settings", { read: "admin", create: "admin", update: "admin", delete: "admin" }],
]);
assertCollectionAccess({
collectionSlug: "posts",
operation: "delete",
userRoles: ["editor"], // throws — "editor" cannot delete
accessMap,
});
assertCollectionAccess({
collectionSlug: "posts",
operation: "create",
userRoles: ["editor"], // passes — "editor" can create
accessMap,
});
validateDocumentPayload
Validates and sanitizes a v.any() document payload before writing to the database. Strips Convex system fields (_id, _creationTime) and any keys not present in the provided field definitions.
Use this for mutations that accept open document payloads to prevent arbitrary field injection.
function validateDocumentPayload(
payload: unknown,
fields: Array<{ name: string; fieldType: string; config?: Record<string, unknown> }>
): asserts payload is Record<string, unknown>;
Throws if payload is not a plain object.
Example
import { validateDocumentPayload } from "vextro/convex/security";
export const upsertPost = mutation({
args: { data: v.any() },
handler: async (ctx, args) => {
validateDocumentPayload(args.data, [
{ name: "title", fieldType: "text" },
{ name: "body", fieldType: "richText" },
{ name: "publishedAt", fieldType: "date" },
]);
// args.data is now typed as Record<string, unknown>
// _id, _creationTime, and unknown fields have been stripped
await ctx.db.insert("posts", args.data);
},
});
createWebhookSecretGuard
Creates a guard function that restricts access to webhook secrets by role. Call the returned guard before returning any secret value to the client.
function createWebhookSecretGuard({
requiredRole,
}: {
requiredRole: string;
}): (userRoles: string[] | null) => void;
Throws: Error("Access denied: webhook secrets require role \"<role>\"").
When userRoles is null (RBAC not configured), access is granted.
Example
import { createWebhookSecretGuard } from "vextro/convex/security";
const guardWebhookSecret = createWebhookSecretGuard({ requiredRole: "admin" });
export const getWebhookConfig = query({
handler: async (ctx) => {
const roles = await rbacGuard(ctx);
guardWebhookSecret(roles); // throws if user is not "admin"
return ctx.db.query("webhook_config").first();
},
});