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
createAuthClientfrom"better-auth/client"instead.For cross-domain OAuth, manage user state locally (cookies/local state).
useSessiononly 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 SigmaNEXT_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:
- 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$fetchinstead of the local API proxy. - Mode B baseURL remnant: If
createAuthClient({ baseURL: 'https://auth.sigmaidentity.com' })is set (Mode B pattern), Better Auth's$fetchcalls go to the auth server. While this doesn't directly affect PKCE (handleCallback uses nativefetchfor cross-domain), it can cause other issues. - 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:
baseURLstill pointing to auth server: In Mode A, either omitbaseURL(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.- Missing API route: Mode A requires
app/api/auth/sigma/callback/route.tswithcreateCallbackHandler(). Mode B uses Better Auth's built-in routing. - Catch-all route conflict: If you had
app/api/auth/[...all]/route.tsfor Mode B, it may conflict with the explicitapp/api/auth/sigma/callback/route.tsin Mode A. Remove the catch-all or ensure the explicit route takes precedence. signOut()calling auth server:authClient.signOut()uses thebaseURL. 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
baseURLfromcreateAuthClient()(or set to own app URL) - Add
app/api/auth/sigma/callback/route.tswithcreateCallbackHandler() - Remove
app/api/auth/[...all]/route.ts(if it was for Mode B) - Remove server-side
betterAuth()config andsigmaCallbackPlugin(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_IDandSIGMA_MEMBER_PRIVATE_KEYenv 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_KEYto the client. It is required only on the server for signing token exchange requests. - Token Storage: In Mode A, store tokens securely (avoid
localStoragefor refresh tokens in production). - Same-Domain Sessions:
useSessiononly 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