npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

better-auth-sync

v0.2.0

Published

Better Auth plugin to sync auth data via webhooks - perfect for mirroring to Convex and other databases

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-sync

Full 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.com

On your frontend app:

NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
CONVEX_SITE_URL=https://your-project.convex.site

2) 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 endpoint
  • verifyWebhook(secret, headers, body, options?): verify signed webhook requests
  • processEvent(db, event): idempotent upsert/delete into Convex mirror tables
  • authTables: prebuilt Convex table definitions for Better Auth entities
  • convexJwt(options): Better Auth JWT plugin config for Convex custom JWT auth
  • convexAuthConfig(options): helper for convex/auth.config.ts
  • fetchJwks(betterAuthUrl): fetch Better Auth JWKS for Convex route
  • createConvexBetterAuth(authClient): React bridge for ConvexProviderWithAuth
  • getAuth(db, sessionToken): read user/session by Better Auth session token
  • getStrictAuth(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 eventId in authEvents

License

MIT