Sigma Auth
Setup

Architecture: Fronting Better Auth

The Sigma Auth client plugin intentionally fronts Better Auth's OIDC authorize endpoint. This is a deliberate architectural decision to ensure wallet access is a prerequisite to authentication.

Why We Front the Endpoint

Standard OAuth providers (Better Auth included) handle authentication in a straightforward way: check session, show consent, return authorization code. But Sigma Identity requires something more:

Wallet access must be verified before any OAuth flow completes.

Better Auth doesn't know about Bitcoin wallets, encrypted backups, or BAP identities. So we intercept the OAuth flow:

  1. Client plugin redirects to /oauth2/authorize (our custom gate)
  2. Gate checks: session → local backup → cloud backup → signup
  3. If wallet not accessible, prompts user to unlock it
  4. Once wallet is accessible, forwards to /api/auth/oauth2/authorize (Better Auth)
  5. Better Auth handles standard OAuth (session, consent, authorization code)

This makes Bitcoin identity the foundation of every authentication, not just an add-on.

The Gate Logic

Client App → /oauth2/authorize (custom gate)

      1. Check session cookie
         ├─ Yes → Proceed to Better Auth

      2. Check localStorage (encrypted backup)
         ├─ Yes → Show password prompt

      3. Check cloud backup availability
         ├─ Yes → Redirect to /restore

      4. No backup → Redirect to signup

All paths are logged for debugging and audit purposes.


Quick Start (Next.js)

Add Sigma Auth to your Next.js app in 3 steps:

1. Install

bun add @sigma-auth/better-auth-plugin better-auth
# or
npm install @sigma-auth/better-auth-plugin better-auth

2. Set Environment Variables

.env.local
# Your OAuth client ID (register at auth.sigmaidentity.com/account)
NEXT_PUBLIC_SIGMA_CLIENT_ID=your-app

# Your app's member private key for signing token exchange requests
SIGMA_MEMBER_PRIVATE_KEY=your-member-wif

3. Create Callback Route

app/api/auth/callback/route.ts
import { createCallbackHandler } from "@sigma-auth/better-auth-plugin/next";

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

That's it. The plugin handles PKCE, state validation, and token exchange automatically.

Client-Side Setup

Create an auth client:

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

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_SIGMA_AUTH_URL || "https://auth.sigmaidentity.com",
  plugins: [sigmaClient()],
});

export const { signIn, sigma } = authClient;

Sign In Button

components/SignInButton.tsx
"use client";

import { signIn } from "@/lib/auth";

export function SignInButton() {
  const handleSignIn = () => {
    signIn.sigma({
      clientId: process.env.NEXT_PUBLIC_SIGMA_CLIENT_ID!,
      callbackURL: `${window.location.origin}/callback`,
    });
  };

  return <button onClick={handleSignIn}>Sign in with Sigma</button>;
}

Callback Page

app/callback/page.tsx
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";

function CallbackContent() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [status, setStatus] = useState<"loading" | "error">("loading");
  const [error, setError] = useState("");

  useEffect(() => {
    const handleCallback = async () => {
      const code = searchParams.get("code");
      const codeVerifier = sessionStorage.getItem("sigma_code_verifier");

      if (!code) {
        setError("No authorization code");
        setStatus("error");
        return;
      }

      try {
        // Exchange code for tokens via your API route
        const response = await fetch("/api/auth/callback", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ code, code_verifier: codeVerifier }),
        });

        if (!response.ok) {
          const data = await response.json();
          throw new Error(data.error || "Token exchange failed");
        }

        const { user, access_token } = await response.json();

        // Store session (use your preferred state management)
        localStorage.setItem("auth_session", JSON.stringify({ user, access_token }));
        sessionStorage.removeItem("sigma_code_verifier");

        router.push("/dashboard");
      } catch (err) {
        setError(err instanceof Error ? err.message : "Authentication failed");
        setStatus("error");
      }
    };

    handleCallback();
  }, [searchParams, router]);

  if (status === "error") {
    return (
      <div className="p-4">
        <h2>Authentication Error</h2>
        <p>{error}</p>
        <button onClick={() => router.push("/")}>Return Home</button>
      </div>
    );
  }

  return <div>Completing authentication...</div>;
}

export default function CallbackPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <CallbackContent />
    </Suspense>
  );
}

How It Works

  1. User clicks sign in - Plugin generates PKCE code_verifier, stores in sessionStorage
  2. Redirects to Sigma - User authenticates, selects BAP identity, grants consent
  3. Sigma redirects back - Returns authorization code to your callback page
  4. Client-side page calls your API - Sends code + code_verifier
  5. Your API route exchanges tokens - Plugin signs request with your member key
  6. Returns user profile + tokens - Includes BAP identity, pubkey, etc.

The member private key (SIGMA_MEMBER_PRIVATE_KEY) proves your app's identity to the Sigma Auth server during token exchange. This is generated when you register your OAuth client.

Features

Automatic PKCE

Code challenge/verifier handled automatically for secure public client flows.

State Validation

CSRF protection via state parameter.

Bitcoin Auth Signatures

Token exchange requests are signed with your member key using bitcoin-auth.

BAP Identity Support

User profiles include full BAP identity data when available.

Custom Configuration

Override defaults if needed:

app/api/auth/callback/route.ts
import { createCallbackHandler } from "@sigma-auth/better-auth-plugin/next";

export const runtime = "nodejs";
export const POST = createCallbackHandler({
  // Override Sigma Auth URL
  issuerUrl: "https://custom.sigmaidentity.com",
  // Override client ID
  clientId: "custom-client-id",
  // Override member key
  memberPrivateKey: process.env.CUSTOM_MEMBER_KEY,
  // Override callback path for redirect URI
  callbackPath: "/auth/callback",
});

Environment Variables

.env.local
# Required
NEXT_PUBLIC_SIGMA_CLIENT_ID=your-app
SIGMA_MEMBER_PRIVATE_KEY=your-member-wif

# Optional (defaults shown)
NEXT_PUBLIC_SIGMA_AUTH_URL=https://auth.sigmaidentity.com

Common Mistakes

Using Wrong Env Var Name

# WRONG
MEMBER_PRIVATE_KEY=...
DROPLIT_MEMBER_PRIVATE_KEY=...

# CORRECT
SIGMA_MEMBER_PRIVATE_KEY=...

API Route Without Runtime

// WRONG - Needs nodejs runtime for bitcoin-auth crypto
export const POST = createCallbackHandler();

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

Missing Suspense for useSearchParams

// WRONG
export default function CallbackPage() {
  const searchParams = useSearchParams(); // Error!
}

// CORRECT - Wrap in Suspense
function CallbackContent() {
  const searchParams = useSearchParams();
}

export default function CallbackPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <CallbackContent />
    </Suspense>
  );
}

Next Steps