Secure Ethereum Sign-In with Next.js: A Step-by-Step Guide Using WalletConnect's AppKit

Thomas Cosialls

Secure and user-friendly authentication methods are crucial to onboard more users into web3. Sign In With Ethereum (SIWE) has emerged as a powerful solution, allowing users to control their digital identity using their Ethereum accounts. WalletConnect’s new AppKit makes it easier than ever to integrate Social Login, On-ramp solution and SIWE into your applications This article will guide you through implementing SIWE in your Next.js application using WalletConnect's AppKit "One Click Auth" feature, with a focus on securing API routes using JWT tokens.

Understanding SIWE and AppKit

Sign In With Ethereum (SIWE) is a standardized authentication method (EIP-4361) that enables users to prove ownership of their Ethereum address through a cryptographic signature. WalletConnect's AppKit simplifies this process by providing a streamlined integration for developers.

AppKit's "One-Click Auth" feature represents a significant advancement in user experience, allowing users to connect their wallet and sign a SIWE message with a single click. This seamless process not only enhances security but also improves the overall user experience of decentralized applications (dApp).

Set up

To get started, you'll first need to correctly install WalletConnect's Appkit following this tutorial. Then we can start setting up the "One Click Auth" feature; run this command in your Next.js project:

yarn add @web3modal/siwe next-auth

Configuring SIWE Client

Let's create the SIWE client. Create a file at app/config/siwe.ts and copy-paste this code:

app/config/siwe.ts
import { getCsrfToken, signIn, signOut, getSession } from 'next-auth/react'
import type { SIWEVerifyMessageArgs, SIWECreateMessageArgs, SIWESession } from '@web3modal/siwe'
import { createSIWEConfig, formatMessage } from '@web3modal/siwe'
import { mainnet, sepolia } from 'viem/chains'

export const siweConfig = createSIWEConfig({
  getMessageParams: async () => ({
    domain: typeof window !== 'undefined' ? window.location.host : '',
    uri: typeof window !== 'undefined' ? window.location.origin : '',
    chains: [mainnet.id, sepolia.id],
    statement: 'Please sign with your account'
  }),
  createMessage: ({ address, ...args }: SIWECreateMessageArgs) => formatMessage(args, address),
  getNonce: async () => {
    const nonce = await getCsrfToken()
    if (!nonce) {
      throw new Error('Failed to get nonce!')
    }

    return nonce
  },
  getSession: async () => {
    const session = await getSession()
    if (!session) {
      throw new Error('Failed to get session!')
    }

    const { address, chainId } = session as unknown as SIWESession

    return { address, chainId }
  },
  verifyMessage: async ({ message, signature }: SIWEVerifyMessageArgs) => {
    try {
      const success = await signIn('credentials', {
        message,
        redirect: false,
        signature,
        callbackUrl: '/protected'
      })

      return Boolean(success?.ok)
    } catch (error) {
      return false
    }
  },
  signOut: async () => {
    try {
      await signOut({
        redirect: false
      })

      return true
    } catch (error) {
      return false
    }
  }
})

This configuration sets up the basic parameters for SIWE messages and handles nonce generation. Add the configuration to your createWeb3Modal object and you are done!

Web3Provider.ts
import { siweConfig } from '@/config/siwe'

createWeb3Modal({
  //...
  siweConfig
})

Users will be prompted to sign a message with their wallet after connecting:

siwe walletconnect
WalletConnect "One Click Auth" modal is super easy to setup

Setting Up the API Route

Next, create an API route to handle authentication. Create a file at app/api/auth/[...nextauth]/route.ts. Don't forget to set NEXTAUTH_SECRET and NEXT_PUBLIC_PROJECT_ID in your .env.local file. I recommend using a password generator tool for your secret passphrase.

api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import credentialsProvider from 'next-auth/providers/credentials'
import {
  type SIWESession,
  verifySignature,
  getChainIdFromMessage,
  getAddressFromMessage,
} from '@web3modal/siwe'

declare module 'next-auth' {
  interface Session extends SIWESession {
    address: string
    chainId: number
  }
}

const nextAuthSecret = process.env.NEXTAUTH_SECRET
if (!nextAuthSecret) {
  throw new Error('NEXTAUTH_SECRET is not set')
}

const projectId = process.env.NEXT_PUBLIC_PROJECT_ID
if (!projectId) {
  throw new Error('NEXT_PUBLIC_PROJECT_ID is not set')
}

const providers = [
  credentialsProvider({
    name: 'Base',
    credentials: {
      message: {
        label: 'Message',
        type: 'text',
        placeholder: '0x0',
      },
      signature: {
        label: 'Signature',
        type: 'text',
        placeholder: '0x0',
      },
    },
    async authorize(credentials) {
      try {
        if (!credentials?.message) {
          throw new Error('SiweMessage is undefined')
        }
        const { message, signature } = credentials
        const address = getAddressFromMessage(message)
        const chainId = getChainIdFromMessage(message)

        const isValid = await verifySignature({ address, message, signature, chainId, projectId })

        if (isValid) {
          return {
            id: `${chainId}:${address}`,
          }
        }

        return null
      } catch (e) {
        return null
      }
    },
  }),
]

const handler = NextAuth({
  // https://next-auth.js.org/configuration/providers/oauth
  secret: nextAuthSecret,
  providers,
  session: {
    strategy: 'jwt',
  },
  callbacks: {
    session({ session, token }) {
      if (!token.sub) {
        return session
      }

      const [, chainId, address] = token.sub.split(':')
      if (chainId && address) {
        session.address = address
        session.chainId = parseInt(chainId, 10)
      }

      return session
    },
  },
})

export { handler as GET, handler as POST }

This setup creates a NextAuth handler that uses SIWE for authentication, verifying the signature and creating a session (using JWT token) upon successful authentication.

Securing API Routes with a Middleware

Now that we have SIWE set up, let's secure our API routes using the next-auth JWT tokens. We can leverage getToken() in a Next.js middleware to verify if the next-auth token is valid, retrieve the user wallet address and pass it along to the api route:

src/middleware.ts
import { getToken } from 'next-auth/jwt'
import { NextResponse, type NextRequest } from 'next/server'

// List routes you want to protect here
export const config = {
  matcher: ['/api/events/:path*', '/api/users/:path*'],
}

export async function middleware(req: NextRequest) {
  //get wallet id from session
  const token = await getToken({ req })

  if (!token) {
    return NextResponse.json({ success: false, message: 'Authentication failed' }, { status: 401 })
  }

  //get wallet id from session token
  const [, chainId, address] = token.sub.split(':')

  // set user wallet address in the request headers
  const requestHeaders = new Headers(req.headers)
  requestHeaders.set('wallet-address', address)

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  })
}

This middleware will automatically protect the specified routes, requiring authentication for access.

Conclusion

Implementing Sign In With Ethereum (SIWE) using WalletConnect's AppKit in your Next.js application provides a secure and user-friendly authentication method. By leveraging next-auth JWT tokens to secure API routes, you create a robust system that protects user data while offering the benefits of Web3 authentication.

Remember to always keep your authentication mechanisms up to date and follow best practices in security. With SIWE and proper API route protection, you're well on your way to building secure and engaging decentralized applications. Happy coding, and welcome to the future of Web3 authentication!

Ready to take your project to the next level?

Contact us today to discuss how we can help you achieve your goals in the blockchain space.