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
| Option | Type | Default | Description |
|---|---|---|---|
getConvexSiteUrl | () => string | required | Returns the Convex deployment URL. |
isAuthRoute | (pathname) => boolean | required | Routes that bypass auth. |
isPublicAsset | (pathname) => boolean | /_astro, /favicon, /assets | Static assets that skip token fetching. |
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. |
getTokenFn | typeof getToken | getToken | Custom token-exchange function. Defaults to getToken from @convex-dev/better-auth/utils. |
cookiePrefix | string | "better-auth" | Cookie name prefix used by better-auth. Must match your better-auth configuration. |
convexUrl | string | -- | Convex deployment URL. When provided, a ConvexHttpClient is created per-request and attached to context.locals.convex. |
Auth flow
- A request arrives at a protected route.
- Middleware exchanges the session cookie for a Convex JWT via
getToken(). - Valid tokens are stored via
setAuthToken; the request continues. - Missing tokens redirect page routes to
loginPathor return401for 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" | Option | Type | Description |
|---|---|---|
convexUrl* | string | The 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 }),
}); | Option | Type | Default | Description |
|---|---|---|---|
getConvexSiteUrl* | () => string | -- | Returns the target URL to proxy requests to. |
fetchFn | typeof fetch | fetch | Custom 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
| Option | Type | Default | Description |
|---|---|---|---|
hooks | VextroAuthHooks | {} | Auth lifecycle hook arrays. |
auditLog | boolean | false | Auto-log auth events to the Vextro audit log. |
Available hooks
| Hook | Fires | Arguments |
|---|---|---|
afterLogin | After successful sign-in or callback | { user, session, ctx } |
afterLogout | After sign-out | { userId, ctx } |
afterSignup | After a new user is created | { user, ctx } |
afterSessionRefresh | After 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
| Preset | Import | Injected field | Injected index |
|---|---|---|---|
okta | okta(options) | oktaSub | by_oktaSub |
google | google(options) | googleId | by_googleId |
github | github(options) | githubId | by_githubId |
microsoft | microsoft(options) | microsoftId | by_microsoftId |
oidc | oidc(options) | oidcSub | by_oidcSub |
emailPassword | emailPassword() | -- | -- |
magicLink | magicLink() | -- | -- |
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:
| Option | Type | Default | Description |
|---|---|---|---|
clientId* | string | -- | OAuth client ID. |
clientSecret* | string | -- | OAuth client secret. |
issuer | string | -- | OIDC issuer URL (required for okta, microsoft, and oidc). |
scopes | string[] | Provider-specific | OAuth scopes to request. |
pkce | boolean | true | Use PKCE for the authorization flow. |
defineVextroAuth options
| Option | Type | Default | Description |
|---|---|---|---|
appName | string | -- | App name for auth UI. |
basePath | string | "/api/auth" | Base path for auth routes. |
providers* | ProviderPreset[] | -- | Array of provider presets. |
components | any | -- | @convex-dev/better-auth component refs (components.betterAuth). Enables full auth integration. |
authConfig | any | -- | Convex auth config (from auth.config.ts). Required alongside components. |
plugins | any[] | [] | Additional Better Auth plugins to include. |
trustedOrigins | string[] | [] | Additional trusted origins for CORS. |
Return value
defineVextroAuth returns a VextroAuthResult object:
| Property | Type | Description |
|---|---|---|
appName | string? | App name passed in config. |
basePath | string | Auth route base path. |
providers | ProviderPreset[] | All configured providers. |
providerMeta | VextroAuthProviderMeta | Merged 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. |
client | any? | 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";