atomic

Writing

Creating a “Sign-In with Stacks” Plugin for Better Auth

2025

Authentication is at the heart of every modern app. When I discovered Better Auth, a TypeScript-first, plugin-friendly authentication system, I knew I wanted to extend it to support Stacks blockchain wallets and that’s exactly what I did by building a SIWS (Sign-In with Stacks) plugin.

This post explains how I built it, why it works, and how you can do something similar.


🚀 What is Better Auth?

Better Auth is a full-stack authentication framework designed for developers who want power, flexibility, and clarity. It comes with:

  • Email/password login

  • Social auth (Google, GitHub, etc.)

  • A powerful plugin system for extensibility

  • First-class TypeScript support

  • Fully framework-agnostic design (works in Next.js, SvelteKit, Hono, etc.)

It gives you full control over authentication flows without the pain of managing infrastructure or vendor lock-in.

Want email and password auth? Just enable it:

export const auth = betterAuth({
  emailAndPassword: {
    enabled: true,
  },
});

Need Google login too? No problem:

socialProviders: {
  google: {
    clientId: "...",
    clientSecret: "...",
  },
}

But what if you want to allow users to sign in with their Stacks wallet (like Hiro or Xverse)? That’s where plugins come in.


🔌 Why I Built the SIWS Plugin

Better Auth supports plugins like passkeys, email OTPs, or usernames. I wanted to let users sign in just by proving ownership of a Stacks address, using a signature, similar to how SIWE (Sign-In with Ethereum) works.

This required:

  • Verifying a cryptographic message signed by the wallet

  • Generating and storing a nonce for replay protection

  • Creating users (if they don’t already exist)

  • Attaching wallet addresses to accounts

  • Starting a session and returning the token

So I wrote a plugin that does just that.


🛠 Prerequisites

Before you begin, ensure you have the following: This article is not for complete beginners

  • Basic understanding of TypeScript, Nextjs and JavaScript.

  • Familiarity with blockchain concepts, particularly Stacks blockchain.

  • Node.js and npm installed on your development machine.

  • Access to a Stacks wallet (e.g., Leather or Xverse) for testing.

  • Basic knowledge of authentication systems and flows.


🧱 Plugin Architecture Overview

Better Auth plugins follow a simple structure: define endpoints and behavior in a function that returns a BetterAuthPlugin object.

Here's a simplified sketch of what the plugin looks like:

//plugin/index.ts
import { validateStacksAddress } from "@stacks/transactions";
import { type BetterAuthPlugin, type User } from "better-auth";
import { APIError } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import { createAuthEndpoint } from "better-auth/plugins";
import { z } from "zod";

export interface WalletAddress {
	id: string;
	userId: string;
	address: string;
	isPrimary: boolean;
	network: "mainnet" | "testnet";
	createdAt: Date;
}

export interface BNSLookupArgs {
	walletAddress: string;
}

export interface BNSLookupResult {
	name: string;
	avatar: string;
}

export interface SIWSPluginOptions {
	domain: string;
	emailDomainName?: string;
	anonymous?: boolean;
	getNonce: () => Promise<string>;
	bnsLookup?: (args: BNSLookupArgs) => Promise<BNSLookupResult>;
	verifyMessage: (args: {
		message: string;
		signature: string;
		address: string;
		nonce: string;
		publicKey: string;
	}) => Promise<boolean>;
}

export const siws = (options: SIWSPluginOptions) => ({
  id: "siws",
  schema: 
	walletAddress: {
		fields: {
			userId: {
				type: "string",
				references: {
					model: "user",
					field: "id",
				},
				required: true,
			},
			address: {
				type: "string",
				required: true,
			},
			network: { type: "string", required: true },
			isPrimary: {
				type: "boolean",
				defaultValue: false,
			},
			createdAt: {
				type: "date",
				required: true,
			},
		},
	},
  endpoints: {
    getSiwsNonce: createAuthEndpoint(...),
    verifySiwsMessage: createAuthEndpoint(...),
  },
});

Let’s break this down in the next sections.


📬 Step 1: Nonce Generation (/siws/nonce)

Before a wallet can sign in, we need to generate a one-time nonce:

getSiwsNonce: createAuthEndpoint(
  "/siws/nonce",
  {
    method: "POST",
    body: z.object({
      walletAddress: z
        .string()
        .refine((address) => validateStacksAddress(address), {
          message: "Invalid Stacks wallet address",
        }),
    }),
  },
  async (ctx) => {
    const { walletAddress } = ctx.body;
    const nonce = await options.getNonce(); // we would implement this when using the plugin

    await ctx.context.internalAdapter.createVerificationValue({
      identifier: `siws:${walletAddress}`,
      value: nonce,
      expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 mins expiry
    });

    return ctx.json({ nonce });
  },
),

This nonce will be used as the challenge string the wallet signs. It ensures that even if someone captures the message, they can’t reuse it.


✍️ Step 2: Signature Verification (/siws/verify)

Once the user signs the message and submits it, we:

  1. Check the nonce is valid

  2. Verify the signature against the message

  3. Look up (or create) the user

  4. Start a session and return it

verifySiwsMessage: createAuthEndpoint(
  "/siws/verify",
  {
    method: "POST",
    body: z.object({
      message: z.string(),
      signature: z.string(),
      walletAddress: z.string(),
      publicKey: z.string(),
      email: z.string().optional(),
    }),
  },
  async (ctx) => {
    const { message, signature, walletAddress, email, publicKey } = ctx.body;

    try {
      // Find the nonce generated during /siws/nonce step
      const verification = await ctx.context.internalAdapter.findVerificationValue(
        `siws:${walletAddress}`
      );

      // If nonce is missing or expired, reject the request
      if (!verification || new Date() > verification.expiresAt) {
        throw ctx.error("UNAUTHORIZED", {
          message: "Invalid or expired nonce",
        });
      }

      // Verify the message signature using the provided function
      const verified = await options.verifyMessage({
        message,
        signature,
        address: walletAddress,
        nonce: verification.value,
        publicKey,
      });

      // Reject if signature is invalid
      if (!verified) {
        throw ctx.error("UNAUTHORIZED", {
          message: "Signature invalid",
        });
      }

      // Delete the nonce to prevent reuse
      await ctx.context.internalAdapter.deleteVerificationValue(verification.id);

      let user: User | null = null;

      // Check if the wallet address is already linked to a user
      const existingWallet: WalletAddress | null =
        await ctx.context.adapter.findOne({
          model: "walletAddress",
          where: [{ field: "address", operator: "eq", value: walletAddress }],
        });

      // Detect mainnet or testnet from the wallet prefix
      const network = walletAddress.startsWith("SP") ? "mainnet" : "testnet";

      // If wallet is linked, fetch the associated user
      if (existingWallet) {
        user = await ctx.context.adapter.findOne({
          model: "user",
          where: [{ field: "id", operator: "eq", value: existingWallet.userId }],
        });
      }

      // If user doesn’t exist, create a new one
      if (!user) {
        const isAnon = options.anonymous ?? true;
        const domain = options.emailDomainName;

        // Use provided email if available, else fallback to wallet-based alias
        const userEmail = !isAnon && email
          ? email
          : `${walletAddress}@${domain}`;

        // Optionally fetch BNS name and avatar
        const { name, avatar } =
          (await options.bnsLookup?.({ walletAddress })) ?? {};

        // Create a new user
        user = await ctx.context.internalAdapter.createUser({
          name: name ?? walletAddress,
          email: userEmail,
          image: avatar ?? "",
        });

        // Save wallet address under user
        await ctx.context.adapter.create({
          model: "walletAddress",
          data: {
            userId: user.id,
            address: walletAddress,
            network,
            isPrimary: true,
            createdAt: new Date(),
          },
        });

        // Link the wallet as an auth account
        await ctx.context.internalAdapter.createAccount({
          userId: user.id,
          providerId: "siws",
          accountId: walletAddress,
          createdAt: new Date(),
          updatedAt: new Date(),
        });
      }

      // Create session for the user
      const session = await ctx.context.internalAdapter.createSession(
        user.id,
        ctx
      );

      // Handle failure to create session
      if (!session) {
        throw ctx.error("INTERNAL_SERVER_ERROR", {
          message: "Internal Server Error",
          status: 500,
        });
      }

      // Set the session cookie
      await setSessionCookie(ctx, { session, user });

      // Return session token and user info
      return ctx.json({
        token: session.token,
        success: true,
        user: {
          id: user.id,
          walletAddress,
          network,
        },
      });
    } catch (err) {
      // Forward Better Auth errors directly
      if (err instanceof APIError) throw err;

      // Handle unexpected errors gracefully
      throw ctx.error("UNAUTHORIZED", {
        message: "Something went wrong.",
        status: 401,
        error: err instanceof Error ? err.message : "Unknown error",
      });
    }
  }
),

🧪 Using the plugin

To test the plugin:

  1. Add it to your auth setup:
// lib/auth.ts
import { siws } from "./plugins/siws";
import { verifyMessageSignatureRsv } from "@stacks/encryption";
import { generateRandomString } from "better-auth/crypto";

const DOMAIN_NAME = "siws.dev";

export const auth = betterAuth({
  plugins: [
    siws({
      domain: DOMAIN_NAME,
      emailDomainName: DOMAIN_NAME,
      getNonce: async () => {
        // Generate random string to use as nonce 
        return generateRandomString(32);
      },
      bnsLookup: async ({ walletAddress }) => {
        // TODO: Implement BNS lookup
      },
      verifyMessage: async ({ message, signature, publicKey }) => {
        try {
          return verifyMessageSignatureRsv({
            message,
            signature,
            publicKey,
          });
        } catch (error) {
          console.error("SIWS verification failed:", error);
          return false;
        }
      },
    }),
  ],
});
  1. Initializing the plugin in authClient

    import {
    	emailOTPClient,
    	inferAdditionalFields,
    	twoFactorClient,
    } from "better-auth/client/plugins";
    import { createAuthClient } from "better-auth/react";
    import { siwsClient } from "./auth/plugins/siws/client";
    
    export const authClient = createAuthClient({
    	plugins: [
    		siwsClient()
    	],
    });
    
    1.  import { useState } from "react";
       import { useRouter } from "next/navigation";
       
       import { DOMAIN_NAME } from "@repo/shared-constants/constants.ts";
       import { Button } from "@repo/ui/components/ui/button";
       import { toast } from "@repo/ui/components/ui/sonner";
       import { Spinner } from "@repo/ui/components/ui/spinner";
       
       import {
       	connect,
       	disconnect,
       	getLocalStorage,
       	isConnected,
       	request,
       } from "@stacks/connect";
       
       import { Wallet } from "lucide-react";
       import { authClient } from "~/lib/auth-client";
       
       export default function ContinueWithWallet() {
       	const [isLoading, setIsLoading] = useState(false);
       	const router = useRouter();
       
       	const handleSignInWithWallet = async () => {
       		setIsLoading(true);
       
       		try {
       			// Always disconnect first to allow user to select a different wallet/account
       			if (isConnected()) {
       				disconnect();
       			}
       
       			// Trigger wallet connection
       			const connectionResult = await connect();
       
       			// If connection fails, stop flow
       			if (!isConnected()) {
       				toast.error("Failed to connect to wallet");
       				return;
       			}
       
       			// Extract STX address from local storage
       			const walletData = getLocalStorage();
       			const address = walletData?.addresses?.stx?.[0]?.address;
       
       			if (!address) {
       				toast.error("No Stacks address found");
       				return;
       			}
       
       			// Step 1: Request a nonce from the server via Better Auth SIWS plugin
       			const { data: nonceData, error: nonceError } = await authClient.siws.nonce({
       				walletAddress: address,
       			});
       
       			if (nonceError || !nonceData?.nonce) {
       				toast.error("Failed to generate authentication nonce");
       				return;
       			}
       
       			// Step 2: Create message for user to sign using wallet
       			const message = `Sign in to Dexion ${DOMAIN_NAME} ${nonceData.nonce}`;
       
       			// Step 3: Prompt user to sign the message
       			const signResponse = await request("stx_signMessage", {
       				message: message,
       			});
       
       			if (!signResponse?.publicKey || !signResponse?.signature) {
       				toast.error("Message signing was cancelled or failed");
       				return;
       			}
       
       			// Step 4: Verify the signed message with the server
       			const { data: verificationData, error: verificationError } =
       				await authClient.siws.verify({
       					message: message,
       					signature: signResponse.signature,
       					walletAddress: address,
       					publicKey: signResponse.publicKey,
       				});
       
       			if (verificationError) {
       				toast.error("Authentication verification failed");
       				console.error("Verification error:", verificationError);
       				return;
       			}
       
       			// Step 5: On success, redirect to dashboard
       			if (verificationData) {
       				toast.success("Authenticated");
       				router.push("/dashboard");
       			}
       		} catch (error) {
       			console.error("Stacks authentication error:", error);
       
       			if (error instanceof Error) {
       				if (error.message.includes("network")) {
       					toast.error("Network error - please check your connection");
       				} else if (error.message.includes("user")) {
       					toast.error("Authentication cancelled by user");
       				} else {
       					toast.error("Authentication failed - please try again");
       				}
       			} else {
       				toast.error("Unexpected error occurred");
       			}
       		} finally {
       			setIsLoading(false);
       		}
       	};
       
       	return (
       		<Button
       			variant="outline"
       			className="w-full bg-muted/50 py-5 text-sm rounded-full"
       			onClick={handleSignInWithWallet}
       			disabled={isLoading}
       		>
       			{isLoading ? <Spinner /> : <Wallet />} Continue with Wallet
       		</Button>
       	);
       }
      

🧠 Lessons Learned

  • Better Auth’s plugin system is incredibly flexible.

  • Handling wallet-based auth is mostly about managing nonce lifecycle and verifying signatures.

  • Adding identity to blockchain-based users is possible, even without passwords or traditional login flows.


📦 Final Thoughts

The Sign-In with Stacks plugin is a great example of how you can extend Better Auth to support any identity method. Whether you’re working with Web3, passkeys, biometrics, or enterprise SSO, Better Auth gives you the tools and the ecosystem to build what you need.

If you want to try it out or contribute, feel free to fork the plugin and go wild!