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:
-
Check the nonce is valid
-
Verify the signature against the message
-
Look up (or create) the user
-
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:
- 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;
}
},
}),
],
});
-
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() ], });-
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!