How to use Sign-in with Ethereum with Next-auth

In the previous post we have seen a quick introduction to Sign-in with Ethereum. In this post we will see how to use it with Next-auth. Next-Auth is a library that allows you to easily add authentication to your Next.js application. It supports multiple providers.

In this post we will not see Next-Auth in depth but we will see how to use it with Sign-in with Ethereum.

The application we will build will contains mutliple pages with the following rules :

  • The home page is public
  • The account page is private
  • The protected page is private
  • The login page is public but if the user is already logged in, it will redirect to the home page

Setting up the project

Let's start by creating a new Next.js project :

npx create-next-app --example with-tailwindcss  next-auth-ethereum

Then install the required dependencies :

yarn add @rainbow-me/rainbowkit next-auth wagmi siwe@^2.1.3

We use the beta 2.1.3 of Sign-in with Ethereum.

Next we configure our providers in pages/_app.tsx :

const { chains, provider } = configureChains(
  [mainnet],
  [
    alchemyProvider({ apiKey: process.env.ALCHEMY_ID as string }),
    publicProvider(),
  ]
)

const { connectors } = getDefaultWallets({
  appName: 'Connect Wallet App',
  chains,
})

const wagmiClient = createClient({
  autoConnect: true,
  connectors,
  provider,
})

function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
  return (
    <WagmiConfig client={wagmiClient}>
      <SessionProvider session={session}>
        <RainbowKitProvider chains={chains}>
          <Component {...pageProps} />
        </RainbowKitProvider>
      </SessionProvider>
    </WagmiConfig>
  )
}

export default App

Configure Next-auth

We can now configure Next-auth in pages/api/auth/[...nextauth].ts :

import CredentialsProvider, {
  CredentialInput,
} from 'next-auth/providers/credentials'
import NextAuth, { NextAuthOptions } from 'next-auth'

import { SiweMessage } from 'siwe'
import { getCsrfToken } from 'next-auth/react'

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: 'Ethereum',
      credentials: {
        message: {
          label: 'Message',
          type: 'text',
          placeholder: '0x0',
        } as CredentialInput,
        signature: {
          label: 'Signature',
          type: 'text',
          placeholder: '0x0',
        },
      },
      async authorize(credentials, req) {
        try {
          const siwe = new SiweMessage(JSON.parse(credentials?.message || '{}'))
          const nextAuthUrl = new URL(
            process.env.NEXTAUTH_URL ?? 'http://localhost:3000/'
          )

          const crsf = await getCsrfToken({ req } as any)
          const result = await siwe.verify({
            signature: credentials?.signature || '',
            domain: nextAuthUrl.host,
            nonce: crsf,
          })

          if (result.success) {
            return {
              id: siwe.address,
            }
          }
          return null
        } catch (e) {
          return null
        }
      },
    }),
  ],
  pages: {
    signIn: '/login',
  },
  session: {
    strategy: 'jwt',
  },
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async session({ session, token }: { session: any; token: any }) {
      session.address = token.sub
      session.user.name = token.sub
      return session
    },
  },
}

export default NextAuth(authOptions)

To use Sign-in with Ethereum with Next-auth we need to use the CredentialsProvider which is usually used for login/password authentication. First we define what type of credentials the providers will receive. In our case it will receive a message and a signature. The message is the standard message generated from siwe.

Then we define the authorize function which will be called when the user ask to login. In this function we verify the signature of the message and return the user address.

To verify the signature we use the verfiy function of SiweMessage class from siwe package.

const siwe = new SiweMessage(JSON.parse(credentials?.message || '{}'))
  const nextAuthUrl = new URL(
    process.env.NEXTAUTH_URL ?? 'http://localhost:3000/'
  )
  const nextAuthUrl = new URL(
    process.env.NEXTAUTH_URL ?? 'http://localhost:3000/'
  )

  const crsf = await getCsrfToken({ req } as any)
  const result = await siwe.verify({
    signature: credentials?.signature || '',
    domain: nextAuthUrl.host,
    nonce: crsf,
  })`

The function also verify that the nonce from message is the same as the one generated by Next-auth, this is to prevent replay attack. And it verify also that the domain is the right one. If one of the two values is not the same or if the signature is not correct the verification will fail.

Protect the pages

To protect pages from being accessed by unauthenticated users we will use next middleware. Middleware allows you to run code before a request is completed, then based on the incoming request, you can modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly. We will create a middleware.ts file in the root folder of the project :

export { default } from 'next-auth/middleware'
export const config = { matcher: ['/account', '/protected'] }

This is the default configuration of Next-auth middleware it will protect all the page that match the matcher regex.

There is also others ways to protect your pages like using the useSession hook on the cliend side, or in the getServerSideProps using getServerSession. You can learn more about it here :

Pages and components

Now we can create the pages and components we need.

First a layout component components/Layout.tsx :

import React from 'react'
import { useSession } from 'next-auth/react'

interface LayoutProps {
  children?: React.ReactNode
}

export const Layout = ({ children }: LayoutProps): React.ReactElement => {
  const { data: session } = useSession()

  return (
    <>
      <nav className="flex w-full flex-col bg-white py-4 px-6 text-center font-sans shadow sm:flex-row sm:items-baseline sm:justify-between sm:text-left">
        <div className="mb-2 sm:mb-0">
          <a
            href="/"
            className="text-grey-darkest hover:text-blue-dark text-2xl no-underline"
          >
            Siwe/Next-Auth
          </a>
        </div>
        <div className="space-x-4">
          {session ? (
            <a
              href="/account"
              className="text-grey-darkest hover:text-blue-dark text-lg no-underline"
            >
              My Account
            </a>
          ) : (
            <a
              href="/login"
              className="text-grey-darkest hover:text-blue-dark text-lg no-underline"
            >
              Login
            </a>
          )}

          <a
            href="/protected"
            className="text-grey-darkest hover:text-blue-dark text-lg no-underline"
          >
            Protected
          </a>
        </div>
      </nav>
      <main className="flex w-full flex-1 flex-col items-center justify-center px-20 text-center">
        {children}
      </main>
    </>
  )
}

The Layout render a navigation bar and the children. We use the useSession hook to display the login link if the user is not authenticated or the link to the account page.

Then we can create the page pages/index.tsx :

import Head from 'next/head'
import { Layout } from '../components/Layout'
import type { NextPage } from 'next'

const Home: NextPage = () => {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center">
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <Layout>
        <h1 className="pb-12 text-4xl font-bold">Welcome on the home</h1>
        <p>This page is not protected you can it when logged in or out</p>
      </Layout>

      <main className="flex w-full flex-1 flex-col items-center justify-center px-20 text-center"></main>
    </div>
  )
}

export default Home

This page is not protected and can be accessed by anyone.

Then we can create the page pages/account.tsx :

const Account: NextPage = () => {
  const mounted = useIsMounted()
  const { data: session } = useSession()
  const { address } = useAccount()
  const { disconnectAsync } = useDisconnect()

  const logOut = async () => {
    await disconnectAsync()
    signOut({ callbackUrl: '/' })
  }

  if (!mounted) return null

  return (
    <div className="flex min-h-screen flex-col items-center justify-center">
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <Layout>
        <div className="space-y-4">
          <h1 className="pb-12 text-4xl font-bold">My Account</h1>
          <p>
            Signed in as <b>{session?.user?.name}</b> <br />
            Wallet account <b>{address}</b>
          </p>

          <button
            onClick={logOut}
            type="button"
            className="inline-flex items-center rounded border border-transparent bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
          >
            Log out
          </button>
        </div>
      </Layout>

      <main className="flex w-full flex-1 flex-col items-center justify-center px-20 text-center"></main>
    </div>
  )
}

export default Account

We have the logOut function that will disconnect the user. In this function we use the disconnectAsync from the useDisconnect hook to disconnect the user from the wallet and the signOut function from next-auth to remove the session and redirect the user to the home page.

Then we can create the pages/protected.tsx :

import Head from 'next/head'
import Image from 'next/image'
import { Layout } from '../components/Layout'
import type { NextPage } from 'next'

const Protected: NextPage = () => {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center">
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <Layout>
        <h1 className="pb-12 text-4xl font-bold">Protected</h1>
        <p>This page is protected you can access it only when logged in</p>
      </Layout>

      <main className="flex w-full flex-1 flex-col items-center justify-center px-20 text-center"></main>
    </div>
  )
}

export default Protected

Nothing special here, this page is protected and can be accessed only when the user is authenticated. Because of the middleware we created previously the user will be redirected to the login page if he is not authenticated.

And finally the login page pages/login.tsx :

const Login: NextPage = () => {
  const { signMessageAsync } = useSignMessage()
  const { disconnectAsync } = useDisconnect()

  const { chain } = useNetwork()
  const { query, push } = useRouter()
  const [error, setError] = useState<string | undefined>(
    query.error as string | undefined
  )
  useAccount({
    onConnect({ address }) {
      setError(undefined)
      if (address) {
        login(address, chain?.id)
      }
    },
  })

  const login = async (
    address: string | undefined,
    chainId: number | undefined
  ) => {
    try {
      const csrf = await getCsrfToken()
      const message = new SiweMessage({
        domain: window.location.host,
        address: address,
        statement: 'Sign in with Ethereum to the app.',
        uri: window.location.origin,
        version: '1',
        chainId: chainId,
        nonce: csrf,
      })
      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      })

      const res = await signIn('credentials', {
        message: JSON.stringify(message),
        redirect: false,
        signature,
        callbackUrl: window.location.origin,
      })

      if (res?.error) {
        console.log(res.error)
        setError('Unable to login. Please try again.')
        await disconnectAsync()
      }

      if (res?.url) {
        console.log(res.url)
        push(res.url)
      }
    } catch (error: any) {
      setError(error.toString())
      await disconnectAsync()
    }
  }

  return (
    <div className="flex min-h-screen flex-col items-center justify-center">
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <Layout>
        <h1 className="pb-12 text-4xl font-bold">Login page</h1>
        <ConnectButton />
        {error && <p className="mt-12 text-red-500">{error}</p>}
      </Layout>

      <main className="flex w-full flex-1 flex-col items-center justify-center px-20 text-center"></main>
    </div>
  )
}

export default Login

export async function getServerSideProps(context: any) {
  const session = await getServerSession(context.req, context.res, authOptions)

  if (session) {
    return {
      redirect: {
        destination: '/',
        permanent: false,
      },
    }
  }

  return {
    props: { session },
  }
}

Here, first we use the useAccount hook to get the callback when the user has connected his wallet. With this we can trigger the login function.

useAccount({
  onConnect({ address }) {
    setError(undefined)
    if (address) {
      login(address, chain?.id)
    }
  },
})

Then we have the login function that will sign a siwe message with the user wallet and send it to the backend to authenticate the user.

To do it first we get the csrf token from the backend provided by Next-Auth, create the standard siwe message and sign it with the user wallet.

const csrf = await getCsrfToken()
const message = new SiweMessage({
  domain: window.location.host,
  address: address,
  statement: 'Sign in with Ethereum to the app.',
  uri: window.location.origin,
  version: '1',
  chainId: chainId,
  nonce: csrf,
})
const signature = await signMessageAsync({
  message: message.prepareMessage(),
})

And then we call the signIn function from next-auth to authenticate the user.

const res = await signIn('credentials', {
  message: JSON.stringify(message),
  redirect: false,
  signature,
  callbackUrl: window.location.origin,
})

ANd we have done with the authentication system.

One more thing

One thing to keep in mind is that now the session is decoralated from the wallet, so if the user switch his wallet in metamask, he will still be authenticated with the previous wallet. And when doing further action that need wallet confirmation this can lead to confusion for the user. But in some cases this can be the expected behavior it's uppon you to decide. Just remember that Next-Auth will just maintain a session and check authorization, but you will always use the connected wallet to validate actions on blockchain.

If you don't want this behavior you can do something like this to logout the user when the wallet change.

useEffect(() => {
  if (session && address !== (session as any).address) {
    logOut()
  }
}, [address])

Conclusion

This is pretty straightforward to implement, but it's a good way to have a secure authentication system in your dapp. I recommand you to deep dive into the next-auth documentation to see all the possibilities it offers.

The source code of this post is available on github