Sigma Auth
Setup

Package Exports

The plugin provides multiple entry points for different use cases:

ExportUse Case
@sigma-auth/better-auth-plugin/clientBrowser-side OAuth flow, signing, encryption
@sigma-auth/better-auth-plugin/serverServer-side token exchange (any TS backend)
@sigma-auth/better-auth-plugin/nextNext.js App Router helpers
@sigma-auth/better-auth-plugin/providerAuth server plugin (for self-hosting)
@sigma-auth/better-auth-pluginType definitions only

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/sigma/callback/route.ts
import { createCallbackHandler } from "@sigma-auth/better-auth-plugin/next";

export const POST = createCallbackHandler();

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

Path convention. The client handleCallback POSTs to /api/auth/sigma/callback, and the OAuth redirect_uri defaults to /auth/sigma/callback. Put the route and the callback page at those paths and the flow works with zero configuration. If you need different paths, you must override both sides (callbackURL on signIn.sigma and callbackPath on createCallbackHandler) so they match.

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

const SIGMA_CLIENT_ID = process.env.NEXT_PUBLIC_SIGMA_CLIENT_ID ?? "";

export function SignInButton() {
  const handleSignIn = () => {
    signIn.sigma({ clientId: SIGMA_CLIENT_ID });
  };

  return (
    <button type="button" onClick={handleSignIn}>
      Sign in with Sigma
    </button>
  );
}

Callback Page

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

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

function CallbackContent() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const handleCallback = async () => {
      try {
        // Plugin handles: state verification, token exchange, bapId extraction
        const result = await authClient.sigma.handleCallback(searchParams);

        // Parse bap JSON string if present
        const bapData = result.user?.bap
          ? typeof result.user.bap === "string"
            ? JSON.parse(result.user.bap)
            : result.user.bap
          : null;

        // bap_id is the direct claim from plugin
        const bapId = result.user?.bap_id || bapData?.id;

        // Store user in your state management (Zustand, Redux, etc.)
        // Example: setUser({ ...result.user, bapId }, result.access_token);

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

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

  if (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.

Client-Side Signing

After authentication, you can sign requests and data using the user's BAP identity. Keys never leave Sigma's domain - signing happens via a secure iframe.

Sign API Requests

// Sign a request for authenticated API calls
const authToken = await authClient.sigma.sign("/api/droplits", { name: "test" });

fetch("/api/droplits", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Auth-Token": authToken,
  },
  body: JSON.stringify({ name: "test" }),
});

Sign Bitcoin Transactions (AIP)

// Sign OP_RETURN data with Author Identity Protocol
const signedOps = await authClient.sigma.signAIP(["6a", "..."]);

Encrypt/Decrypt Messages

// Encrypt data for a friend (Type42 key derivation)
const encrypted = await authClient.sigma.encrypt(
  "Hello friend!",
  friendBapId,
  friend.publicKey
);

// Decrypt data from a friend
const decrypted = await authClient.sigma.decrypt(
  encryptedContent,
  friendBapId,
  friend.publicKey
);

// Get derived public key for friend requests
const myPubKeyForFriend = await authClient.sigma.getFriendPublicKey(friendBapId);

Identity Management

// Get current identity
const bapId = authClient.sigma.getIdentity();

// Manually set identity (for multi-identity wallets)
authClient.sigma.setIdentity(bapId);

// Clear identity on logout
authClient.sigma.clearIdentity();

// Check if ready for signing
const ready = authClient.sigma.isReady();

Custom Configuration

createCallbackHandler(config) reads every value from env by default (NEXT_PUBLIC_SIGMA_AUTH_URL, NEXT_PUBLIC_SIGMA_CLIENT_ID, SIGMA_MEMBER_PRIVATE_KEY). Override only the fields you need:

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

export const POST = createCallbackHandler({
  // Override Sigma Auth URL
  issuerUrl: "https://custom.sigmaidentity.com",
  // Override client ID
  clientId: "custom-client-id",
  // Override the account/member private key (WIF)
  accountPrivateKey: process.env.CUSTOM_MEMBER_KEY,
  // Override callback path used as redirect_uri (default: /auth/sigma/callback)
  // If you change this, also pass callbackURL to signIn.sigma so both sides match.
  callbackPath: "/auth/callback",
});

The exact CallbackRouteConfig shape is { issuerUrl?, clientId?, accountPrivateKey?, callbackPath? } — all fields optional.

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

Other TypeScript Backends (Elysia, Hono, Express)

For non-Next.js backends, use exchangeCodeForTokens directly from the /server export:

Elysia example
import { Elysia, t } from "elysia";
import { exchangeCodeForTokens } from "@sigma-auth/better-auth-plugin/server";

const app = new Elysia()
  .post("/oauth/exchange", async ({ body, set }) => {
    const { code, redirectUri, codeVerifier } = body;

    try {
      const result = await exchangeCodeForTokens({
        code,
        redirectUri,
        clientId: process.env.SIGMA_CLIENT_ID!,
        memberPrivateKey: process.env.SIGMA_MEMBER_PRIVATE_KEY!,
        codeVerifier,
        // Optional: override issuer URL
        // issuerUrl: "https://auth.sigmaidentity.com",
      });

      return {
        access_token: result.access_token,
        id_token: result.id_token,
        refresh_token: result.refresh_token,
        user: result.user,
      };
    } catch (error) {
      const err = error as { status?: number; error?: string };
      set.status = err.status || 500;
      return { error: err.error || "Token exchange failed" };
    }
  }, {
    body: t.Object({
      code: t.String(),
      redirectUri: t.String(),
      codeVerifier: t.Optional(t.String()),
    }),
  });

Server Export API

import {
  exchangeCodeForTokens,
  type TokenExchangeOptions,
  type TokenExchangeResult,
  type TokenExchangeError,
} from "@sigma-auth/better-auth-plugin/server";

TokenExchangeOptions:

  • code - Authorization code from OAuth callback
  • redirectUri - Must match the redirect_uri used in authorization
  • clientId - Your OAuth client ID
  • memberPrivateKey - Your member private key (WIF format)
  • codeVerifier - PKCE code verifier (optional)
  • issuerUrl - Auth server URL (default: https://auth.sigmaidentity.com)

TokenExchangeResult:

  • user - SigmaUserInfo with BAP identity
  • access_token - OAuth access token
  • id_token - OIDC ID token
  • refresh_token - Refresh token (optional)

Common Mistakes

Using Wrong Env Var Name

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

# CORRECT
SIGMA_MEMBER_PRIVATE_KEY=...

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

On this page