Setup Next.js with Sigma Auth

Guide for integrating Sigma Auth (Bitcoin-native authentication) into a Next.js application using the @sigma-auth/better-auth-plugin package.

When to Use

  • Setting up new Next.js app with Sigma Identity authentication
  • Adding Bitcoin-native auth to existing Next.js project
  • Implementing OAuth flow with auth.sigmaidentity.com
  • Integrating BAP (Bitcoin Attestation Protocol) identity

Scripts

detect.ts - Project Analysis

Analyzes your project structure and provides setup recommendations.

# Analyze current directory
bun run scripts/detect.ts

# Analyze specific project
bun run scripts/detect.ts /path/to/project

Output: JSON report including:

  • Framework detection (Next.js App Router, Pages Router, Payload CMS)
  • Package manager detection
  • Directory structure
  • Existing auth configuration
  • Recommendations for setup

validate-env.ts - Environment Validation

Validates required environment variables and checks WIF format.

# Check environment from .env.local or .env
bun run scripts/validate-env.ts

# Check specific env file
bun run scripts/validate-env.ts .env.production

Output: JSON report with status of each required variable.

health-check.ts - Integration Health Check

Tests connection to Sigma Auth server and validates OAuth configuration.

# Check default auth server
bun run scripts/health-check.ts

# Check specific auth server
bun run scripts/health-check.ts https://auth.sigmaidentity.com

Output: JSON report including:

  • Auth server connectivity
  • OpenID configuration availability
  • JWKS endpoint status
  • Response latency

Installation

bun add @sigma-auth/better-auth-plugin

Quick Start

Choose Your Integration Mode

  • Mode A — OAuth client (cross-domain): Your app is not the auth server. You handle tokens locally.
  • Mode B — Same-domain Better Auth server: You run Better Auth (or proxy to Convex/another backend) on your own domain and can use sessions.

Mode A — OAuth Client (Cross-Domain)

Use this when your users authenticate against auth.sigmaidentity.com (or another Better Auth server) on a different domain.

1. Environment Variables (.env.local)

# Public variables
NEXT_PUBLIC_SIGMA_CLIENT_ID=your-app-name
NEXT_PUBLIC_SIGMA_AUTH_URL=https://auth.sigmaidentity.com

# Private variables (server-only)
SIGMA_MEMBER_PRIVATE_KEY=your-member-wif-key

# Optional (needed behind proxies for correct redirect_uri)
NEXT_PUBLIC_APP_URL=http://localhost:3000

2. Client Configuration (lib/auth-client.ts)

import { createAuthClient } from "better-auth/react";
import { sigmaClient } from "@sigma-auth/better-auth-plugin/client";

export const authClient = createAuthClient({
  baseURL: "/api/auth", // default; points to your app's API routes
  plugins: [sigmaClient()],
});

export const { signIn } = authClient;

If you are not using React hooks, import createAuthClient from "better-auth/client" instead.

For cross-domain OAuth, manage user state locally (cookies/local state). useSession only works in Mode B.

3. Token Exchange API (app/api/auth/sigma/callback/route.ts)

import { createCallbackHandler } from "@sigma-auth/better-auth-plugin/next";

export const runtime = "nodejs";
export const POST = createCallbackHandler();

4. OAuth Callback Page (app/auth/sigma/callback/page.tsx)

This page handles the OAuth redirect and stores tokens locally.

const result = await authClient.sigma.handleCallback(searchParams);
// result contains { access_token, user, ... } - store manually

5. Sign-In Component

"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>
  );
}

Mode B — Same-Domain Better Auth Server (Prisma/Convex/Other)

Use this when your app runs Better Auth on the same domain (or proxies to it). If you're using Convex, follow setup-convex for the exact wiring.

1. Environment Variables (.env.local)

NEXT_PUBLIC_SIGMA_CLIENT_ID=your-app-name
NEXT_PUBLIC_SIGMA_AUTH_URL=https://auth.sigmaidentity.com
SIGMA_MEMBER_PRIVATE_KEY=your-member-wif-key

2. Server Configuration (lib/auth.ts)

Add sigmaCallbackPlugin to your Better Auth server config. It registers POST /sigma/callback inside Better Auth.

import { betterAuth } from "better-auth";
import { sigmaCallbackPlugin } from "@sigma-auth/better-auth-plugin/server";

export const auth = betterAuth({
  plugins: [
    sigmaCallbackPlugin({
      // Optional overrides (defaults to env vars)
      // clientId: process.env.NEXT_PUBLIC_SIGMA_CLIENT_ID,
      // memberPrivateKey: process.env.SIGMA_MEMBER_PRIVATE_KEY,
    })
  ],
  // ... other config (database, etc.)
});

3. API Route (app/api/auth/[...all]/route.ts)

Expose Better Auth in Next.js. The plugin endpoint becomes /api/auth/sigma/callback.

import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { POST, GET } = toNextJsHandler(auth);

4. Client Configuration (lib/auth-client.ts)

import { createAuthClient } from "better-auth/react";
import { sigmaClient } from "@sigma-auth/better-auth-plugin/client";

export const authClient = createAuthClient({
  baseURL: "/api/auth",
  plugins: [sigmaClient()],
});

export const { signIn, useSession } = authClient;

5. OAuth Callback Page (app/auth/sigma/callback/page.tsx)

Still required because OAuth redirects are GETs. In this mode you can rely on session cookies instead of storing tokens manually.


Troubleshooting

403 on Token Exchange (CSRF / trustedOrigins)

Symptom: OAuth flow succeeds (user authenticates, code is returned in redirect), but the callback page shows "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 listed in trustedOrigins. This commonly happens on Vercel preview deployments (e.g. your-app-git-branch-team.vercel.app) which have dynamic URLs that don't match your hardcoded production domain.

Fix (Mode B): Add Vercel's auto-set environment variables to your trustedOrigins in lib/auth.ts:

const vercelUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : "";
const vercelBranchUrl = process.env.VERCEL_BRANCH_URL
  ? `https://${process.env.VERCEL_BRANCH_URL}`
  : "";

export const auth = betterAuth({
  trustedOrigins: [
    "https://your-production-domain.com",
    vercelUrl,
    vercelBranchUrl,
    "http://localhost:3000",
  ].filter(Boolean),
  // ...
});

VERCEL_URL and VERCEL_BRANCH_URL are automatically set by Vercel on every deployment (without the https:// protocol prefix). This covers all preview, branch, and production deployments.

Fix (Mode A): If using the standalone createCallbackHandler(), the POST to your own /api/auth/sigma/callback route may also be subject to your framework's CSRF protections. Ensure the callback origin is trusted.

Important: This is a Better Auth configuration issue, not a Sigma plugin issue. The OAuth flow (redirect to Sigma, user auth, redirect back with code) works correctly. The 403 happens on the subsequent local POST that exchanges the code for tokens.

Missing Environment Variables

Run the validation script to check all required env vars:

bun run scripts/validate-env.ts

Required variables:

  • NEXT_PUBLIC_SIGMA_CLIENT_ID - Your app's client ID registered with Sigma
  • NEXT_PUBLIC_SIGMA_AUTH_URL - The Sigma auth server URL (e.g. https://auth.sigmaidentity.com)
  • SIGMA_MEMBER_PRIVATE_KEY - Server-only WIF key for signing token exchange requests

Callback URL Mismatch

Symptom: OAuth redirect fails or returns an error before reaching your callback page.

Root Cause: The redirect URI sent during the OAuth flow doesn't match the allowed callback URLs configured in your Sigma client registration.

Fix: 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)
  • https://your-app-git-branch-team.vercel.app/auth/sigma/callback (Vercel previews)

auth-client baseURL Misconfiguration

Symptom: Sign-in button does nothing, or token exchange POSTs go to the wrong URL.

Root Cause: The baseURL in createAuthClient() must point to your own app's API routes, not the Sigma auth server.

// Mode A: Points to YOUR app's API routes
export const authClient = createAuthClient({
  baseURL: "/api/auth", // Relative to your app
  plugins: [sigmaClient()],
});

// Mode B: Same pattern - YOUR app serves Better Auth
export const authClient = createAuthClient({
  baseURL: "/api/auth", // Relative to your app
  plugins: [sigmaClient()],
});

400 "Missing a required credential value" (PKCE code_verifier)

Symptom: OAuth flow reaches the callback page, the callback handler calls the auth server's token endpoint, but gets 400: "Missing a required credential value for authorization_code grant".

Root Cause: The auth server's @better-auth/oauth-provider requires either code_verifier (PKCE) or client_secret for the authorization_code grant. The sigma plugin uses PKCE, storing code_verifier in sessionStorage during signIn.sigma() and reading it back in handleCallback(). If the verifier is missing from the token exchange, this error occurs.

Common causes:

  1. Cross-domain detection failure (fixed in v0.0.74): Plugin v0.0.73 used String.includes() which incorrectly matched subdomains ("auth.sigmaidentity.com".includes("sigmaidentity.com") = true). This made the plugin think it was same-domain when it was cross-domain, causing it to use $fetch instead of the local API proxy.
  2. Mode B baseURL remnant: If createAuthClient({ baseURL: 'https://auth.sigmaidentity.com' }) is set (Mode B pattern), Better Auth's $fetch calls go to the auth server. While this doesn't directly affect PKCE (handleCallback uses native fetch for cross-domain), it can cause other issues.
  3. sessionStorage cleared: The code_verifier is stored in sessionStorage which is per-origin and per-tab. If the sign-in opens in a different tab or the session is cleared, the verifier is lost.

Fix: Ensure plugin is v0.0.74+, remove any baseURL pointing to the auth server, and verify the callback handler logs show hasCodeVerifier: true.

Migrating from Mode B to Mode A

Symptom: Various auth failures after switching from same-domain (Mode B) to cross-domain (Mode A) OAuth.

Common leftover issues:

  1. baseURL still pointing to auth server: In Mode A, either omit baseURL (defaults to current origin) or set it to your app's own API path (e.g., /api/auth). Never point it to the auth server for Mode A.
  2. Missing API route: Mode A requires app/api/auth/sigma/callback/route.ts with createCallbackHandler(). Mode B uses Better Auth's built-in routing.
  3. Catch-all route conflict: If you had app/api/auth/[...all]/route.ts for Mode B, it may conflict with the explicit app/api/auth/sigma/callback/route.ts in Mode A. Remove the catch-all or ensure the explicit route takes precedence.
  4. signOut() calling auth server: authClient.signOut() uses the baseURL. In Mode A, this should go to your app (or be handled locally), not the auth server.

Checklist for Mode B to Mode A migration:

  • Remove baseURL from createAuthClient() (or set to own app URL)
  • Add app/api/auth/sigma/callback/route.ts with createCallbackHandler()
  • Remove app/api/auth/[...all]/route.ts (if it was for Mode B)
  • Remove server-side betterAuth() config and sigmaCallbackPlugin (not needed in Mode A)
  • Update sign-out to clear local state only (no server session to invalidate in Mode A)
  • Verify NEXT_PUBLIC_SIGMA_CLIENT_ID and SIGMA_MEMBER_PRIVATE_KEY env vars are set

Plugin Version Pinning

Symptom: After updating the plugin, node_modules still has the old version.

Root Cause: Bun's lockfile or cache may resolve to an older version, especially with caret (^) ranges.

Fix: Pin the exact version in package.json (no caret):

"@sigma-auth/better-auth-plugin": "0.0.74"

Then run:

bun add @sigma-auth/better-auth-plugin@0.0.74

Verify with:

cat node_modules/@sigma-auth/better-auth-plugin/package.json | grep version

React Hooks Violation in Callback Page

Symptom: React error about hooks being called conditionally, or callback page not rendering properly.

Root Cause: useState or other hooks called inside conditional blocks (e.g., if (error) { const [copied, setCopied] = useState(false); }). This violates the Rules of Hooks.

Fix: Move ALL hooks to the top level of the component function. Use conditional rendering in JSX instead of conditional hook calls.

Security Considerations

  • Environment Variables: Never expose SIGMA_MEMBER_PRIVATE_KEY to the client. It is required only on the server for signing token exchange requests.
  • Token Storage: In Mode A, store tokens securely (avoid localStorage for refresh tokens in production).
  • Same-Domain Sessions: useSession only works when your auth server is on the same domain (Mode B).
  • Wallet Unlock Gate: Plugin ensures wallet access before authentication (session -> local backup -> cloud backup -> signup).
  • PKCE: The client plugin automatically handles PKCE (Proof Key for Code Exchange) for secure OAuth flows.

Reference

Full documentation: https://github.com/b-open-io/better-auth-plugin