NextAuth.js JWT session with credentials provider for beginners

Our Blog

Lukáš Mikolaj01.02.202310 minutes

There are many options for Authentication in Nextjs like Supabase, Firebase, Userbase and much more. We will focus on NextAuth.js and our typescript implementation of JWT session between the existing Django backend and Next.js via the Credentials provider. We will try to focus on our use case to save time so we will omit all unused options and features.

Why NextAuth.js

This full-stack library helps you to integrate with every major OAuth provider and also with just email Authentication (which is recommended because of higher security). You can also create a custom OAuth or Credentials provider if it is needed. And that's what we love about this library, it provides you with common flows and default handlers but you can easily rewrite every step to match your needs.

Bare minimum configuration

After installing the package via command yarn add next-auth or if you prefer npm install next-auth, you must create configuration [...nextauth].ts file which will be located on API route /api/auth/[...nextauth].ts. That means all requests that will come to /api/auth/* will be handled by NextAuth.js. Inside this file, we will export the handler function, which will contain our configuration. You can find more details about configuration here.

// pages/api/auth/[...nextauth].ts export default async function auth(req: NextApiRequest, res: NextApiResponse) { return await NextAuth(req, res, { providers: [ ... ], session: { strategy: "jwt", }, cookies: cookies, callbacks: { ... }, });}

When you want to use a JWT session in general, you must set the session.strategy to "jwt" and specify the secret which will be used to encrypt tokens. We recommend you set the secret via the env variable NEXTAUTH_SECRET. Also, you will need the canonical URL of your site as NEXTAUTH_URL if you are not deploying to Vercel. In our example, we will use session-token, callback-url and csrf-token. They are used respectively to store the JWT token, default callback where you will be redirected after signIn/signOut and lastly CSRF token. To have our token available across all subdomains you must set the domain option for cookies to the valid domain, E.g. if your domains are account.example.com and example.com you must set the domain option to example.com.

// pages/api/auth/[...nextauth].ts const cookies: Partial<CookiesOptions> = { sessionToken: { name: `next-auth.session-token`, options: { httpOnly: true, sameSite: "none", path: "/", domain: process.env.NEXT_PUBLIC_DOMAIN, secure: true, }, }, callbackUrl: { name: `next-auth.callback-url`, options: { ... }, }, csrfToken: { name: "next-auth.csrf-token", options: { ... }, },};

Type safety

NextAuth.js provides built-in types, but as we need to store more information in our session object we must override types of Session, User and JWT objects. Read more about cookies and their option in docs.

// types/next-auth.d.ts declare module "next-auth" { /** * Returned by `useSession`, `getSession` and received as * a prop on the `SessionProvider` React Context */ interface Session { refreshTokenExpires?: number; accessTokenExpires?: string; refreshToken?: string; token?: string; error?: string; user?: User; } interface User { firstName?: string; lastName?: string; email?: string | null; id?: string; contactAddress?: { id?: string; }; } } declare module "next-auth/jwt" { /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */ interface JWT { refreshTokenExpires?: number; accessTokenExpires?: number; refreshToken?: string; token: string; exp?: number; iat?: number; jti?: string; } }

Auth flow

Credentials provider

We can finally add a credentials provider to our configuration. The name and id of the provider are required to distinguish between different providers. The credentials object represents fields of the sign-in form, this object structure will be passed as the first parameter of authorize function.

// pages/api/auth/[...nextauth].ts providers: [ Providers.Credentials({ name: 'credentials', id: 'credentials', credentials: { username: { }, password: { } }, async authorize(credentials, req) { // ...​ } })],

authorize function is responsible for fetching users from custom backend implementation that should provide us with JWT token.

// pages/api/auth/[...nextauth].ts async authorize(credentials) { const response = await fetch('...', { ... variables: { email: credentials?.email, password: credentials?.password, }, }); const data = await response.json(); if (response.ok && data?.token) { return data; } return Promise.reject(new Error(data?.errors));​​ };

Callbacks

The first callback in our flow is jwt. This callback is called whenever a JSON Web Token is created or accessed by the client. Here is the right place to implement token rotation. The aim of refreshAccessToken function is to use the refresh token stored in our token object and use it to acquire a new access token with an updated expiration time. Be aware that our backend is providing us with expiration time in seconds and output from Date.now() is in milliseconds, that's why we need to divide it by 1000.

// pages/api/auth/[...nextauth].ts export const jwt = async ({ token, user }: { token: JWT; user?: User }) => { // first call of jwt function just user object is provided if (user?.email) { return { ...token, ...user }; } // on subsequent calls, token is provided and we need to check if it's expired if (token?.accessTokenExpires) { if (Date.now() / 1000 < token?.accessTokenExpires) return { ...token, ...user }; } else if (token?.refreshToken) return refreshAccessToken(token); return { ...token, ...user }; };

The output of jwt is passed into session callback as a token. This is a good place for passing additional data into the session object. In our case, we will parse all data that the backend provides us inside the token and pass it as a user object for our web client. Also, we can check if the access and refresh tokens are expired and throw an error.

// pages/api/auth/[...nextauth].ts export const session = ({ session, token }: { session: Session; token: JWT }): Promise<Session> => { if (Date.now() / 1000 > token?.accessTokenExpires && token?.refreshTokenExpires && Date.now() / 1000 > token?.refreshTokenExpires) { return Promise.reject({ error: new Error("Refresh token has expired. Please log in again to get a new refresh token."), }); } const accessTokenData = JSON.parse(atob(token.token.split(".")?.at(1))); session.user = accessTokenData; token.accessTokenExpires = accessTokenData.exp; session.token = token?.token; return Promise.resolve(session); };

Here is a visualization of our authentication flow. Auth-flow {764x187}

Client side usage

As we mentioned before, we use a custom sign-in page so that after client-side validation we can call signIn. With redirects set to false, we can handle errors and success manually.

// pages/login.tsx signIn("credentials", { username: data?.username, password: data?.password, redirect: false, }).then((response) => { if (response?.error) { // show notification for user } else { // redirect to destination page } });

If we don't need to validate the response we can set the redirect to true and provide a callback URL so that after the user is logged out he will be redirected to specified page.

// pages/logout.tsx signOut({ redirect: true, callbackUrl: `${process?.env.NEXT_PUBLIC_LOGIN_PAGE}/login`, });

When you need to access session data or access a token in the client, you can use useSession() hook. In our case, we will get the Session type with our custom properties.

Middleware

If you are using Next.js 12 or newer you can use NextAuth.js in middleware. In basic usage, we can just export a matcher object with an array of path names which we want to secure.

// middleware.ts export { default } from "next-auth/middleware" export const config = { matcher: [ ... ] }

If you need some advanced logic you can use custom middleware implementation. We can access and decode token data in middleware via getToken(). For example, we can redirect the user if he doesn't have admin access.

// middleware.ts export async function middleware(request: NextRequest) { const token = await getToken({ req: request, secret: process?.env?.NEXTAUTH_SECRET, cookieName: ACCESS_TOKEN, // next-auth.session-token }); // redirect user without access to login if (token?.token && Date.now() / 1000 < token?.accessTokenExpires) { return NextResponse.redirect("/login"); } // redirect user without admin access to login if (!token?.isAdmin) { return NextResponse.redirect("/login"); } return NextResponse.next(); }

Subdomains setup

When you have just one domain, everything until now is valid for you. Congratulation, you're done. The rest have successfully finished implementation for a domain, that is responsible for authentication. At the time of writing this article, there is no official solution provided by NextAuth.js documentation. We can find here just that we need to set a custom cookie policy.

We need to share everything except the authorize function across all subdomains. It makes sense because we will never call signIn from subdomains.

// pages/api/auth/[...nextauth].ts export default NextAuth({ providers: [ CredentialsProvider({ name: "id", name: "credentials", credentials: {}, async authorize(_credentials, _req) { return null; }, }), ], session: { strategy: "jwt", }, cookies: cookies, callbacks: { session, jwt, }, });

So we need to share settings for cookies, session and jwt callback to access and refresh the token the same. That's why we recommend you move these functions into shared modules so you can access them from both sites. Don't forget to use same secret, because the decryption of the token will fail.

© 2023 Created by Remaster. All rights reserved.

Company ID: CZ10666648