npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zig-zag/better-siwp

v0.1.2

Published

Sign-In with Polkadot (SIWP) plugin for Better Auth - Polkadot wallet authentication made simple

Readme

@zig-zag/better-siwp

Sign In With Polkadot (SIWP) plugin for Better-Auth. Add wallet-based authentication to any Polkadot application with a single plugin. Users connect their wallet, sign a message, and get a server-side session.

Built on the SIWS standard by Talisman. Works with any Polkadot wallet extension (Polkadot.js, Talisman, SubWallet) and any wallet framework (LunoKit, raw extension APIs, etc.).

Table of Contents

Installation

npm i @zig-zag/better-siwp

Peer dependencies:

npm i better-auth zod

Quick Start

1. Server Plugin

// lib/auth.ts
import { betterAuth } from "better-auth";
import { siwp } from "@zig-zag/better-siwp";

export const auth = betterAuth({
  database: yourAdapter,
  plugins: [
    siwp({
      domain: "example.com",
    }),
  ],
});

2. Client Plugin

// lib/auth-client.ts
import { createAuthClient } from "better-auth/client";
import { siwpClient } from "@zig-zag/better-siwp/client";

export const authClient = createAuthClient({
  plugins: [siwpClient()],
});

3. API Route (Next.js)

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth);

That's it on the server side. The plugin registers two endpoints:

  • POST /api/auth/siwp/nonce — generates a nonce for a wallet address
  • POST /api/auth/siwp/verify — verifies a signed message and creates a session

Usage with LunoKit (Recommended)

LunoKit handles wallet connection and provides a useSignMessage hook that pairs perfectly with this plugin.

npm i @luno-kit/react @luno-kit/ui @tanstack/react-query @talismn/siws

Sign-in function

import { SiwsMessage } from "@talismn/siws";
import { authClient } from "@/lib/auth-client";

export async function signInWithPolkadot(
  address: string,
  signMessage: (params: { message: string }) => Promise<{ signature: string }>
) {
  // 1. Get a nonce from the server
  const { data } = await authClient.siwp.nonce({ walletAddress: address });

  // 2. Build a SIWS message
  const siwsMessage = new SiwsMessage({
    domain: window.location.host,
    address,
    statement: "Sign in with your Polkadot wallet",
    uri: window.location.origin,
    version: "1.0.0",
    nonce: data.nonce,
    issuedAt: Date.now(),
    expirationTime: Date.now() + 24 * 60 * 60 * 1000,
  });
  const message = siwsMessage.prepareMessage();

  // 3. Sign via LunoKit's useSignMessage hook
  const { signature } = await signMessage({ message });

  // 4. Verify with the server — session cookie is set automatically
  await authClient.siwp.verify({ message, signature, walletAddress: address });
}

React component

import { useAccount, useSignMessage } from "@luno-kit/react";
import { useConnectModal } from "@luno-kit/ui";
import { signInWithPolkadot } from "@/lib/auth/polkadot-auth-client";

function AuthButton() {
  const { account } = useAccount();
  const { signMessageAsync } = useSignMessage();
  const { open: openConnectModal } = useConnectModal();

  if (!account) {
    return <button onClick={openConnectModal}>Connect Wallet</button>;
  }

  return (
    <button onClick={() => signInWithPolkadot(account.address, signMessageAsync)}>
      Sign In
    </button>
  );
}

Usage with @polkadot/extension-dapp

If you prefer to work with the Polkadot.js extension APIs directly:

npm i @polkadot/extension-dapp @talismn/siws
import { web3Enable, web3Accounts, web3FromAddress } from "@polkadot/extension-dapp";
import { SiwsMessage } from "@talismn/siws";
import { authClient } from "@/lib/auth-client";

async function signIn() {
  // 1. Connect to wallet extensions
  await web3Enable("My App");
  const accounts = await web3Accounts();
  const account = accounts[0];

  // 2. Get nonce
  const { data } = await authClient.siwp.nonce({ walletAddress: account.address });

  // 3. Build and sign the message
  const siwsMessage = new SiwsMessage({
    domain: window.location.host,
    address: account.address,
    statement: "Sign in with your Polkadot wallet",
    uri: window.location.origin,
    version: "1.0.0",
    nonce: data.nonce,
    issuedAt: Date.now(),
    expirationTime: Date.now() + 24 * 60 * 60 * 1000,
  });
  const message = siwsMessage.prepareMessage();

  const injector = await web3FromAddress(account.address);
  const { signature } = await injector.signer.signRaw!({
    address: account.address,
    data: message,
    type: "bytes",
  });

  // 4. Verify — session is created server-side
  await authClient.siwp.verify({ message, signature, walletAddress: account.address });
}

API Reference

Server Exports

import { siwp, parseMessage, verifySIWS } from "@zig-zag/better-siwp";
import type { SIWPOptions, SiwsMessage } from "@zig-zag/better-siwp";

| Export | Description | |--------|-------------| | siwp(options) | Better-Auth server plugin | | parseMessage(message) | Parse a SIWS message string (re-exported from @talismn/siws) | | verifySIWS(message, signature, address) | Verify a SIWS signature (re-exported from @talismn/siws) | | SIWPOptions | Plugin configuration type | | SiwsMessage | SIWS message type |

Client Exports

import { siwpClient } from "@zig-zag/better-siwp/client";

| Export | Description | |--------|-------------| | siwpClient() | Better-Auth client plugin — adds authClient.siwp.nonce() and authClient.siwp.verify() |

Client Methods

After adding siwpClient() to your auth client:

// Request a nonce (configurable expiry, single use)
const { data, error } = await authClient.siwp.nonce({
  walletAddress: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
});
// data: { nonce: string }

// Verify a signed message and create a session
const { data, error } = await authClient.siwp.verify({
  message: "...",          // The prepared SIWS message string
  signature: "0x...",      // The wallet signature
  walletAddress: "5Grw...", // The signing address
});
// data: { token: string, success: boolean, user: { id: string, walletAddress: string } }

Configuration Options

siwp({
  // Optional: your domain without protocol
  // If not set, auto-detected from the request Origin header
  domain: "example.com",

  // Optional: nonce lifetime in seconds (default: 900 = 15 minutes)
  nonceExpiresIn: 600, // 10 minutes

  // Optional: custom nonce generation
  getNonce: async () => {
    return crypto.randomUUID();
  },

  // Optional: custom signature verification
  verifyMessage: async ({ message, signature, address }) => {
    // Your custom logic — return true if valid
    return true;
  },

  // Optional: extract user info from the wallet
  getUserInfo: async ({ message, address, signature }) => {
    return {
      name: "Alice",
      email: "[email protected]",
      image: "https://example.com/avatar.png",
    };
  },

  // Optional: domain for generated email addresses
  // Default: uses the resolved domain (e.g., "[email protected]")
  emailDomainName: "users.example.com",
});

| Option | Type | Required | Default | |--------|------|----------|---------| | domain | string | No | Auto-detected from Origin or Host header | | nonceExpiresIn | number | No | 900 (15 minutes, in seconds) | | getNonce | () => Promise<string> | No | Random alphanumeric string | | verifyMessage | (params) => Promise<boolean> | No | verifySIWS from @talismn/siws | | getUserInfo | (params) => Promise<UserInfo> | No | Name from truncated address | | emailDomainName | string | No | Same as resolved domain |

Note: When your API and frontend run on different ports (e.g., API on 3001, frontend on 3000), the domain is auto-detected from the Origin header sent by the browser, which contains the frontend's domain. If you need explicit control, set the domain option.

Error Handling

Verification errors throw structured APIError responses with machine-readable error codes:

const { data, error } = await authClient.siwp.verify({ ... });

if (error) {
  switch (error.code) {
    case "ADDRESS_MISMATCH":
      // Address in message doesn't match walletAddress
      break;
    case "DOMAIN_MISMATCH":
      // Domain in message doesn't match server domain
      break;
    case "INVALID_NONCE":
      // Nonce expired or doesn't match
      break;
    case "INVALID_SIGNATURE":
      // Wallet signature verification failed
      break;
  }
}

| Error Code | HTTP Status | When | |-----------|-------------|------| | ADDRESS_MISMATCH | 400 | Wallet address in SIWS message doesn't match the walletAddress parameter | | DOMAIN_MISMATCH | 400 | Domain in SIWS message doesn't match the server's domain | | INVALID_NONCE | 401 | Nonce expired (default: 15 min) or was already used | | INVALID_SIGNATURE | 401 | Cryptographic signature verification failed |

How It Works

1. User clicks "Connect Wallet"
   └─ Wallet extension shows available accounts

2. User selects an account
   └─ App calls authClient.siwp.nonce({ walletAddress })
   └─ Server generates a nonce, stores it (15 min expiry)

3. App builds a SIWS message with the nonce
   └─ Using SiwsMessage from @talismn/siws

4. User signs the message in their wallet
   └─ sr25519 signature, private key never leaves the extension

5. App calls authClient.siwp.verify({ message, signature, walletAddress })
   └─ Server validates: address match, domain match, nonce match
   └─ Server verifies the cryptographic signature
   └─ Server creates or finds the user
   └─ Server creates a session and sets a cookie

6. User is authenticated
   └─ Session persists across page loads via Better-Auth

The nonce is deleted after verification — each signature is single-use (replay protection).

Examples

The GitHub repository includes two complete example apps:

| Example | Stack | Description | |---------|-------|-------------| | examples/nextjs-lunokit | LunoKit + Dedot + Next.js | Recommended — modern wallet connection with LunoKit hooks | | examples/nextjs-app | @polkadot/extension-dapp + Next.js | Direct extension API usage |

Both examples include a polished dark-themed UI with Polkadot identicons, wallet connection, session persistence, and sign-out.

Part of the ZigZag Ecosystem

License

MIT - Yogesh Kumar