Features

Authentication (Better Auth)

Vextro supports multiple auth providers. This page covers the Better Auth harness at vextro/auth/better-auth. For Clerk, see the Clerk authentication guide.

Middleware setup

import { createAuthMiddleware } from "vextro/auth/better-auth";

export const onRequest = createAuthMiddleware({
  getConvexSiteUrl: () => import.meta.env.PUBLIC_CONVEX_URL,
  isAuthRoute: (pathname) =>
    pathname.startsWith("/api/auth/") || pathname === "/login",
  loginPath: "/login",
  setAuthToken: (context, token) => {
    context.locals.authToken = token;
  },
});

Middleware options

OptionTypeDefaultDescription
getConvexSiteUrl() => stringrequiredReturns the Convex deployment URL.
isAuthRoute(pathname) => booleanrequiredRoutes that bypass auth.
isPublicAsset(pathname) => boolean/_astro, /favicon, /assetsStatic assets that skip token fetching.
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.
getTokenFntypeof getTokengetTokenCustom token-exchange function. Defaults to getToken from @convex-dev/better-auth/utils.
cookiePrefixstring"better-auth"Cookie name prefix used by better-auth. Must match your better-auth configuration.
convexUrlstring--Convex deployment URL. When provided, a ConvexHttpClient is created per-request and attached to context.locals.convex.

Auth flow

  1. A request arrives at a protected route.
  2. Middleware exchanges the session cookie for a Convex JWT via getToken().
  3. Valid tokens are stored via setAuthToken; the request continues.
  4. Missing tokens redirect page routes to loginPath or return 401 for API routes.

For API routes, a fast path skips getToken() when an Authorization: Bearer header is present. Otherwise the middleware falls back to getTokenFn to exchange the session cookie for a Convex JWT.

On Better Auth sign-out routes, the middleware expires both normal and __Secure- variants of the session and Convex JWT cookies, along with Vextro's warm-client cookie. Keep sign-out paths inside isAuthRoute so this cleanup runs even when the upstream handler returns an error response.

URL utility

getConvexSiteUrl converts a Convex deployment URL (.convex.cloud) to the corresponding HTTP Actions URL (.convex.site). Local development URLs are returned unchanged.

import { getConvexSiteUrl } from "vextro/auth/better-auth";

const siteUrl = getConvexSiteUrl({ convexUrl: import.meta.env.CONVEX_URL });
// "https://my-app-123.convex.cloud" → "https://my-app-123.convex.site"
// "http://localhost:3210"           → "http://localhost:3210"
OptionTypeDescription
convexUrl*stringThe Convex deployment URL to convert.

Auth proxy handler

createAuthProxyHandler creates an Astro API route that proxies requests to the Convex site URL. This is used to forward Better Auth API calls from the Astro server to the Convex HTTP Actions backend.

The proxy preserves the incoming HTTP method and streamed request body, including POST bodies on current Node runtimes, and forwards redirect responses without following them.

import type { APIRoute } from "astro";
import { createAuthProxyHandler, getConvexSiteUrl } from "vextro/auth/better-auth";

const convexUrl = import.meta.env.CONVEX_URL;

export const ALL: APIRoute = createAuthProxyHandler({
  getConvexSiteUrl: () => getConvexSiteUrl({ convexUrl }),
});
OptionTypeDefaultDescription
getConvexSiteUrl*() => string--Returns the target URL to proxy requests to.
fetchFntypeof fetchfetchCustom fetch implementation for the proxy request.

The handler preserves cookies, forwards x-forwarded-for, and returns a mutable Response compatible with Astro's response pipeline.

Permission guards

import { hasAnyPermission, hasAllPermissions } from "vextro/auth/better-auth";

const canEdit = hasAnyPermission({
  userPermissions: user.permissions,
  required: ["posts.edit", "posts.admin"],
});

const canManage = hasAllPermissions({
  userPermissions: user.permissions,
  required: ["posts.edit", "posts.delete"],
});

Protecting pages

Access the auth token from Astro.locals and pass it to Convex queries:

---
const token = Astro.locals.authToken;
if (!token) return Astro.redirect("/login");
const user = await convex.query(api.users.me, {}, { token });
---

Auth lifecycle hooks

Vextro provides a Better Auth plugin that fires lifecycle hooks on auth events. The plugin integrates with your project's Better Auth setup -- Vextro does not own auth, it provides composable utilities.

Setup

import { createVextroAuthPlugin, createConvexAuth, genericOAuth } from "vextro/auth/better-auth";

export const createAuth = createConvexAuth({
  plugins: [
    genericOAuth({ /* ... */ }),
    createVextroAuthPlugin({
      hooks: {
        afterLogin: [
          async ({ user, session, ctx }) => {
            console.log(`User ${user.email} logged in`);
          },
        ],
        afterSignup: [
          async ({ user, ctx }) => {
            console.log(`New user: ${user.email}`);
          },
        ],
        afterLogout: [
          async ({ userId, ctx }) => {
            console.log(`User ${userId} logged out`);
          },
        ],
      },
      auditLog: true,
    }),
  ],
});

Plugin options

OptionTypeDefaultDescription
hooksVextroAuthHooks{}Auth lifecycle hook arrays.
auditLogbooleanfalseAuto-log auth events to the Vextro audit log.

Available hooks

HookFiresArguments
afterLoginAfter successful sign-in or callback{ user, session, ctx }
afterLogoutAfter sign-out{ userId, ctx }
afterSignupAfter a new user is created{ user, ctx }
afterSessionRefreshAfter a session is refreshed{ session, ctx }

Each hook type accepts an array of functions. Multiple hooks execute in order. Hook errors are caught and do not interrupt the auth flow.

TypeScript types

import type {
  VextroAuthHooks,
  VextroAuthPluginOptions,
  VextroAfterLoginHook,
  VextroAfterLogoutHook,
  VextroAfterSignupHook,
  VextroAfterSessionRefreshHook,
} from "vextro";

Non-blocking hooks

Auth hook errors are caught silently to prevent auth flow interruption. If a hook throws, the auth operation still completes. Use structured logging inside hooks to track failures.

Auth provider presets

Vextro ships provider presets that automatically inject the correct fields and indexes into the users table when used with createVextroSchema. This eliminates the need to manually add provider-specific fields like oktaSub or googleId.

Setup

import { defineVextroAuth, google, github } from "vextro/convex/authProviders";

export const authConfig = defineVextroAuth({
  appName: "My Admin",
  providers: [
    google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    github({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
  ],
});

Then pass the merged metadata to createVextroSchema:

import { createVextroSchema } from "vextro/convex/schema";

const vextro = createVextroSchema({
  collections: [...],
  globals: [...],
  auth: authConfig,
});

Built-in presets

PresetImportInjected fieldInjected index
oktaokta(options)oktaSubby_oktaSub
googlegoogle(options)googleIdby_googleId
githubgithub(options)githubIdby_githubId
microsoftmicrosoft(options)microsoftIdby_microsoftId
oidcoidc(options)oidcSubby_oidcSub
emailPasswordemailPassword()----
magicLinkmagicLink()----

emailPassword() and magicLink() take no arguments. They return a provider preset with no injected fields or indexes, since email is already present on the users table.

OAuth provider options

All OAuth presets accept these options:

OptionTypeDefaultDescription
clientId*string--OAuth client ID.
clientSecret*string--OAuth client secret.
issuerstring--OIDC issuer URL (required for okta, microsoft, and oidc).
scopesstring[]Provider-specificOAuth scopes to request.
pkcebooleantrueUse PKCE for the authorization flow.

defineVextroAuth options

OptionTypeDefaultDescription
appNamestring--App name for auth UI.
basePathstring"/api/auth"Base path for auth routes.
providers*ProviderPreset[]--Array of provider presets.
componentsany--@convex-dev/better-auth component refs (components.betterAuth). Enables full auth integration.
authConfigany--Convex auth config (from auth.config.ts). Required alongside components.
pluginsany[][]Additional Better Auth plugins to include.
trustedOriginsstring[][]Additional trusted origins for CORS.

Return value

defineVextroAuth returns a VextroAuthResult object:

PropertyTypeDescription
appNamestring?App name passed in config.
basePathstringAuth route base path.
providersProviderPreset[]All configured providers.
providerMetaVextroAuthProviderMetaMerged userFields and userIndexes for schema injection.
createAuth(ctx) => Auth?Creates a Better Auth instance for a Convex context. Only present when components and authConfig are provided.
clientany?The @convex-dev/better-auth client for direct API access. Only present when components and authConfig are provided.
registerRoutes(http, options?) => void?Registers auth routes on a Convex HTTP router. Only present when components and authConfig are provided.

Full Better Auth integration

When components and authConfig are provided, defineVextroAuth builds createAuth, client, and registerRoutes automatically. This eliminates the need to manually wire up Better Auth in your Convex backend.

import { defineVextroAuth, okta, google } from "vextro/convex/authProviders";
import { components } from "./_generated/api";
import authConfig from "./auth.config";

export const auth = defineVextroAuth({
  appName: "My Admin",
  components: components.betterAuth,
  authConfig,
  providers: [
    okta({
      issuer: process.env.OKTA_OAUTH_ISSUER!,
      clientId: process.env.OKTA_OAUTH_CLIENT_ID!,
      clientSecret: process.env.OKTA_OAUTH_CLIENT_SECRET!,
    }),
    google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  trustedOrigins: ["https://my-admin.example.com"],
});

Then register routes in http.ts:

import { auth } from "./auth";
import { httpRouter } from "convex/server";

const http = httpRouter();
auth.registerRoutes?.(http, { cors: true });
export default http;

Peer dependencies

createAuth, client, and registerRoutes require @convex-dev/better-auth and better-auth to be installed. These are optional peer dependencies — if not installed, these properties will be undefined. The providerMeta (schema injection) always works regardless.

Custom providers

Use defineProvider to create a custom preset for providers not covered by the built-ins:

import { v } from "convex/values";
import { defineProvider, defineVextroAuth } from "vextro/convex/authProviders";

const saml = defineProvider({
  id: "saml",
  userFields: { samlNameId: v.optional(v.string()) },
  userIndexes: [{ name: "by_samlNameId", fields: ["samlNameId"] }],
  options: { entryPoint: "https://idp.example.com/sso" },
});

export const authConfig = defineVextroAuth({
  providers: [saml],
});

TypeScript types

import type {
  DefineVextroAuthConfig,
  VextroAuthResult,
  ProviderPreset,
  OAuthProviderOptions,
  OidcProviderOptions,
  VextroAuthProviderMeta,
} from "vextro";
Previous
Access Control