Add support for web3 sign in. #750

Closed
opened 2026-03-13 08:02:48 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @rahmanwolied on GitHub (Feb 27, 2025).

Is this suited for github?

  • Yes, this is suited for github

No response

Describe the solution you'd like

I am writing to implement a wallet address authentication feature using message signing but failed. If anyone can nudge me in the right direction or work on it themselves it would be great.

Describe alternatives you've considered

I have implemented such a feature with auth.js's credentials provider. But I want something more robust.

Additional context

No response

Originally created by @rahmanwolied on GitHub (Feb 27, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. _No response_ ### Describe the solution you'd like I am writing to implement a wallet address authentication feature using message signing but failed. If anyone can nudge me in the right direction or work on it themselves it would be great. ### Describe alternatives you've considered I have implemented such a feature with auth.js's credentials provider. But I want something more robust. ### Additional context _No response_
Author
Owner

@rokitgg commented on GitHub (Mar 1, 2025):

Hey! I've been in this exact same situation and somehow managed to write my own SIWE plugin.

Not really sure if it may suite your purpose, but I'll leave it here just in case.

If you find any bug or have any suggestions, please let me know so that maybe we can end up with a PR that closes the issue. Thank you.

index.ts (server plugin)

import { generateId } from "better-auth";
import {
  type BetterAuthPlugin,
  type User,
  setSessionCookie,
} from "better-auth";
import { APIError, createAuthEndpoint } from "better-auth/api";

// Database Instance
import { db } from "@@repo/db/drizzle";
import { eq, user as userTable } from "@@repo/db/schema";

// Zod
import { z } from "zod";

// SIWE deps
import { SiweMessage, generateNonce } from "siwe";
import { http, createConfig, getEnsName, getEnsAvatar } from "@wagmi/core";
import { mainnet, sepolia } from "@wagmi/core/chains";

export interface SIWEPluginOptions {
  domain: string;
  // Optional configuration
  chainId?: 1 | 11155111 | undefined;
  version?: string;
  resources?: string[];
}

export const wagmiConfig = createConfig({
  chains: [mainnet, sepolia],
  transports: {
    [mainnet.id]: http("https://eth.llamarpc.com"),
    [sepolia.id]: http(),
  },
});

export const siwe = (options: SIWEPluginOptions) =>
  ({
    id: "sign-in-with-ethereum",
    schema: {
      user: {
        fields: {
          publicKey: {
            type: "string",
            unique: true,
          },
        },
      },
    },
    endpoints: {
      // Generate nonce endpoint
      nonce: createAuthEndpoint(
        "/siwe/nonce",
        {
          method: "POST",
          body: z.object({
            publicKey: z.string(),
          }),
        },
        async (ctx) => {
          const nonce = generateNonce();
          // Store nonce with 15-minute expiration
          await ctx.context.internalAdapter.createVerificationValue({
            id: generateId(),
            identifier: `siwe_${ctx.body.publicKey.toLowerCase()}`,
            value: nonce,
            expiresAt: new Date(Date.now() + 15 * 60 * 1000),
          });

          return { nonce };
        }
      ),
      // Verify siwe payload
      verify: createAuthEndpoint(
        "/siwe/verify",
        {
          method: "POST",
          body: z.object({
            message: z.string(),
            signature: z.string(),
            publicKey: z.string(),
          }),
        },
        async (ctx) => {
          const { message, signature } = ctx.body;
          // Parse and validate SIWE message
          const siweMessage = new SiweMessage(message);

          try {
            // Find stored nonce to check it's validity
            const verification =
              await ctx.context.internalAdapter.findVerificationValue(
                `siwe_${ctx.body.publicKey.toLowerCase()}`
              );
            // Ensure nonce is valid and not expired
            if (!verification || new Date() > verification.expiresAt) {
              throw new APIError("UNAUTHORIZED", {
                message: "Unauthorized: Invalid or expired nonce",
              });
            }
            // Verify SIWE message
            const verified = await siweMessage.verify({
              signature,
              nonce: verification.value,
              // domain: options.domain,
            });

            if (!verified.success) {
              throw new APIError("UNAUTHORIZED", {
                message: "Unauthorized: Invalid SIWE signature",
              });
            }

            // Delete used nonce to prevent replay attacks
            // now moved to n after hook on /sign-out route
            // await ctx.context.internalAdapter.deleteVerificationValue(
            //   verification.id
            // );

            let user = await db.query.user.findFirst({
              where: eq(userTable.publicKey, ctx.body.publicKey),
            });

            if (!user) {
              const tempEmail = `${ctx.body.publicKey}@${process.env.NEXT_PUBLIC_BASE_URL}`;
              const ens = await getEnsName(wagmiConfig, {
                address: ctx.body.publicKey as `0x${string}`,
                chainId: options.chainId ?? 1,
              });

              const avatar = await getEnsAvatar(wagmiConfig, {
                name: (ens as string) ?? ctx.body.publicKey,
                chainId: options.chainId ?? 1,
              });

              user = await ctx.context.internalAdapter.createUser({
                name: ens ?? ctx.body.publicKey,
                email: tempEmail,
                publicKey: ctx.body.publicKey,
                avatar: avatar ?? "",
              });
            }

            const session = await ctx.context.internalAdapter.createSession(
              user.id,
              ctx.request
            );

            if (!session) {
              return ctx.json(null, {
                status: 500,
                body: {
                  message: "Internal Server Error",
                  status: 500,
                },
              });
            }

            await setSessionCookie(ctx, { session, user });

            return ctx.json({ token: session.token });
          } catch (error: any) {
            if (error instanceof APIError) throw error;
            throw new APIError("UNAUTHORIZED", {
              message: "Something went wrong. Please try again later.",
              error: error.message,
            });
          }
        }
      ),
    },
  }) satisfies BetterAuthPlugin;

client.ts (client plugin)

import { BetterAuthClientPlugin } from "better-auth";
import type { siwe } from ".";

type SignInWithEthereumPlugin = typeof siwe;

export const siweClientPlugin = () => {
  return {
    id: "sign-in-with-ethereum",
    $InferServerPlugin: {} as ReturnType<SignInWithEthereumPlugin>,
  } satisfies BetterAuthClientPlugin;
};

@rokitgg commented on GitHub (Mar 1, 2025): Hey! I've been in this exact same situation and somehow managed to write my own SIWE plugin. Not really sure if it may suite your purpose, but I'll leave it here just in case. If you find any bug or have any suggestions, **please** let me know so that maybe we can end up with a PR that closes the issue. Thank you. ### **index.ts (server plugin)** ``` import { generateId } from "better-auth"; import { type BetterAuthPlugin, type User, setSessionCookie, } from "better-auth"; import { APIError, createAuthEndpoint } from "better-auth/api"; // Database Instance import { db } from "@@repo/db/drizzle"; import { eq, user as userTable } from "@@repo/db/schema"; // Zod import { z } from "zod"; // SIWE deps import { SiweMessage, generateNonce } from "siwe"; import { http, createConfig, getEnsName, getEnsAvatar } from "@wagmi/core"; import { mainnet, sepolia } from "@wagmi/core/chains"; export interface SIWEPluginOptions { domain: string; // Optional configuration chainId?: 1 | 11155111 | undefined; version?: string; resources?: string[]; } export const wagmiConfig = createConfig({ chains: [mainnet, sepolia], transports: { [mainnet.id]: http("https://eth.llamarpc.com"), [sepolia.id]: http(), }, }); export const siwe = (options: SIWEPluginOptions) => ({ id: "sign-in-with-ethereum", schema: { user: { fields: { publicKey: { type: "string", unique: true, }, }, }, }, endpoints: { // Generate nonce endpoint nonce: createAuthEndpoint( "/siwe/nonce", { method: "POST", body: z.object({ publicKey: z.string(), }), }, async (ctx) => { const nonce = generateNonce(); // Store nonce with 15-minute expiration await ctx.context.internalAdapter.createVerificationValue({ id: generateId(), identifier: `siwe_${ctx.body.publicKey.toLowerCase()}`, value: nonce, expiresAt: new Date(Date.now() + 15 * 60 * 1000), }); return { nonce }; } ), // Verify siwe payload verify: createAuthEndpoint( "/siwe/verify", { method: "POST", body: z.object({ message: z.string(), signature: z.string(), publicKey: z.string(), }), }, async (ctx) => { const { message, signature } = ctx.body; // Parse and validate SIWE message const siweMessage = new SiweMessage(message); try { // Find stored nonce to check it's validity const verification = await ctx.context.internalAdapter.findVerificationValue( `siwe_${ctx.body.publicKey.toLowerCase()}` ); // Ensure nonce is valid and not expired if (!verification || new Date() > verification.expiresAt) { throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Invalid or expired nonce", }); } // Verify SIWE message const verified = await siweMessage.verify({ signature, nonce: verification.value, // domain: options.domain, }); if (!verified.success) { throw new APIError("UNAUTHORIZED", { message: "Unauthorized: Invalid SIWE signature", }); } // Delete used nonce to prevent replay attacks // now moved to n after hook on /sign-out route // await ctx.context.internalAdapter.deleteVerificationValue( // verification.id // ); let user = await db.query.user.findFirst({ where: eq(userTable.publicKey, ctx.body.publicKey), }); if (!user) { const tempEmail = `${ctx.body.publicKey}@${process.env.NEXT_PUBLIC_BASE_URL}`; const ens = await getEnsName(wagmiConfig, { address: ctx.body.publicKey as `0x${string}`, chainId: options.chainId ?? 1, }); const avatar = await getEnsAvatar(wagmiConfig, { name: (ens as string) ?? ctx.body.publicKey, chainId: options.chainId ?? 1, }); user = await ctx.context.internalAdapter.createUser({ name: ens ?? ctx.body.publicKey, email: tempEmail, publicKey: ctx.body.publicKey, avatar: avatar ?? "", }); } const session = await ctx.context.internalAdapter.createSession( user.id, ctx.request ); if (!session) { return ctx.json(null, { status: 500, body: { message: "Internal Server Error", status: 500, }, }); } await setSessionCookie(ctx, { session, user }); return ctx.json({ token: session.token }); } catch (error: any) { if (error instanceof APIError) throw error; throw new APIError("UNAUTHORIZED", { message: "Something went wrong. Please try again later.", error: error.message, }); } } ), }, }) satisfies BetterAuthPlugin; ``` ### **client.ts (client plugin)** ``` import { BetterAuthClientPlugin } from "better-auth"; import type { siwe } from "."; type SignInWithEthereumPlugin = typeof siwe; export const siweClientPlugin = () => { return { id: "sign-in-with-ethereum", $InferServerPlugin: {} as ReturnType<SignInWithEthereumPlugin>, } satisfies BetterAuthClientPlugin; }; ```
Author
Owner

@reslear commented on GitHub (Mar 3, 2025):

dup https://github.com/better-auth/better-auth/issues/41

@reslear commented on GitHub (Mar 3, 2025): dup https://github.com/better-auth/better-auth/issues/41
Author
Owner

@rokitgg commented on GitHub (Mar 6, 2025):

dup #41

Will be drafting the SIWE plugin PR today!

@rokitgg commented on GitHub (Mar 6, 2025): > dup [#41](https://github.com/better-auth/better-auth/issues/41) Will be drafting the SIWE plugin PR today!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#750