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 AuthClerk
HostingSelf-hostedManaged service
SetupMore setup (database adapter, plugin)Faster setup (publishable key + secret key)
ControlFull control over schema and sessionsClerk controls session lifecycle
Vendor dependencyNone beyond your own infraClerk vendor lock-in
Import prefixvextro/auth/better-authvextro/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;
};
FieldTypeDefaultDescription
providerIdstring—OAuth provider identifier.
callbackPathstring—OAuth callback route path.
authPathstring—Base path for auth API routes.
titlestring—Login page title.
descriptionstring—Login page description text.
buttonLabelstring—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

FieldTypeDefaultDescription
getConvexSiteUrl() => stringrequiredReturns the Convex site URL for token exchange.
isAuthRoute(pathname: string) => booleanrequiredReturns true for auth API routes (skips token fetch to avoid loops).
isPublicAsset(pathname: string) => booleanStarts with /_astro, /favicon, /assetsReturns true for static assets.
isApiRoute(pathname: string) => booleanStarts with /api/ but not /api/auth/Returns true for API routes (returns 401 instead of redirect).
loginPathstring"/login"Path to redirect unauthenticated users.
setAuthToken(context, token) => void—Callback to store token on context.locals.
getTokenFnFunctiongetToken from better-authCustom token exchange function.
convexUrlstring—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

FieldTypeDefaultDescription
appNamestringrequiredApplication name.
baseURLstringrequiredBase URL for auth endpoints.
basePathstring"/api/auth"Auth API path prefix.
secretstringrequiredAuth secret key.
trustedOriginsstring[][]Trusted origins for CORS.
account{ storeStateStrategy? }—Account configuration.
database(ctx) => unknownrequiredDatabase adapter factory.
pluginsPlugin[]requiredbetter-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

FieldTypeDefaultDescription
hooksVextroAuthHooks{}Auth lifecycle hooks.
auditLogbooleanfalseAutomatically log auth events.

VextroAuthHooks

HookTypeTrigger
afterLoginArray<(args: { user, session, ctx }) => Promise<void> | void>After successful login.
afterLogoutArray<(args: { userId?, ctx }) => Promise<void> | void>After logout.
afterSignupArray<(args: { user, ctx }) => Promise<void> | void>After new user registration.
afterSessionRefreshArray<(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 pathExports
vextro/auth/clerkcreateClerkMiddleware, createClerkConvexAuthConfig, all role/user getters, guards
vextro/auth/clerk/middlewarecreateClerkMiddleware
vextro/auth/clerk/convex-configcreateClerkConvexAuthConfig
vextro/auth/clerk/get-user-rolescreateClerkVextroGetUserRoles, createClerkNativeGetUserRoles
vextro/auth/clerk/get-current-usercreateClerkDbGetCurrentUser, createClerkJwtGetCurrentUser
vextro/auth/clerk/guardshasAnyPermission, hasAllPermissions

createClerkMiddleware

Astro middleware that verifies Clerk JWTs and sets context.locals.convexAuthToken.

Token resolution order:

  1. Authorization: Bearer <token> header
  2. __session cookie

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

FieldTypeDefaultDescription
secretKeystringrequiredClerk secret key (CLERK_SECRET_KEY).
isAuthRoute(pathname: string) => booleanrequiredReturns true for routes that should skip auth (e.g., /login).
isPublicAsset(pathname: string) => booleanStarts with /_astro, /favicon, /assetsReturns true for static assets (skipped entirely).
isApiRoute(pathname: string) => booleanStarts with /api/ but not /api/auth/Returns true for API routes (returns 401 instead of redirect).
loginPathstring"/login"Redirect destination for unauthenticated page requests.
setAuthToken(context, token) => void—Additional callback to store the token (e.g., on a custom local).
convexUrlstring—When provided, creates a per-request ConvexHttpClient on context.locals.convex.
jwtKeystring—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

FieldTypeDefaultDescription
issuerDomainstringrequiredClerk Frontend API URL. Dev format: https://verb-noun-00.clerk.accounts.dev. Prod format: https://clerk.<YOUR_DOMAIN>.com.
applicationIDstring"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

FieldTypeDescription
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

FieldTypeDescription
claimMode"publicMetadata" | "orgRole" | "custom"required — How to read roles from the JWT. See modes table below.
roleClaimPathstringDot-separated path into the JWT payload. Only used when claimMode is "custom".

Claim Modes

ModeJWT pathClerk setup requiredNotes
"publicMetadata"metadata.roleClerk Dashboard → Sessions → Custom Claims: { "metadata": "{{ user.public_metadata }}" }Role(s) come from the user’s public metadata. Supports string or string array.
"orgRole"org_roleClerk Organizations enabled; org claim mapping configuredStrips org: prefix from the value (e.g., "org:admin" → "admin").
"custom"roleClaimPath (dot-separated)Any custom JWT template that sets the desired pathMost 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

FieldTypeDescription
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>;
ArgTypeDefaultDescription
getUserRoles(ctx) => Promise<string[]>—Role resolution function. When omitted, the guard always returns null (RBAC bypassed).
failOpenbooleanfalseWhen 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 slugs
  • null — RBAC not configured (no getUserRoles provided, or failOpen caught 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();
  },
});
Previous
Custom Components