better-auth-sync
v0.2.0
Published
Better Auth plugin to sync auth data via webhooks - perfect for mirroring to Convex and other databases
Maintainers
Readme
better-auth-sync
A Better Auth plugin that syncs auth data to external databases via webhooks, with first-class helpers for Convex.
Installation
npm install better-auth-syncFull Setup (Better Auth + Convex + JWT + React)
This is the end-to-end setup most apps want.
1) Define environment variables
On your Better Auth server:
BETTER_AUTH_URL=https://auth.your-app.com
WEBHOOK_URL=https://your-project.convex.site/auth-webhook
WEBHOOK_SECRET=replace-with-a-long-random-secret
APP_ORIGIN=https://your-app.comOn your frontend app:
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
CONVEX_SITE_URL=https://your-project.convex.site2) Configure Better Auth with sync + Convex JWT
// src/auth.ts
import { betterAuth } from "better-auth";
import { syncPlugin } from "better-auth-sync";
import { convexJwt } from "better-auth-sync/jwt";
export const auth = betterAuth({
// ...adapter, trustedOrigins, providers, etc.
plugins: [
convexJwt({
issuer: process.env.APP_ORIGIN!,
audience: process.env.APP_ORIGIN!,
}),
syncPlugin({
secret: process.env.WEBHOOK_SECRET!,
url: process.env.WEBHOOK_URL!,
retryAttempts: 3,
}),
],
});3) Add mirrored auth tables to Convex schema
// convex/schema.ts
import { defineSchema } from "convex/server";
import { authTables } from "better-auth-sync/convex";
export default defineSchema({
...authTables,
// your app tables...
});4) Configure Convex auth provider
// convex/auth.config.ts
import { convexAuthConfig } from "better-auth-sync/convex";
export default convexAuthConfig({
convexSiteUrl: process.env.CONVEX_SITE_URL!,
applicationID: process.env.APP_ORIGIN!,
});applicationID must match the JWT issuer you configured in convexJwt.
5) Add webhook + JWKS HTTP routes in Convex
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
import { verifyWebhook, fetchJwks } from "better-auth-sync/convex";
const http = httpRouter();
http.route({
path: "/auth-webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.text();
const verification = await verifyWebhook(
process.env.WEBHOOK_SECRET!,
request.headers,
body,
);
if (!verification.success) {
return new Response(verification.error, { status: 401 });
}
await ctx.runMutation(api.authSync.processAuthEvent, {
event: verification.event,
});
return new Response("ok", { status: 200 });
}),
});
http.route({
path: "/.well-known/jwks.json",
method: "GET",
handler: httpAction(async () => {
return fetchJwks(process.env.BETTER_AUTH_URL!);
}),
});
export default http;6) Process webhook events in a Convex mutation
// convex/authSync.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { processEvent } from "better-auth-sync/convex";
export const processAuthEvent = mutation({
args: { event: v.any() },
handler: async (ctx, { event }) => {
return await processEvent(ctx.db, event);
},
});7) Wire Convex auth into React
// src/providers/convex-provider.tsx
"use client";
import { ReactNode } from "react";
import { ConvexReactClient } from "convex/react";
import { ConvexProviderWithAuth } from "convex/react";
import { createConvexBetterAuth } from "better-auth-sync/react";
import { authClient } from "@/lib/auth-client";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
const useAuth = createConvexBetterAuth(authClient);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ConvexProviderWithAuth client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithAuth>
);
}8) (Optional) Read auth session data from mirrored tables
// convex/me.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { getAuth } from "better-auth-sync/convex";
export const me = query({
args: { sessionToken: v.string() },
handler: async (ctx, { sessionToken }) => {
const auth = await getAuth(ctx.db, sessionToken);
if (!auth) throw new Error("Unauthorized");
return auth.user;
},
});9) Use strict auth checks in Convex functions (recommended)
If you rely on JWT identity from ctx.auth.getUserIdentity(), add a strict
check against mirrored sessions so deleted/revoked sessions are treated as
unauthenticated.
// convex/secureQuery.ts
import { query } from "./_generated/server";
import { getStrictAuth } from "better-auth-sync/convex";
export const secureQuery = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
const auth = await getStrictAuth(ctx.db, identity);
if (!auth) {
throw new Error("Unauthorized");
}
return {
user: auth.user,
session: auth.session,
};
},
});Extending with custom entities
Third-party Better Auth plugins can be mirrored without waiting for a
release by registering them via customEntities. The key is the adapter
model name your plugin uses in adapter.create({ model }).
syncPlugin({
secret: process.env.WEBHOOK_SECRET!,
url: process.env.WEBHOOK_URL!,
customEntities: {
myPluginRecord: { stripFields: ["internalSecret"] },
},
});You'll also need to add a matching Convex table and an entry in
ENTITY_TO_TABLE on the receiving side. See authTables in
better-auth-sync/convex for the shape.
Built-in OAuth 2.1 Provider mirroring
The OAuth provider tables from @better-auth/oauth-provider are mirrored
out of the box (oauthClient, oauthAccessToken, oauthRefreshToken,
oauthConsent). Raw secrets and tokens (clientSecret, token) are
stripped before dispatch — they stay in your auth database only.
Core APIs
syncPlugin(options): dispatch auth lifecycle events to your webhook endpointverifyWebhook(secret, headers, body, options?): verify signed webhook requestsprocessEvent(db, event): idempotent upsert/delete into Convex mirror tablesauthTables: prebuilt Convex table definitions for Better Auth entitiesconvexJwt(options): Better Auth JWT plugin config for Convex custom JWT authconvexAuthConfig(options): helper forconvex/auth.config.tsfetchJwks(betterAuthUrl): fetch Better Auth JWKS for Convex routecreateConvexBetterAuth(authClient): React bridge forConvexProviderWithAuthgetAuth(db, sessionToken): read user/session by Better Auth session tokengetStrictAuth(db, identity): verify JWT identity also has an active mirrored session
Security Notes
- Webhooks are signed with
HMAC-SHA256(secret, timestamp + "." + rawBody) - Default replay protection window is 5 minutes (
toleranceInSeconds) - Event deduplication happens via
eventIdinauthEvents
License
MIT
