Package Exports
The plugin provides multiple entry points for different use cases:
| Export | Use Case |
|---|---|
@sigma-auth/better-auth-plugin/client | Browser-side OAuth flow, signing, encryption |
@sigma-auth/better-auth-plugin/server | Server-side token exchange (any TS backend) |
@sigma-auth/better-auth-plugin/next | Next.js App Router helpers |
@sigma-auth/better-auth-plugin/provider | Auth server plugin (for self-hosting) |
@sigma-auth/better-auth-plugin | Type 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:
- Client plugin redirects to
/oauth2/authorize(our custom gate) - Gate checks: session → local backup → cloud backup → signup
- If wallet not accessible, prompts user to unlock it
- Once wallet is accessible, forwards to
/api/auth/oauth2/authorize(Better Auth) - 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 signupAll 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-auth2. Set Environment Variables
# 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-wif3. Create Callback Route
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.
Client-Side Setup
Create an auth client:
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
"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
"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
- User clicks sign in - Plugin generates PKCE code_verifier, stores in sessionStorage
- Redirects to Sigma - User authenticates, selects BAP identity, grants consent
- Sigma redirects back - Returns authorization code to your callback page
- Client-side page calls your API - Sends code + code_verifier
- Your API route exchanges tokens - Plugin signs request with your member key
- 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
Override defaults if needed:
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
# 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.comOther TypeScript Backends (Elysia, Hono, Express)
For non-Next.js backends, use exchangeCodeForTokens directly from the /server export:
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 callbackredirectUri- Must match the redirect_uri used in authorizationclientId- Your OAuth client IDmemberPrivateKey- 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 identityaccess_token- OAuth access tokenid_token- OIDC ID tokenrefresh_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
- Register OAuth Client - Get your client ID and member key
- OAuth Error Handling - Handle common errors
- PKCE Flow Details - Understand the security model