Make your app token gated with next-auth

In the previous posts we have seen how to handle authentication with next-auth and Sign-in with Ethereum. In this post we will see how we can check that the user is owner of the token particular token that is required to access app. For this post we will use the code from this post as a base.

To check if the user is owner of a token from a specific contract we will use the Alchemy SDK. The SDK is a wrapper around the Alchemy API. It provide a method call verifyNftOwnership that take the user's address and the contract address of the token and return a boolean. Like this

const isOwner = await alchemy.nft.verifyNftOwnership(
  'User address',
  'Contract address'
)

First we need to install the SDK

yarn add alchemy-sdk

Then we want to block the user if he doesn't hold at least one token. We will do this in the authorize function of the pages/api/auth/[...nextauth].ts

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,
    });

    const alchemy = new Alchemy({
      apiKey: process.env.ALCHEMY_API_KEY || "",
      network: Network.ETH_MAINNET,
      maxRetries: 0,
    });
    const isOwner = await alchemy.nft.verifyNftOwnership(
      siwe.address,
      "0xB852c6b5892256C264Cc2C888eA462189154D8d7"
    );

    if (result.success && isOwner) {
      return {
        id: siwe.address,
        isOwner,
      };
    }
    return null;
  } catch (e) {
    return null;
  }
}

Here just after validating the siwe message, we initialize the alchemy sdk and call the verifyNftOwnership method. In this case we check if the user is the owner of some RektGuy NFT. If it's not the case it won't be able to access the app.

Right now we only control the holding of the token at the login, but the user can sell/transfer the token and it will still be able to access the app until this session become invalid. We need to check the ownership of the token after the login. To do this we will use the session and jwt callback of Next-Auth.

callbacks: {
    async jwt({ token, user }) {
      /* Step 1: update the token based on the user object */
      if (user) {
        token.isOwner = (user as any).isOwner;
      } else {
        token.isOwner = await alchemy.nft.verifyNftOwnership(
          token.sub ?? "",
          "0xB852c6b5892256C264Cc2C888eA462189154D8d7"
        );
      }
      return token;
    },

    async session({ session, token }: { session: any; token: any }) {
      session.address = token.sub;
      session.user.name = token.sub;
      session.isOwner = token.isOwner;
      return session;
    },
  }

The jwt callback is called whenever a JSON Web Token is created (i.e. at sign in) or updated (i.e whenever a session is accessed in the client). Requests to /api/auth/signin, /api/auth/session and calls to getSession(), getServerSession(), useSession() will invoke this function. At sign in, the user object is passed to the function this is the object returned by the authorize function. So at login we set the isOwner property of the token object. And others time we just check if the user is still the owner of the token and we update the value.

The session callback is called whenever a session is checked. By default, only a subset of the token is returned for increased security. To make something available you added to the token (in our case the isOwner value) via the jwt() callback, you have to explicitly forward it here to make it available to the client.

In the client we can access the isOwner value with the useSession hook. This is usefull for two reasons :

  • If you manage differents section of your app that are not all token gated you can show/hide the section depending on the value of isOwner
  • And more important you can use this value to check if the user is still the owner of the token. If it's not the case you can sign him out and redirect it to the login page.

Here an exemple of code that you can add in a HOC or a custom hook.

import { useSession } from 'next-auth/client'
import { signOut } from 'next-auth/react'
// ...
const [session, loading] = useSession()
// ...

useEffect(() => {
  if (!loading && !session?.isOwner) {
    signOut()
    router.push('/login')
  }
}, [loading, session])

Here the complete code of the pages/api/auth/[...nextauth].ts

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

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

const alchemy = new Alchemy({
  apiKey: process.env.ALCHEMY_API_KEY || '',
  network: Network.ETH_MAINNET,
  maxRetries: 0,
})

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,
          })

          const isOwner = await alchemy.nft.verifyNftOwnership(
            siwe.address,
            '0xB852c6b5892256C264Cc2C888eA462189154D8d7'
          )

          if (result.success && isOwner) {
            return {
              id: siwe.address,
              isOwner,
            }
          }
          return null
        } catch (e) {
          return null
        }
      },
    }),
  ],
  pages: {
    signIn: '/login',
  },
  session: {
    strategy: 'jwt',
  },
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async jwt({ token, user }) {
      /* Step 1: update the token based on the user object */
      if (user) {
        token.isOwner = (user as any).isOwner
      } else {
        token.isOwner = await alchemy.nft.verifyNftOwnership(
          token.sub ?? '',
          '0xB852c6b5892256C264Cc2C888eA462189154D8d7'
        )
      }
      return token
    },

    async session({ session, token }: { session: any; token: any }) {
      session.address = token.sub
      session.user.name = token.sub
      session.isOwner = token.isOwner
      return session
    },
  },
}

export default NextAuth(authOptions)

One last thing, depending on your requirements you may not need to check if the user is still the owner of the token every time. You can for exemple only check this every 4 hours. To do this you can add a validity property to the token wich is the last time the control has been done and you can check if the time is expired before calling verifyNftOwnership.

callbacks: {
    async jwt({ token, user }) {
      /* Step 1: update the token based on the user object */
      if (user) {
        token.isOwner = (user as any).isOwner;
        token.validity = Date.now();
      } else {
        if (token.validity < Date.now() - 4 * 60 * 60 * 1000) {
          token.isOwner = await alchemy.nft.verifyNftOwnership(
            token.sub ?? "",
            "0xB852c6b5892256C264Cc2C888eA462189154D8d7"
          );
          token.validity = Date.now();
        }
      }
      return token;
    },

...

So that's it, you can now use next-auth with a NFT as a login system. I hope this will help you to build your own token gated app.

You can get the code here.