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.
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.
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: { ... }, }, };
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; } }
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)); };
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.
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.
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(); }
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.