Using Next-Auth Credentials Provider with the Database Session Strategy

NextAuthjs is a great authentication library that integrates well with Nextjs and provides session management using either a database or JWT strategy. It comes with built in support for many popular oauth identity providers such as Google, Apple, AzureAD, Twitter etc, email link and token based verification and authentication as well as a credentials provider to support integrating with  LDAP or any other arbitrary username/password based user store by using a user provided authorize callback function.

However, while it is generally flexible it does have some limitations  related to managing authentication and sessions using the Credentials Provider. The one that comes up frequently is the decision not to support "out of the box" using this provider with the database strategy.  Imposing this limitation is not accidental but an intentional choice by the developers of the library as indicated in the documentation.

Credentials provider use is deliberately limited to only JWT sessions.

The rationale for this is apparently to discourage users from creating their own user store and using password based authentication as a main route as well as the inherent complications with securing and supporting password validation. My personal opinion is the first argument is mostly a "religious" one and that JWT's and third party auth methods while easier to implement have their own drawbacks as outlined here and here as well as quite a few other places including other downsides such as the inability in the case of JWTs to cancel a token once issued and for external identity stores or 3rd parties also using oauth to track and monitor user behaviour etc which have their own privacy or other "tech religious" biases that could associated with them. My feeling it is best for the framework to make life easier but leave some choices up to the developers judgement. Too much customization is bad and I'm ok with convention by default but outright unncessarily hampering and restricting the library isn't the way to go either.

Ultimately, this limitation really boils down to being annoying and it's existence is surprising given that the module is fairly flexible in other areas and implements database and JWT for all other providers. So the question is how do you get around this?

The good news is there is a solution and below I will outline a workaround. This has only been tested on v4 of next-auth and may break in the future but it does cover the basic steps as explained here in more detail.  The workaround works by doing the following:

  1. Creating a custom signup api route to ensure that username/password and other user details that will be valided by the Credentials Provider are stored in the database and a corresponding Account model is also linked to this user entry. The basic database models used by NextAuthjs can be modified to add new fields and in this example the username and password fields are added to the user model to support authentication. When a new profile is created a corresponding entry is included in the Accounts table with a type and provider values of credentials.
  2. Creating a Signup page or other mechanism that utilizes the api route to create user accounts or link existing accounts. You could also manually do this in the database or make the link in the authorization callback if an account entry for the user doesn't exist.
  3. Initialize the /pages/api/auth/[...nextauth].js api route using the advanced initialization format by writing a handler function that processes the req,res from Nextjs or in future another backend framework. This handler should not return before calling the NextAuth with the options specific for this implementation. NextAuth should also be the last call in the handler function as detailed here.
  4. In the [...nextauth].js catch all api route create a function that will act as a helper to generate new unique session token keys. The default by next-auth is to use UUIDs but you could also use crypto to generate random alphanumeric characters of varying lengths.
  5. In the [...nextauth].js catch all api route create a function that will act as a helper to calculate the expiry date for session cookies using the MaxAge set in the NextAuth options
  6. Define the initialization values for the Credentials Provider under the provider options in NextAuth. Ensure that you define the authorize callback. The authorize callback should check the username and password submitted in the credentials argument against your database preferrably using a crytographic hash comparison function (the function you use depends on your hasing method whether bcrypt or SHA256 and well as if you use a salt value). If the user is valided it should return the stripped down user profile information that will be saved in the session cookie later of null otherwise.
  7. Define the session callback function under the NextAuth callbacks option. This callback will take a session object and a user object and save the stripped down profile to the session object and return the final session object. Note, the user argument is passed the full user object with potentially sensitive information so ensure to remove these when saving to the session object. This session object is what is returned as the data in useSession hook in react.
  8. Define the signIn callback.This is the most important callback to define as this callback is called by NextAuth after the authorize callback is called and only if the user credentials is validated. Therefore, it is in this callback that you will check that the provider action is for credentials since these callbacks are also triggered by other providers and only when it is a credentials signIn will you generate a new cookie with the session token and set it in the response header.
  9. Overwrite the default JWT encode and decode functions. Typically all you would need to do for other providers is stop at 8 since NextAuth does a check on a flag called useJWTSession that will be false when using a database strategy but unfortunately in the if control look for the credentials provider it does not do this check and always creates a JWT session (hence the limitation) whereas the other providers will actually call the standard callbackHandler that manages the account linkages and creates the database sessions. Therefore, in order for our workaround to work we must manipulate the output from the JWT encode to not returned the JWT signed tokens that would encapsulate the user data but instead return the sessionToken value instead that references our database sessions table. When doing this we must be careful to ensure that this altered behaviour only happens for the Credentials Provider as the other providers rely on the JWT signing and functions to set state cookies especially when doing oauth.
  10. That is it! Next up is some sample code covering some but not all of the steps.

Below are some sample code snippets for tasks 1, 3, 4, 5, 8 and 9 above. These examples utilize Prisma ORM to manage access to the database, the crypto npm nodule to manage UUID and other random number operations for token generation, cookies npm module to manipulate HTTP cookies and the next-auth/jwt module to access the default JWT encode and decode operations.

Sample Code for the Sign Up api route

/page/api/auth/signup.js

This will overwrite the default signup api action that ships with NextAuthjs to allow custom behaviour. In the snippet below DbClient references a PrismaClient instance and the api checks that the req.method is a POST request and filter responses as JSON with the fields {name, username, email, password, confirm and a csrfToken} expected as the form input during signup. If no user account exists and some other basic checks performed then both user and a linked account will be created.


...

export default async function handler(req, res) {
  try {
    switch (req.method) {
      case "POST":
        const { name, username, email, password, confirm, csrfToken } =
          req.body

        if (
          !((name && username && email && password && confirm) &&
          password.length >= 1)
        ) {
          res.status(400).json({
            statusText: "Invalid user parameters",
          })
          break
        }

        if (password != confirm) {
          res.status(400).json({
            statusText: "Password mismatch",
          })
          break
        }

        const profileExists = await DbClient.user.findMany({
          where: {
            OR: [
              {
                username: username,
              },
              {
                email: email,
              },
            ],
          },
        })

        if (
          profileExists &&
          Array.isArray(profileExists) &&
          profileExists.length > 0
        ) {
          res.status(403).json({
            statusText: "User already exists",
          })
          break
        }

        const user = await DbClient.user.create({
          data: {
            name: name,
            email: email,
            username: username,
            password: await generate.hashBcrypt(password),
            status: "active",
          },
        });

        if (!user) {
          res.status(500).json({
            statusText: "Unable to create user account",
          })
        }

        const account = await DbClient.account.create({
          data: {
            userId: user.id,
            type: "credentials",
            provider: "credentials",
            providerAccountId: user.id,
          },
        })

        if (user && account) {
          res.status(200).json({
            id: user.id,
            name: user.name,
            email: user.email,
          });
        } else {
          res.status(500).json({
            statusText: "Unable to link account to created user profile",
          })
        }
        break
      default:
        res.setHeader("Allow", ["POST"]);
        res.status(405).json({
          statusText: `Method ${req.method} Not Allowed`,
        })
    }
    
    ...
    

To enable advanced initialization of NextAuth do the following in the [...nextauth].js file where options is the object containing the standard configuration options in the simple initialization.


import NextAuth from 'next-auth'

...

export default async function handler(req, res) {

...



    // Trigger the next-auth authentication flow at the end of the advanced initiatilization
    return await NextAuth(req, res, options)
}

Example of the token and date helper functions code to use in the cookie creation are below


...

const generate = {}
generate.uuid = function () {
    return uuidv4()
}

generate.uuidv4 = function () {
    return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
        (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
    )
}

// Modules needed to support key generation, token encryption, and HTTP cookie manipulation 
import { randomUUID } from 'crypto'
import Cookies from 'cookies'
import { encode, decode } from 'next-auth/jwt'


// Helper functions to generate unique keys and calculate the expiry dates for session cookies
const generateSessionToken = () => {
  // Use `randomUUID` if available. (Node 15.6++)
  return randomUUID?.() ?? generate.uuid()
}

const fromDate = (time, date = Date.now()) => {
  return new Date(date + time * 1000)
}

...

The signIn and JWT encode and decode are key for the whole workaround flow. Some sample code is below


...

const callbacks = {
        async signIn({ user, account, profile, email, credentials }) {
            // Check if this sign in callback is being called in the credentials authentication flow. If so, use the next-auth adapter to create a session entry in the database (SignIn is called after authorize so we can safely assume the user is valid and already authenticated).
            if (req.query.nextauth.includes('callback') && req.query.nextauth.includes('credentials') && req.method === 'POST') {
                if (user) {
                    const sessionToken = generateSessionToken()
                    const sessionExpiry = fromDate(session.maxAge)
                    
                    await adapter.createSession({
                        sessionToken: sessionToken,
                        userId: user.id,
                        expires: sessionExpiry
                    })

                    const cookies = new Cookies(req,res)

                    cookies.set('next-auth.session-token', sessionToken, {
                        expires: sessionExpiry
                    })
                }   
            }

            return true;
        },
        
        ...
}

...

 const options = {
 
 ...
 
     callbacks: callbacks,
     
 ...
}

...

    // Trigger the next-auth authentication flow at the end of the advanced initiatilization
    return await NextAuth(req, res, options)
}

Where adapter above is the adapter passed to the NextAuth function. For example, if you are using the PrismaAdapter you could have the something similar to the below where prisma is your database client instance.


const adapter = PrismaAdapter(prisma)

To overwrite the JWT encode and decode functions you must define the jwt option in the NextAuth options object.


 const options = {
        
        ...
        
        jwt: {
            ...
            
            // Customize the JWT encode and decode functions to overwrite the default behaviour of storing the JWT token in the session cookie when using credentials providers. Instead we will store the session token reference to the session in the database.
            encode: async (token, secret, maxAge) => {
                if (req.query.nextauth.includes('callback') && req.query.nextauth.includes('credentials') && req.method === 'POST') {
                    const cookies = new Cookies(req,res)

                    const cookie = cookies.get('next-auth.session-token')

                    if(cookie) return cookie; else return '';

                }
                // Revert to default behaviour when not in the credentials provider callback flow
                return encode(token, secret, maxAge)
            },
            decode: async (token, secret) => {
                if (req.query.nextauth.includes('callback') && req.query.nextauth.includes('credentials') && req.method === 'POST') {
                    return null
                }

                // Revert to default behaviour when not in the credentials provider callback flow
                return decode(token, secret)
            }
        },
        
        ...
}

Hopefully, the above saves anyone looking to use database strategy with credentials provider and next-auth some significant development time. Ideally, in future it would be better if the next-auth team accepts a feature request and pull in code into next-auth core that allows an option to be set to enable database sessions for this provider by simply having user define a custom function in the options similar to how they do with authorize for the credentials validation. It could be named sessionCreate or some other similar named callback.

However, until that happens you can utilize this workaround to get it done.