Setup Sigma Auth OAuth integration in a Convex application. Guides through installing @sigma-auth/better-auth-plugin, configuring Convex environment variables, and setting up the auth server.
From sigma-authnpx claudepluginhub b-open-io/claude-plugins --plugin sigma-authThis skill is limited to using the following tools:
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Implements structured self-debugging workflow for AI agent failures: capture errors, diagnose patterns like loops or context overflow, apply contained recoveries, and generate introspection reports.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Guide for integrating Sigma Auth (Bitcoin-native authentication) into a Convex application using the @sigma-auth/better-auth-plugin package with @convex-dev/better-auth.
For general Better Auth concepts (session management, plugins, hooks), see
Skill(better-auth-best-practices). This skill covers Convex + Sigma specific wiring and deployment pitfalls.
setup-nextjs insteadbun add @convex-dev/better-auth@0.10.10 better-auth@1.4.9 @sigma-auth/better-auth-plugin
@convex-dev/better-auth@0.10.10has a strict peer dependency onbetter-auth@1.4.9.
convex/convex.config.ts)Register the Better Auth component. Its tables (user, session, account, verification) are isolated from your app tables.
import betterAuth from "@convex-dev/better-auth/convex.config";
import { defineApp } from "convex/server";
const app = defineApp();
app.use(betterAuth);
export default app;
convex/auth.config.ts)import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;
Next.js app (.env.local)
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
NEXT_PUBLIC_CONVEX_SITE_URL=https://your-deployment.convex.site
NEXT_PUBLIC_SIGMA_CLIENT_ID=your-app-name
NEXT_PUBLIC_SIGMA_AUTH_URL=https://auth.sigmaidentity.com
Convex deployment (CLI)
npx convex env set BETTER_AUTH_URL "https://your-site-url"
npx convex env set BETTER_AUTH_SECRET "your-random-secret"
npx convex env set NEXT_PUBLIC_SIGMA_CLIENT_ID "your-app-name"
npx convex env set NEXT_PUBLIC_SIGMA_AUTH_URL "https://auth.sigmaidentity.com"
npx convex env set SIGMA_MEMBER_PRIVATE_KEY "your-member-wif-key"
Use
--prodfor production:npx convex env set VAR "value" --prod
convex/auth.ts)Add the sigmaCallbackPlugin to your Better Auth server configuration. This runs inside the Convex environment.
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { sigmaCallbackPlugin } from "@sigma-auth/better-auth-plugin/server";
import { betterAuth } from "better-auth/minimal";
import { components } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import authConfig from "./auth.config";
export const authComponent = createClient<DataModel>(components.betterAuth);
export const createAuth = (ctx: GenericCtx<DataModel>) => {
const siteUrl = process.env.BETTER_AUTH_URL!;
return betterAuth({
baseURL: siteUrl,
secret: process.env.BETTER_AUTH_SECRET,
database: authComponent.adapter(ctx),
trustedOrigins: [siteUrl],
plugins: [
convex({ authConfig }),
sigmaCallbackPlugin(),
],
});
};
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx);
},
});
Important: Use
"better-auth/minimal"(NOT"better-auth") for the Convex runtime.
lib/auth-server.ts)Proxy /api/auth/* requests to Convex.
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL!;
const convexSiteUrl = process.env.NEXT_PUBLIC_CONVEX_SITE_URL!;
export const {
handler,
preloadAuthQuery,
isAuthenticated,
getToken,
fetchAuthQuery,
fetchAuthMutation,
fetchAuthAction,
} = convexBetterAuthNextJs({ convexUrl, convexSiteUrl });
app/api/auth/[...all]/route.ts)import { handler } from "@/lib/auth-server";
export const { GET, POST } = handler;
lib/auth-client.ts)import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { sigmaClient } from "@sigma-auth/better-auth-plugin/client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [convexClient(), sigmaClient()],
});
export const { signIn, signOut, useSession } = authClient;
components/convex-provider.tsx)Replace the standard ConvexProvider with the auth-aware provider.
"use client";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
import { authClient } from "@/lib/auth-client";
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL!;
const convex = new ConvexReactClient(convexUrl);
export function ConvexClientProvider({
children,
initialToken,
}: {
children: ReactNode;
initialToken?: string | null;
}) {
return (
<ConvexBetterAuthProvider
client={convex}
authClient={authClient}
initialToken={initialToken}
>
{children}
</ConvexBetterAuthProvider>
);
}
"use client";
import { signIn } from "@/lib/auth-client";
export function SignInButton() {
return (
<button onClick={() => signIn.sigma({
clientId: process.env.NEXT_PUBLIC_SIGMA_CLIENT_ID!,
// callbackURL defaults to /auth/sigma/callback
})}>
Sign in with Sigma
</button>
);
}
sigmaClient initiates the OAuth flow, redirecting to auth.sigmaidentity.com.convexBetterAuthNextJs handler proxies /api/auth/* requests to Convex HTTP actions.sigmaCallbackPlugin running in Convex:
SIGMA_MEMBER_PRIVATE_KEY.ConvexBetterAuthProvider reads session state and provides it to your app via useSession().The default "supported plugins" for @convex-dev/better-auth do not include admin or organization. To use these plugins, you must use the Local Install approach, which replaces the npm component with a local component definition that you control.
Full documentation: https://labs.convex.dev/better-auth/features/local-install
The standard @convex-dev/better-auth component ships a fixed schema. Plugins like admin and organization add additional tables and fields that the standard component does not know about. The local install lets you generate a schema that includes these plugin tables, then wire up the adapter yourself.
convex/betterAuth/convex.config.ts)Replace the npm component with a local component definition:
import { defineComponent } from "convex/server";
const component = defineComponent("betterAuth");
export default component;
convex/convex.config.ts)Point the app at your local component instead of the npm package:
import { defineApp } from "convex/server";
import betterAuth from "./betterAuth/convex.config";
const app = defineApp();
app.use(betterAuth);
export default app;
convex/auth.ts)Split into two functions: createAuthOptions (safe for module-load analysis) and createAuth (validates env vars at runtime).
Critical pattern -- env() vs must(): The createApi call in the adapter imports createAuthOptions at module load time, before Convex env vars are available. If createAuthOptions throws on a missing env var, the push will fail. Use an env() helper that returns "" instead of throwing. The createAuth function runs at request time, so it can validate with must().
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { sigmaCallbackPlugin } from "@sigma-auth/better-auth-plugin/server";
import { betterAuth } from "better-auth/minimal";
import { admin } from "better-auth/plugins/admin";
import { organization } from "better-auth/plugins/organization";
import { components } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import authConfig from "./auth.config";
import authSchema from "./betterAuth/schema";
// Returns "" instead of throwing -- safe for module-load analysis
function env(name: string): string {
return process.env[name] ?? "";
}
// Throws if missing -- use only at request time
function must(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing env var: ${name}`);
return v;
}
export const authComponent = createClient<DataModel, typeof authSchema>(
components.betterAuth,
{ local: { schema: authSchema } },
);
// Safe for module-load: never throws on missing env vars
export function createAuthOptions(ctx?: GenericCtx<DataModel>) {
const siteUrl = env("BETTER_AUTH_URL");
return {
baseURL: siteUrl,
secret: env("BETTER_AUTH_SECRET"),
database: ctx ? authComponent.adapter(ctx) : undefined,
trustedOrigins: [siteUrl, "http://localhost:3000"].filter(Boolean),
plugins: [
convex({ authConfig }),
sigmaCallbackPlugin(),
admin(),
organization(),
],
};
}
// Request-time only: validates env vars, then creates the auth instance
export const createAuth = (ctx: GenericCtx<DataModel>) => {
must("BETTER_AUTH_URL");
must("BETTER_AUTH_SECRET");
return betterAuth({
...createAuthOptions(ctx),
database: authComponent.adapter(ctx),
});
};
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx);
},
});
Create a temporary file convex/betterAuth/auth.ts for schema generation:
import { createAuth } from "../auth";
export const auth = createAuth({} as any);
Then generate the schema:
cd convex/betterAuth
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=dummy npx @better-auth/cli generate -y
Delete the temporary file immediately after generation:
rm convex/betterAuth/auth.ts
Convex analyzes all
.tsfiles in theconvex/tree at push time. Ifauth.tsremains, the push will fail becausecreateAuth({} as any)triggers env var access during module analysis.
convex/betterAuth/adapter.ts)Wire the generated schema to the auth options:
import { createApi } from "@convex-dev/better-auth";
import { createAuthOptions } from "../auth";
import schema from "./schema";
export const {
create,
findOne,
findMany,
updateOne,
updateMany,
deleteOne,
deleteMany,
} = createApi(schema, createAuthOptions);
lib/auth-client.ts)Add the admin and organization client plugins:
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { sigmaClient } from "@sigma-auth/better-auth-plugin/client";
import { createAuthClient } from "better-auth/react";
import { adminClient } from "better-auth/client/plugins";
import { organizationClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
convexClient(),
sigmaClient(),
adminClient(),
organizationClient(),
],
});
export const { signIn, signOut, useSession } = authClient;
Whenever you add, remove, or update plugins, regenerate the schema:
# Re-create the temporary auth.ts
cat > convex/betterAuth/auth.ts << 'EOF'
import { createAuth } from "../auth";
export const auth = createAuth({} as any);
EOF
# Generate
cd convex/betterAuth
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=dummy npx @better-auth/cli generate -y
# Clean up
rm convex/betterAuth/auth.ts
Before going live, verify ALL of these. Missing any one causes silent auth failures:
| Variable | Value | Purpose |
|---|---|---|
NEXT_PUBLIC_CONVEX_URL | https://<deployment>.convex.cloud | Client SDK connection (queries, mutations, subscriptions) |
NEXT_PUBLIC_CONVEX_SITE_URL | https://<deployment>.convex.site | HTTP actions URL (auth proxy forwards requests here) |
CRITICAL: NEXT_PUBLIC_CONVEX_SITE_URL must be the .convex.site URL. If you set it to your app domain (e.g., https://myapp.com), the auth proxy (convexBetterAuthNextJs) will loop back to itself instead of forwarding to Convex. This causes infinite redirects or timeouts during sign-in.
bunx convex env set targets the DEV deployment by default. Production requires --prod:
# PRODUCTION (the deployed app uses these)
bunx convex env set BETTER_AUTH_URL "https://your-domain.com" --prod
bunx convex env set BETTER_AUTH_SECRET "$(openssl rand -hex 32)" --prod
bunx convex env set NEXT_PUBLIC_SIGMA_CLIENT_ID "your-app-name" --prod
bunx convex env set NEXT_PUBLIC_SIGMA_AUTH_URL "https://auth.sigmaidentity.com" --prod
bunx convex env set SIGMA_MEMBER_PRIVATE_KEY "your-wif-key" --prod
# Verify they're actually set
bunx convex env list --prod
Without --prod, your production app has NO env vars and auth silently fails.
Running bunx convex dev only pushes functions to the dev deployment. Production needs:
bunx convex deploy --yes
Without this, your production Convex deployment has no functions, queries, or HTTP actions.
On Vercel (Settings > Environment Variables), set:
NEXT_PUBLIC_CONVEX_URL=https://<deployment>.convex.cloud
NEXT_PUBLIC_CONVEX_SITE_URL=https://<deployment>.convex.site
Common mistake: Setting NEXT_PUBLIC_CONVEX_SITE_URL to your app domain (https://myapp.com). This breaks the auth proxy.
baseURL = Your App DomainIn convex/auth.ts, baseURL (from BETTER_AUTH_URL) must be your user-facing domain:
baseURL: process.env.BETTER_AUTH_URL, // e.g., "https://myapp.com"
NOT the .convex.site URL. This is used for OAuth redirect URIs.
The auth route handler must defer env var access to request time:
// src/app/api/auth/[...all]/route.ts
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
function createHandler() {
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
const convexSiteUrl = process.env.NEXT_PUBLIC_CONVEX_SITE_URL;
if (!convexUrl || !convexSiteUrl) {
throw new Error(
"NEXT_PUBLIC_CONVEX_URL and NEXT_PUBLIC_CONVEX_SITE_URL must be set",
);
}
return convexBetterAuthNextJs({ convexUrl, convexSiteUrl }).handler;
}
export const GET = async (req: Request) => {
const { GET } = createHandler();
return GET(req);
};
export const POST = async (req: Request) => {
const { POST } = createHandler();
return POST(req);
};
Without this, convexBetterAuthNextJs throws at build time when env vars aren't available yet.
A bare sign-in button is not sufficient. Set up proper auth pages:
bunx shadcn@latest add login-05 signup-05
Then adapt for Sigma auth: remove email/password fields, keep the branded layout. Use your project logo, not the default icons.
Also add sign-in/sign-out controls to the app shell (sidebar footer, header) so users can access auth from anywhere.
Don't blindly redirect unauthenticated users into the app. Use Next.js middleware to check auth and redirect to /login. See Skill(better-auth-best-practices) for the middleware pattern. Key: use betterFetch("/api/auth/get-session", { baseURL: request.nextUrl.origin }) and match all routes except api, _next, login, signup.
Symptom: OAuth flow succeeds but callback returns "Token Exchange Failed - Server returned 403". Better Auth logs: Invalid origin: https://your-preview-url.vercel.app
Root Cause: Better Auth's CSRF protection rejects POST requests from origins not in trustedOrigins. This is common on Vercel preview deployments with dynamic URLs.
Fix: Add Vercel's auto-set env vars to your Better Auth config's trustedOrigins in convex/auth.ts. You'll need to set VERCEL_URL and VERCEL_BRANCH_URL as Convex env vars (Convex doesn't get these automatically like Vercel does):
trustedOrigins: [
siteUrl,
process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "",
process.env.VERCEL_BRANCH_URL ? `https://${process.env.VERCEL_BRANCH_URL}` : "",
"http://localhost:3000",
].filter(Boolean),
Convex env vars are separate from Vercel env vars. Set them with:
npx convex env set VAR_NAME "value" # Dev deployment
npx convex env set VAR_NAME "value" --prod # Production deployment
npx convex env list # Verify dev
npx convex env list --prod # Verify production
Ensure your callback URL is registered in Sigma for every domain you deploy to:
http://localhost:3000/auth/sigma/callback (local dev)https://your-domain.com/auth/sigma/callback (production)SIGMA_MEMBER_PRIVATE_KEY is critical for signing token exchange requests. Ensure it is stored securely in Convex environment variables and never exposed to the client.Full documentation: https://github.com/b-open-io/better-auth-plugin