Implementing Credentials Provider on NextJS and NextAuth (Registration and Login)

Chris Board

Apr 26, 202123 min read
developmenttutorialreactjsnextjsnextauth

NOTE: This blog post was updated on 14th April 2022 to include an example or registering new users and basing the instructions on the latest version of the NextAuth library - at the time of writing the version being version 4.3.1.

Recently I started looking into using NextJS and NextAuth as I've heard lots of good things about how it simplifies the development process and also, I am always trying to keep up with the latest technologies and stay up to date as much as possible. However, implementing the NextAuth authentication provider with my own MySQL database and ensure the user was logged in before showing sensitive information proved to be tricker than I expected, so hopefully this blog post, will save you the two or three 3 days I spent trying to figure what the issue was.

In case you don't know what it is, NextJS is effectively a layer or framework on top of ReactJS that supports a hybrid of static and server side rendering, typescript support, smart bundling and route pre-fetching among other benefits. NextJS allows you to create a frontend/backend project in one and each page that you want to render, is just added to a pages folder which automatically creates you a route so there is no need to configure a Router manually within app.js like you would in a standard create-react-app type project. For API endpoints, you can create a typescript file in the /api subdirectory and that automatically creates a request URL, and don't worry just because the API is part of same project, NextJS uses intelligent bundling so that the client side code is no bigger than what is necessary and same goes for the backend.

NextAuth on the other hand provides a simple already implemented authentication system with sign up, sign in, and sign out for multiple different providers such as Apple, GitHub, Twitter including using your own authentication database if required - which is where this blog post comes in, and in this tutorial this is focussed on the sign in (if I have issues with the forgotten password side of things I'll potentially do another post).

Note that next-auth is purely an authentication mechanism, it does not handle registration of new users. You might be asking though, why their is a signUp method if it doesn’t do registration. I’m not 100% sure as not used this particular area of next-auth but I believe this is a callback for authentication systems, such as Apple/Twitter/GitHub signup so when you login to those accounts if you detect its a new user sign up this signUp callback is triggered. We’re not using that as we’re using own backend so its up to us to handle.

When I started looking into this, I already had a basic user database that I wanted to hook in to, using my own sign in form. NextAuth does provide an auto generated sign in page if you want to use that, but I wanted to use my own form to ensure it matched with my own formatting and style of the site, so this is what this blog post will show as there some lacking on documentation or examples on how to implement this so thought I'd write this post so hopefully you don't need to spend 3 days trying to figure it out like I did ?

Note: This tutorial is based on using MySQL server but the same setup should be pretty much the same. Also note that for the database access I am using is Prisma for MySQL Database access from the projects backend. I will briefly explain what I am doing with the prisma requests, but this isn't focussed towards a prisma tutorial.

Starting Your Project

Locate where you want to store your project and open a command prompt or terminal window to your directory and run the following command:

npx create-next-app my-project --use-npm

This will create a new project under the directory my-project. If you go into the my-project directory, you should see a similar file layout as below:

Next change directory into my-projects run from your terminal npm run dev and it will start the next development server and you can then browse to http://localhost:3000 and you should see the following: (note all further commands are run from the my-project directory).

If you see the below error screen instead of the Welcome to NextJS screen above then make sure that you are running the project from the actual path on your PC, if it is from a symlink then it may not work.

In my-project/pages there is an index.js file. We're going to use Typescript so rename this to be index.tsx.

We then need to install typescript so this can be done with the following command:

npm install --save-dev typescript @types/react

Then restart your dev server, you should see something outputted saying that typescript was detected and that tsconfig.json file has been created for you.

Next lets set up the user table we're going to use. Remember, I'm using prisma for the database management but you don't need to, use whatever you normally use to query the database.

Setting Up The Database

First we need to create a .env file that will store a configuration such as our database query string. This should be created in the root of your my-project folder.

Add the following to your newly created .env file

DATABASE_URL="mysql://username:password%@127.0.0.1:3306/tutorial"

Change your the username and password attributes to a valid MySQL login, 127.0.0.1 might need to be changed depending on where your database server is located and on the end is tutorial this is our database name that we're going to use.

To install prisma run the following:

npm install prisma --save-dev
npx prisma

Next create a folder in the root of your my-project folder called prisma and add a new file called schema.prisma then add the following contents into this file:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model users {
  userId        Int         @id @default(autoincrement())
  registeredAt  DateTime   @default(now())
  firstName     String      @db.VarChar(250)
  lastName      String      @db.VarChar(250)
  email         String      @db.VarChar(250)
  password      String      @db.VarChar(250)
  isActive      String      @default("1") @db.Char(1)
}

This is setting up prisma to what JS file will be used (auto generated by prisma) and the datasource so which database engine and which environment variable to extract from the .env file to determine the database connection string. After that is the model users which then determines how our users table is created.

If you are manually creating the database and not using prisma you can run the following SQL statement:

CREATE TABLE `users` (
  `userId` int(11) NOT NULL AUTO_INCREMENT,
  `firstName` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `lastName` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `email` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `password` varchar(250) COLLATE utf8mb4_unicode_ci NOT NULL,
  `isActive` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '1',
  `registeredAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `expires` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`userId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Run npx prisma migrate dev to allow prisma to create your database table. It will ask you for a name of the migration so you can just call it something like init.

run npx prisma studio and once started open a new browser window to http://localhost:5555 and you should see something. This will allow you to view the database. You should see something like below when browsing to http://localhost:5555

From All Models select users and you should see the following:

Fixing the Basic CSS

We’re going to create a couple of simple forms, one to register a user and the other to login. The form will look horrible as we’re not using design framework for this, so amend the CSS in your my-project/styles/globals.css and copy and paste the following CSS:

html,
body {
  padding: 0;
  margin: 5px;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
    Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
  font-size: medium;
}

a {
  color: inherit;
  text-decoration: none;
}

* {
  box-sizing: border-box;
}

input {
  display: block;
  margin-top: 5px;
  margin-bottom: 5px;
  height: 30px;
  width: 240px;
}

button {
  margin-top: 10px;
  margin-bottom: 5px;
  height: 30px;
  display: block;
}

a {
  color: blue;
  margin-top: 5px;
}

Create Registration Form

We want to be able to register users so next we want to create a simple registration page. Inside /my-project/pages/register.tsx copy and paste the following into this file:

import * as React from "react";
import Link from "next/link";
import {useState} from "react";

export default function Register() {

    const [firstName, setFirstName] = useState('');
    const [lastName, setLastName] = useState('');
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');

    const registerUser = (event) => {
        event.preventDefault();
    }

    return (
        <>
            <h1>Register</h1>

            <form onSubmit={registerUser}>
                <label>
                    First Name: <input type='text' value={firstName} onChange={(e) => setFirstName(e.target.value)} />
                </label>
                <label>
                    Last Name: <input type='text' value={lastName} onChange={(e) => setLastName(e.target.value)} />
                </label>
                <label>
                    Email: <input type='text' value={email} onChange={(e) => setEmail(e.target.value)} />
                </label>
                <label>
                    Password: <input type='password' value={password} onChange={(e) => setPassword(e.target.value)} />
                </label>
                <button type='submit'>Register User</button>

                <Link href='/register'>Register</Link>
            </form>
        </>
    )
}

Creating Your Login Form

Next up we want to create a login form that allows the user to enter their email and password to login.

Inside /my-project/pages/index.tsx change the home function to look like the below:

import * as React from "react";
import {useState} from "react";
import Link from 'next/link'

export default function Home() {

  const [user, setUser] = useState('');
  const [password, setPassword] = useState('');
  const [loginError, setLoginError] = useState('');
  const handleLogin = (event) => {
    event.preventDefault();
  }

  return (
      <form onSubmit={handleLogin}>
        {loginError}
        <label>
          Email: <input type='text' value={user} onChange={(e) => setUser(e.target.value)} />
        </label>
        <label>
          Password: <input type='password' value={password} onChange={(e) => setPassword(e.target.value)} />
        </label>
        <button type='submit'>Submit login</button>

          <Link href='/register'>Register</Link>
      </form>
  )
}

This creates a very basic (not very pretty form) with an email and password field and a submit button that when pressed calls the handleLogin method and just prevents the form submit doing the HTML default action for a form.

Next we want to prepare our API.

Setting Up the API

Before we can attempt logging in or registering a user, we need to install next-auth which is the library we’re using to perform the authentication, and bcrypt which is responsible for hashing our password for storage in our database.

First thing we need to do is to install next-auth. This can be done using the following command:

npm install next-auth
npm install bcrypt

Before we can attempt logging in or registering a user, we need to install next-auth which is the library we’re using to perform the authentication, and bcrypt which is responsible for hashing our password for storage in our database.

First thing we need to do is to install next-auth. This can be done using the following command:

npm install next-auth
npm install bcrypt

We need to set up a NEXTAUTH url within our .env file. Locate your .env file inside your my-project folder and add the following:

NEXTAUTH_URL=http://localhost:3000

Now we need to set up our next-auth implementation. Under /my-project/pages create a new directory called api and create a new directory inside api called auth and in there create a file called [...nextauth].ts (full path will be /my-project/pages/api/auth/[...nextauth].ts.

This is creating an API endpoint so that whenever a request goes to http://localhost/api/auth/... (... being the authentication method we need) it will always be handled by that [...nextauth].ts script.

Inside this script we need to add the following, each section will be explained afterwards:

import NextAuth from 'next-auth'
import { PrismaClient } from '@prisma/client'
import CredentialsProvider from "next-auth/providers/credentials";
let userAccount = null;

const prisma = new PrismaClient();

const bcrypt = require('bcrypt');

const confirmPasswordHash = (plainPassword, hashedPassword) => {
    return new Promise(resolve => {
        bcrypt.compare(plainPassword, hashedPassword, function(err, res) {
            resolve(res);
        });
    })
}

const configuration = {
    cookie: {
        secure: process.env.NODE_ENV && process.env.NODE_ENV === 'production',
    },
    session: {
        jwt: true,
        maxAge: 30 * 24 * 60 * 60
    },
    providers: [
        CredentialsProvider({
            id: "credentials",
            name: "credentials",
            credentials: {},
            async authorize(credentials : any) {
                try
                {
                    const user = await prisma.users.findFirst({
                        where: {
                            email: credentials.email
                        }
                    });

                    if (user !== null)
                    {
                        //Compare the hash
                        const res = await confirmPasswordHash(credentials.password, user.password);
                        if (res === true)
                        {
                            userAccount = {
                                userId: user.userId,
                                firstName: user.firstName,
                                lastName: user.lastName,
                                email: user.email,
                                isActive: user.isActive
                            };
                            return userAccount;
                        }
                        else
                        {
                            console.log("Hash not matched logging in");
                            return null;
                        }
                    }
                    else {
                        return null;
                    }
                }
                catch (err)
                {
                    console.log("Authorize error:", err);
                }

            }
        }),
    ],
    callbacks: {
        async signIn(user, account, profile) {
            try
            {
                //the user object is wrapped in another user object so extract it
                user = user.user;
                console.log("Sign in callback", user);
                console.log("User id: ", user.userId)
                if (typeof user.userId !== typeof undefined)
                {

                    if (user.isActive === '1')
                    {
                        console.log("User is active");
                        return user;
                    }
                    else
                    {
                        console.log("User is not active")
                        return false;
                    }
                }
                else
                {
                    console.log("User id was undefined")
                    return false;
                }
            }
            catch (err)
            {
                console.error("Signin callback error:", err);
            }

        },
        async register(firstName, lastName, email, password) {
            try
            {
                await prisma.users.create({
                    data: {
                        firstName: firstName,
                        lastName: lastName,
                        email: email,
                        password: password
                    }
                })
                return true;
            }
            catch (err)
            {
                console.error("Failed to register user. Error", err);
                return false;
            }

        },
        async session(session, token) {
            if (userAccount !== null)
            {
                //session.user = userAccount;
                session.user = {
                    userId: userAccount.userId,
                    name: `${userAccount.firstName} ${userAccount.lastName}`,
                    email: userAccount.email
                }

            }
            else if (typeof token.user !== typeof undefined && (typeof session.user === typeof undefined
                || (typeof session.user !== typeof undefined && typeof session.user.userId === typeof undefined)))
            {
                session.user = token.user;
            }
            else if (typeof token !== typeof undefined)
            {
                session.token = token;
            }
            return session;
        },
        async jwt(token, user, account, profile, isNewUser) {
            console.log("JWT callback. Got User: ", user);
            if (typeof user !== typeof undefined)
            {
                token.user = user;
            }
            return token;
        }
    }
}
export default (req, res) => NextAuth(req, res, configuration)

Right, now lets breakdown what's going on here.

cookie: {
    secure: process.env.NODE_ENV && process.env.NODE_ENV === 'production',
},
redirect: false,
session: {
    jwt: true,
    maxAge: 30 * 24 * 60 * 60
    },

The section above sets up some basic settings. The cookie/secure option is so that the secure flag is only set on the cookie when you are in production mode when presumably you would be using HTTPS, but turns this flag off for local development when you are likely just using HTTP.

In the session, we enable JWT which stands for JSON Web Token, this will provide our user model in the session so we can access through the lifetime of the user being logged in and the max age sets the maximum age of when the session expires if its not used (it will automatically be incremented to each time the user makes a request).

The next section is the providers section:

providers: [
        CredentialsProvider({
            id: "credentials",
            name: "credentials",
            credentials: {},
            async authorize(credentials : any) {
                try
                {
                    const user = await prisma.users.findFirst({
                        where: {
                            email: credentials.email
                        }
                    });

                    if (user !== null)
                    {
                        //Compare the hash
                        const res = await confirmPasswordHash(credentials.password, user.password);
                        if (res === true)
                        {
                            userAccount = {
                                userId: user.userId,
                                firstName: user.firstName,
                                lastName: user.lastName,
                                email: user.email,
                                isActive: user.isActive
                            };
                            return userAccount;
                        }
                        else
                        {
                            console.log("Hash not matched logging in");
                            return null;
                        }
                    }
                    else {
                        return null;
                    }
                }
                catch (err)
                {
                    console.log("Authorize error:", err);
                }

            }
        }),
    ]

This provides an array of providers, in our case we only have one which is the credentials provider, but you can add other providers such as GitHub, Google or Apple for example.

The id is used so that when we call the signin method from the frontend we trigger the provider that we need, in this case the credentials.

Next we have an async authorize method. This method takes a parameter called credentials. Credentials will hold the user and password that you will send from the frontend with the sign in information. I am then looking up in the database using the prisma client for a record in the database with the email address, don’t pass the password as it won’t match and we need to compare the hash ourselves. If you are writing the query yourself, the above await prisma.users.findFirst({...}) is the equivalent of running the SQL:

SELECT * FROM users WHERE email='value';

If the database returns a value, then that user is returned otherwise null. In our case because we're using prisma an object containing each field and value will be returned.

If we get a user back, we then call the method confirmPasswordHash and pass in our password from the frontend, and the hashed password that is returned in our user object. If the password hash matches we then set up our userAccount object.

The reason we create a separate object for the userAccount instead of just setting it to be the user object returned by Prisma, is because this will create our user session object. Only information relevant should be put in the session, not things like passwords as this is a potential security risk, so we build up our own object omitting the columns the session doesn’t need to know.

If the password hash wasn’t matched, we return null which will result in an error being returned to our frontend - i.e. the login didn’t exist.

Next up we have a series of callbacks. Note that the callbacks will take various parameters but not all of them are required for this particular provider that we're using and depending on the provider, some information may differ - the below is only relevant for the credentials provider.

try
{
    //the user object is wrapped in another user object so extract it
    user = user.user;
    if (typeof user.userId !== typeof undefined)
    {

        if (user.isActive === '1')
        {
            console.log("User is active");
            return user;
        }
        else
        {
            console.log("User is not active")
            return false;
        }
    }
    else
    {
        console.log("User id was undefined")
        return false;
    }
}
catch (err)
{
    console.error("Signin callback error:", err);
}

The user parameter in the signin method is an object that contains a key called user which has our information in, we just update the user to unwrap this to save us having to keep referring to user.user everytime.

The first callback is the signIn method. This is confirming that the user is actually allowed to sign in. If we searched in the database for isActive=1 only, we wouldn't be able to tell the user, whether their password was wrong, or if there account was actually disabled, so here in this callback we use the user model that was returned by the signIn method and check if isActive is equal to 1 meaning that the user can signin in otherwise we return false.

async jwt(token, user, account, profile, isNewUser) {
            if (typeof user !== typeof undefined)
            {
                token.user = user;
            }
            return token;
        }

Don't worry, I've not skipped the session, callback, but the jwt callback is used first and this sets up the data required for the session.

The user parameter contains the user model returned by the signIn method, but note only on the first call to this callback, i.e. when the user first signs in, so here, we're checking if the user is not undefined, and if so, add the user key to the token and set it to be the value of the user model, otherwise it just returns the token.

async session(session, token) {
            if (userAccount !== null)
            {
                session.user = userAccount;
            }
            else if (typeof token.user !== typeof undefined && (typeof session.user === typeof undefined 
                || (typeof session.user !== typeof undefined && typeof session.user.userId === typeof undefined)))
            {
                session.user = token.user;
            }
            else if (typeof token !== typeof undefined)
            {
                session.token = token;
            }
            return session;
        }

If the userAccount is not null (that is created at the top of the script and then set on the authorize request) and then sets this in the session with the user key.

Or if the user model is inside the token, then the user from the token is re-added back to the session.user otherwise if the token is set then the session.token is set to the value of the token parameter and then the session is returned. The reason for the first else if was it seemed like getting the session from the frontend had a slightly different object layout compared to getting the session from the API request.

That's the API built but we now need to tie the frontend form with the API.

Completing the Registration Form

To do this under my-project/pages/api create a new file called register.ts and copy and paste the following into this file:

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient();
const bcrypt = require('bcrypt');

export default async (req, res) => {
    if (req.method === "POST")
    {
        const {firstName, lastName, email, password} = req.body;

        try
        {
            const hash = await bcrypt.hash(password, 0);
            await prisma.users.create({
                data: {
                    firstName: firstName,
                    lastName: lastName,
                    email: email,
                    password: hash
                }
            });

            return res.status(200).end();
        }
        catch (err)
        {
            return res.status(503).json({err: err.toString()});
        }
    }
    else
    {
        return res.status(405).json({error: "This request only supports POST requests"})
    }
}

The first thing we do is import the prisma client and instantiate a new instance of the client and then create the Next API route handler.

Within the async method is an “if” statement which checks the HTTP method within the parameter. To create a new user, we are using the POST request, anything else, a 405 method not allowed response is returned.

In the if statement we extract each property in the post field into their own constants in the request body and then pass this into the data object within prisma’s create method to add the user details into our database.

We then return a 200 OK and the .end() method is called as we’re not returning any response body in the response.

That’s the API endpoint created, now we need to send the request from the frontend to the API.

If we go back to my-project/pages/register.tsx we need to update the registerUser method to send the request.

This is just an example to get started, you’ll likely need to make some amendments to ensure that the providers details don’t already exist so you don’t create multiple users with the same email for example.

Paste in the following for this method

const registerUser = async (event) => {
	event.preventDefault();
	
	const data = {
	    firstName: firstName,
	    lastName: lastName,
	    email: email,
	    password: password
	}
	
	await axios.post('/api/register', data);
	signIn("credentials", {
	    email, password, callbackUrl: `${window.location.origin}/dashboard`, redirect: false }
	).then(function(result) {
	    router.push(result.url)
	}).catch(err => {
	    alert("Failed to register: " + err.toString())
	});
}

The first thing we do here is create a data object consisting of the information we need to send to the API to register the user.

We then use axios http library (npm install axios) to send a POST request with our data to our /api/register endpoint (What we created in /api/register.ts).

This isn’t production ready code, as we’re not actually checking the result of the POST request, we’re assuming it just works and if it does, we then call the signIn method using the same details to actually login against the user details we provided to the register. We need to do this as there is no way to authenticate the user directly as part of the registration.

Completing Login Request

Go back to /my-project/pages/index.tsx and add the following import line:

import {useRouter} from "next/router";
import {signIn} from "next-auth/react";

After the state variables, create an instance of the router as follows:

const router = useRouter();

This will allow us to redirect to another page (not that we have one) if signed in successfully.

Change your handleLogin method so that it now looks like the below:

const handleLogin = (event) => {
        event.preventDefault();
        event.stopPropagation();

        signIn("credentials", {
            email, password, callbackUrl: `${window.location.origin}/dashboard`, redirect: false }
        ).then(function(result){
            if (result.error !== null)
            {
                if (result.status === 401)
                {
                    setLoginError("Your username/password combination was incorrect. Please try again");
                }
                else
                {
                    setLoginError(result.error);
                }
            }
            else
            {
                router.push(result.url);
            }
        });
    }

This calls the signin method using the credentials provider and passes the email and password that we need to authenticate against, and the callbackUrl is used when we deem the signin successful and is passed back to us in the result object that is returned in the promise.

If signing is not successful, then result.error will be null so we check if an error exists and then check the HTTP status code, therefore if 401 unauthorised we show a username/password combination error by setting loginError, otherwise we show the error result that was returned by nextauth and set loginError to be the error returned.

If everything is OK, we use the router.push and provide the URL in the result object.

Before we can test, we need to create a new file in the /my-project/pages directory called _app.js.

Once the file is created add the following:

import {SessionProvider} from 'next-auth/react'

export default function MyApp({Component, pageProps}) {


    return (
        <SessionProvider options={{clientMaxAge: 0}} session={pageProps.session}>
            <Component {...pageProps} />
        </SessionProvider>
    )
}

Ensuring All Pages are Authenticated

If you aren’t logged in and go to /dashboard at the moment you’ll see something like the following:

At the moment, every page/component you go to, you’ll need to check the session. This is probably impractical, so what we can do instead is create a wrapper component that checks the session is valid and if it is, shows the page/component, but if the session is not valid, then a login error is shown.

To do this, lets create the wrapper component with the following code:

import * as React from "react";
import {useSession} from "next-auth/react";
import Link from 'next/link'
import {useRouter} from "next/router";

export default function Wrapper(props)
{
    const session = useSession();
    const router = useRouter();

    if ((session !== null && session?.status === "authenticated") ||
        (router.pathname === "/" || router.pathname === '/register'))
    {
        return (
            props.children
        )
    }
    else {
        return (
            <>
                <h1>You are not authenticated</h1>

                <Link href='/'>Back to Login</Link>
            </>
        )
    }

}

This check the session isn’t null and that the status shows is equal to authenticated, but only validates the session if the current path is not / e.g. the login page and not /register e.g. the registration page.

If the session is valid or we’re on the login or registration page, then it renders the children of the wrapper component, otherwise it shows a not authenticated error with a link back to the login page.

We need to wrap this component though in pages/_app.js.

Change _app.js to contain the following instead:

import '../styles/globals.css'
import { SessionProvider } from "next-auth/react"
import Wrapper from "../components/Wrapper";

function MyApp({ Component, pageProps }) {
console.log("Got Session: ", pageProps.session);
  return (
      <SessionProvider options={{clientMaxAge: 0}} session={pageProps.session}>
          <Wrapper>
              <Component {...pageProps} />
          </Wrapper>

      </SessionProvider>

  )

}

export default MyApp

The session provider needs to be the top level components to ensure everything underneath has access to the session data.

If you now refresh the /dashboard page you will now see the following:

That’s it, we’ve implemented a very basic example of registering a user and logging in and handling components not being accessible if authentication hasn’t occurred

Test Track

Are you a developer or involved in Quality Assurance Testing or User Acceptance Testing, you might be interested in Test Track

A simple and affordable test planning and management solution.