Authentication in a Next.js Application with next-auth and Firebase

Adam Richardson / February 06, 2021

10 min read

Authentication in a Next.js Application with next-auth and Firebase

Authentication is a requirement for most apps and there are plenty of tools and resources available to make this process easier. There are still lots of challenges as each project will demand different user flows, models and information to be stored.

One of the reasons we decided to write about this implementation is because our first time using "next-auth" we took a bit of digging to catch on, and thought we could maybe spare you some research! This is a brilliant open source project and will save you drastic amounts of time.

So first lets talk about what we needed to achieve for the app.

  1. Ability to protect routes, ensuring users are signed up before visiting these pages.

  2. Protecting admin routes ensuring that a user is an admin before accessing the page.

  3. Protecting API routes with admin only access.

We will accomplish the first requiremt with out of the box functionality. Example code is from our "Forever Chess Games" project. The second and third step will require is to create a database so that we can store users, and also find out if they are an admin or not.

I will assume you have a Next.js project fully setup, and you are looking to add authentication to it. Let's install next-auth, I will also install firebase-admin here. Our data requests are only happening on the server side, so we do not need the firebase client, just the admin.

// Using Yarn 
yarn add next-auth firebase-admin
//Using npm 
npm i next-auth firebase-admin

Let's first create the files that we will need to the setup.

touch /pages/api/auth/[...nextauth].js
touch /firebaseAdmin.js

Let's have a look at our base nextauth API route. This code is for the docs, we're going to modify it for our use case.

import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'

export default NextAuth({
  // This is where our providers are stored. We are only using Github for our website, but you can use as many as you want. There are guides on the Next JS docs for integrating all supported providers, we will just talk through Github integration.
  providers: [
    Providers.GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET
    }),
    // ...add more providers here
  ],
})

This is all you need to get started with authentication. We can now create a Github application. I usually create 2 applications, one for development, and one for production.

Log in to Github and go to settings > developer settings > New Github Application. Complete with the details as below. Add your production URL to the webhook URL. We're not using this, but it can not be a localhost.

Github project settings

Scroll to the bottom and register app. Once you've done that, hit "Generate a new secret client. Add the keys to your .env.local file using the names we declared in our nextauth API route. Your auth is now setup!

Let's look at the documentation for how to use it on the client.

import { signIn, signOut, useSession } from 'next-auth/client'

export default function Page() {
  const [ session, loading ] = useSession()

  return <>
    {!session && <>
      Not signed in <br/>
      <button onClick={() => signIn()}>Sign in</button>
    </>}
    {session && <>
      Signed in as {session.user.email} <br/>
      <button onClick={() => signOut()}>Sign out</button>
    </>}
  </>
}

Hopefully the code above makes sense to you. We're using the useSession hook to get the session of our users. If there is a session, then we will show the content. If there is no session, the page will display a sign in button.

This satisfies requirement 1. If you just need your users to be authenticated, but not store any additional information, then you are done! You can add a provider to your custom _app.js file. This is well documented in the documentation. We don't want to use this on many routes, so we will just add it manually where we need it.

Let's take care of requirement 2 and start by implementing Firebase Admin. Copy and paste this file, and add the required environment variables. You can find these in the settings of your firebase console. Service accounts, generate new service account. Download and open the .json file and take the 3 variables from there that you need and add them to a .env.local file.

/firebaseAdmin.js
import * as firebaseAdmin from 'firebase-admin';

const privateKey = process.env['PRIVATE_KEY'].replace(/\\n/g, '\n');
const clientEmail = process.env['CLIENT_EMAIL'];
const projectId = process.env['PROJECT_ID'];

// Check if we have all the correct credentials.
if (!privateKey || !clientEmail || !projectId) {
  console.log(`Failed to load Firebase credentials.`);
}

// This will initialize a Firebase Admin app if on doesn't exist already, if it does then it will return the existing application. We can now just import this wherever we need to use Firebase Admin.
export default !firebaseAdmin.apps.length
  ? firebaseAdmin.initializeApp({
      credential: firebaseAdmin.credential.cert({
        privateKey: privateKey,
        clientEmail,
        projectId,
      }),
      databaseURL: `https://${projectId}.firebaseio.com`,
    })
  : firebaseAdmin;

OK so now we can work on requirement 2. The flow we are looking for will look like this.

  1. User Requests sign in
  2. We check if the user exists in firebase
  3. If they don't, we add the user to firebase
  4. If user already exists, or we have added the user to firebase, we now sign them into the app.

Let's take a look at the additional code in our API route. We create the function getUser() which will fetch a user. If a user is found then it will return the user. If a user is not found, it will return null. You should wrap this in a trycatch block and handle errors but that's beyond the scope of this post. You can write this code cleaner however I think it's much more readable this way for learning purposes. The 2nd if statement is not actually necessary either, it's just to illustrate the point.


import firebaseAdmin from '../../../firebaseAdmin';

async function getUser(username) {
// Fetch user taking a username parameter.
  const userData = await firebaseAdmin
    .firestore()
    .collection('users')
    .where('name', '==', username)
    .get();

  const userArray = [];
  if (!userData.empty) {
    userData.forEach((doc) => {
      userArray.push(doc.data());
    });

    return userArray[0];
  }
    if (userData.empty) {
    return null;
  }
}

Within our auth API route, we can configure exactly what happens for each callback such as signin, signout and session. Let's do this now for sign in.

The default signin callback looks like this. If it returns true, it will log in the user, if it returns false, it would not log in the user. Pretty simple.

  callbacks: {
    async signIn(user, account, profile) {
      return true;
    },

  },

Before we return true, and actually sign in the user, let's add the logic to check if a user exists.

    async signIn(user, account, profile) {
    
    // We call our DB user function, this would return the user if there was one, and null if there wasn't. We are passing in the user.name paramater which in our example will search for the github username. We can do this as we are just using Github. Github doesn't provide an email address if it's not set to public. If you wanted to exclusively use email with different providers, you could handle the case user.email doesn't exist and redirect to add the email to your firebase database. We will stick to username for our example.
    
      const dbUser = await getUser(user.name);
    // If there is not a DB user, we would add the user to the database, and then return true in our .then() statement. We set the isAdmin to false for all users on signup.
      if (!dbUser) {
        firebaseAdmin
          .firestore()
          .collection('users')
          .add({ ...user, isAdmin: false })
          .then((_) => true);
      }
      return true;
    },

The last thing we need to do is modify our session, to retrieve the user data from firebase. We are once again using our getUser() function.

async session(session, user) {
    const dbUser = await getUser(user.name);
    return { session, dbUser:dbUser };
  },

Here is to complete API route

/api/[...nextauth].js
import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';
import firebaseAdmin from '../../../firebaseAdmin';

async function getUser(username) {
  const userData = await firebaseAdmin
    .firestore()
    .collection('users')
    .where('name', '==', username)
    .get();

  const userArray = [];
  if (!userData.empty) {
    userData.forEach((doc) => {
      userArray.push(doc.data());
    });

    return userArray[0];
  }
  if (userData.empty) {
    return null;
  }
}

const options = {
  // @link https://next-auth.js.org/configuration/providers
  providers: [
    Providers.GitHub({
      clientId: process.env.NEXTAUTH_GITHUB_ID,
      clientSecret: process.env.NEXTAUTH_GITHUB_SECRET,
    }),
  ],

  callbacks: {
    async signIn(user, account, profile) {
      const dbUser = await getUser(user.name);
      if (!dbUser) {
        firebaseAdmin
          .firestore()
          .collection('users')
          .add({ ...user, isAdmin: false })
          .then((_) => true);
      }
      return true;
    },

    async session(session, user) {
      const dbUser = await getUser(user.name);
      return { session, dbUser: dbUser };
    },
  },

  // You should set this secret to a long random string in your .env file such as 
  // AUTH_SECRET=aeijqoiewdjkasdh1092438iuqdjknasdb180293eiuqwkdjhbasdhg81
  secret: process.env.AUTH_SECRET,
  debug: true, //Enables debug messages in the console
};

const Auth = (req, res) => NextAuth(req, res, options);

export default Auth;

We are now good to go with requirement 2!

On the client side, we now just need to check if the user is an admin. Here is an example of what that might look like, taken from our admin dashboard.

const Dashboard = () => {
  const [session, loading] = useSession();
  
  if (loading) {
    return (
      <div className='w-screen h-screen flex justify-center items-center'>
        <LoadingSpinner />
      </div>
    );
  }

  return (
    <>
      {!session && (
        <>
          <div className='max-w-2xl h-screen w-screen flex flex-col justify-center items-center mx-auto'>
            <button
              className='bg-indigo-500 text-white px-8 py-2 rounded-lg mt-2'
              onClick={() => signIn('github')}>
              Sign in
            </button>
          </div>
        </>
      )}

      {session && !session.dbUser.isAdmin && (
        <>
          Not authorised to be here. <br />
          <a href='/'>Go Home</a>
        </>
      )}
      {session && session.dbUser.isAdmin && (
            <div> ADMIN CONTENT HERE </div>
            <h1>{session.dbUser.name}</h1>
      )}
    </>
  );
};

export default Dashboard;

We're almost there. We are calling API routes in our admin dashboard from the client side, which is now protected. We also need to protect the API route though. Let's take a look at that by checking out one of our API files.

/api/somethingcool.js
import { getSession } from 'next-auth/client';
import firebaseAdmin from '../../firebaseAdmin';

export default async function (req, res) {
  const session = await getSession({ req });
  if (!session.dbuser.isAdmin) {
    return res.status(401).json({ error: 'Not authorised' });
  } 
  // Do authorised stuff here!
}

Phew, requirement 3 now settled. Hopefully you can see the relevant ease of implementation for this solution. This is perfectly suited if you only need authorisation in a few places within your application or to easily protect certain admin routes easily, like in the example given. This only scratches the surface of next-auth and the customisations possible, along with using their provided custom user models and integrating databases directly. I really like the simplicity and speed of this approach although there is definitely optimisations that could be made. We are make a call to the database on every getSession call, for example. For our simple use case admin dashboard, this is more than enough!