[How To] Authentication with IdentityServer4 and NextAuth v4 (Next.js App Router)

Gergo StarostaGergo Starosta
13 min read

Introduction

I recently worked on a project where I was responsible for the entire frontend, and authentication was handled through IdentityServer4. Initially, it was quite challenging because I had never integrated IdentityServer4 with the Next.js App Router before. In the past, I had used Next-Auth for a few projects with OAuth integrations (such as GitHub, Google, and credentials), and it worked well. Fortunately, Next-Auth also provides support for IdentityServer4 through a dedicated provider.

During the process of setting up authentication, I encountered several problems and issues. Finding up-to-date resources was difficult, and most blog posts only covered parts of the solution, with some information being inaccurate.

If you’re in a similar situation and want to learn how to seamlessly integrate authentication between the Next.js App Router and IdentityServer4, this guide is for you.

Prerequisites

Next.js

We will need a project with the version of Next 13.2 or above.

This is because Vercel introduced the App Router in Next 13. I was using Next 14 throughout the project, but it doesn't really matter. If you are new to Next 14 (latest stable version) I recommend checking one of my previous blog post, where I go through the features that Next 14 introduced.

IdentityServer4

IdentityServer is an authentication server that implements OpenID Connect (OIDC) and OAuth 2.0 standards for ASP.NET Core.

Our company had developed our own IdentityServer in the past couple of years, and many projects of ours still uses it. So for us, it was an easy choice as our backends are mainly written in .NET.

💡
IdentityServer4 has been deprecated and is superceded by Duende IdentityServer6.

Setup the environment variables

Just grab any existing Next project or create a new with:

npx create-next-app@latest

To make things work, we have to define our environment variables, without exposing them. Exactly for this purposes we need to create a .env.local file (if you want, you can use a .env file, note that in Next the .env.local will override the contents of the .env file). Both files should be in the root of your project at the same level as app/

These variables will hold the values of information that is needed to connect to the IdentityServer.

.env.local

NEXTAUTH_URL = "https://localhost:3000/api/auth"
IdentityServer4_CLIENT_ID = "your_client_id"
IdentityServer4_CLIENT_SECRET = "your_client_secret"
IdentityServer4_ISSUER = "your_identity_server_issuer"
IdentityServer4_SCOPE = "your_identity_server_scopes"
NEXTAUTH_SECRET ="your_unique_secret"

Let's talk through all of these.

First you have to define the NEXT_AUTH_URL. This will point next-auth where it should listen for every request (signin, signout etc.). The mentioned value is default and probably will work just fine for you.

📢
As you can see I wrote https:// instead of http:// although it is used in Next as default to expose your app. We will get back to this later

The following four variables should reflect your settings in your IdentityServer4. These will tell next-auth where to go, and will help you authenticate to the IdentityServer. These variables can be set at the dashboard or directly in the database of your IdentityServer. This step depends on the service's provider, so it may vary. In our case we had to run a few scripts, to populate the database with the correct data. This step is crucial, if there is a mismatch between the server and client config, the connection will not build out.

When setting up your IdentityServer, make sure that the callback/redirect URL is https://example.com/api/auth/callback/identity-server4

The last thing we have to define is the NEXT_AUTH_SECRET. This random value will help next-auth to encrypt tokens during the authentication process. You can use any online tool to generate this, or the following command line command:

openssl rand -base64 32

Changes to the package.json

IdentityServer4 requires every request to use SSL, so instead of the default http:// we need to expose our app as https://.

It can happen, that you can communicate with your IdentityServer using simple http, but it is not recommended for production environments. Without https you may run into the following error:

localhost sent an invalid response.
ERR_SSL_PROTOCOL_ERROR

To use https in our Next project, we have to modify one line at the scripts section in the package.json file:

package.json

"scripts": {
    - "dev": "next dev",
    + "dev": "next dev --experimental-https",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },

With this flag, we can run our app at https://localhost:3000. As you can see, this is still experimental and Next.js will warn you after every npm run dev with the following:

Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification.

You can read more about this here: https://vercel.com/guides/access-nextjs-localhost-https-certificate-self-signed

This small change will solve the problem and raise a new one, but no worries, we will fix it in a minute.

Self-signing certificates

We just setup our project to use https, instead of http, but by doing so we may face a new error:

https://next-auth.js.org/errors#signin_oauth_error self-signed certificate {
  error: {
    message: 'self-signed certificate',
    stack: 'Error: self-signed certificate\n' +
      '    at TLSSocket.onConnectSecure (node:_tls_wrap:1685:34)\n' +
      '    at TLSSocket.emit (node:events:519:28)\n' +
      '    at TLSSocket._finishInit (node:_tls_wrap:1085:8)\n' +
      '    at ssl.onhandshakedone (node:_tls_wrap:871:12)\n' +
      '    at TLSWrap.callbackTrampoline (node:internal/async_hooks:130:17)',
    name: 'Error'
  },
  providerId: 'identity-server4',
  message: 'self-signed certificate'
}

This is because our certificate is self-signed. After running the modified npm run dev script, you may notice that a new folder is created in the root of the project called certificates. This folder will contain the localhost-key.pem and localhost.pem files. These keys are automatically generated Next.js and will be used in local development.

To surpess this error in development, we need to add one more line to our freshly created .env.local file:

.env.local

NEXTAUTH_URL = https://localhost:3000/api/auth
IdentityServer4_CLIENT_ID = your_identity_server_client_id
IdentityServer4_CLIENT_SECRET = your_identity_server_client_secret
IdentityServer4_ISSUER = your_identity_server_issuer
IdentityServer4_SCOPE = "your_identity_server_scopes"
NEXTAUTH_SECRET ="your_unique_secret"

+ NODE_TLS_REJECT_UNAUTHORIZED = 0

In production if you are using Vercel or any other hosting service, they will handle the signing for you automatically. Self-signing certificates is not recommended in production, it leaves you with many security vulnerabilities.

Let's setup next-auth

NextAuth is an open-source library for implementing authentication in Next.js projects. They provide easy to setup solutions for integrating the credentials provider (email + password) and OAuth. They have gone through a small "rebranding" and restructuring, so next-auth v5 is now called Auth.js. We will use next-auth, version 4 to be precise.

The NextAuth documentation can be found here: https://next-auth.js.org/. While the new Auth.js docs are here: https://authjs.dev/.

To setup next-auth, first we need to download the required dependencies. Just run the following command in your terminal:

npm install next-auth

The next step is to integrate the SessionProvider so we can use the things NextAuth provides throughout the app.

When using the app router in Next, there is a app/layout.tsx file that defines the RootLayout of the project. This is a server-component, therefore we can't use the provided SessionProvider here directly, because it uses React.Context under the hood.

Because of how Server Components work, we can nest Client Components in them, but not the other way around.

📢
If this Server / Client component thing is all new to you, I highly recommend you to check out this video: https://youtu.be/VIwWgV3Lc6s?si=2T8E7R1aHM5aad9h by Theo - t3.gg. He will explain everything you need to know.

So in order to use the SessionProvider, we have to create a custom provider, which is a Client component. You can create this file anywhere you want, the personal preference of mine is to put these type of things in the lib folder, in the root of the project.

lib/Providers.tsx

'use client';

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


export default function NextAuthProvider({
    children,
}: {
    children: ReactNode;
}) {
    return (
        <SessionProvider>
            {children}
        </SessionProvider>
    );
}

This won't do much, just wrap the NextAuth SessionProvider in a new client component.

Now we can use this in our RootLayout:

app/layout.tsx


import "./globals.css";
import type { Metadata } from "next";
+ import NextAuthProvider from "@/lib/Providers";

export const metadata: Metadata = {
  title: "How to setup IdentityServer4 with Next app router using next-auth",
  description: "If this blog post was helpful, please leave a like.",
};

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {

  return (
    <html lang="en">
      <body>
        + <NextAuthProvider>
            {children}
        + </NextAuthProvider>
      </body>
    </html>
  );
}

By doing this, we will access every feature of next-auth in every segment of our app. Obviously this file may vary and have more content, I just cleared it for simplicity.


Next we can define our auth configurations. To do that, create a file to store the config. I named it authOptions.ts and put it in the lib/ folder. (You can name and put this file anywhere you want, it is up to you)

lib/authOptions.ts


import IdentityServer4Provider from 'next-auth/providers/identity-server4';
import { NextAuthOptions } from 'next-auth';

export const authOptions: NextAuthOptions = {

  secret: process.env.NEXTAUTH_SECRET,
  providers: [
    IdentityServer4Provider({
      id: 'identity-server4', // keep it as is, not placeholder value
      name: 'IdentityServer4', // keep it as is, not placeholder value
      issuer: process.env.IdentityServer4_ISSUER,
      clientId: process.env.IdentityServer4_CLIENT_ID,
      clientSecret: process.env.IdentityServer4_CLIENT_SECRET,
      authorization: { params: { scope: process.env.IdentityServer4_SCOPE } },
      wellKnown: `${process.env.IdentityServer4_ISSUER}/.well-known/openid-configuration`,
    }),
  ],

  callbacks: {
  // You can modify any callback of next-auth here, like jwt, session etc..
  },

  pages: {
    signIn: '/login',
  },
  session: { strategy: 'jwt' },
};

Basically we give the information that is needed to connect to the IdentityServer as parameters.

NextAuth should recognize and find the wellKnown (aka. discovery document), but for some reason it didn’t work for me, so provided it manually.

In the providers array, we can define many different providers such as Apple, Twitter and so on. We will only setup IdentityServer4 here. To learn more about the different providers, visit https://next-auth.js.org/providers/.

In the callback object you can override built-in callbacks such as jwt, session etc.. This is quite helpful if we want to destructure or modify the values stored in them. To learn more about callbacks visit https://next-auth.js.org/configuration/callbacks.

Also we add the secret, the session strategy and the signin page's route. More on that later.

The last thing we have to do is to create a route.ts file in the app/api/auth/[...nextauth] folder. This is crucial, following this is mandatory.

app/api/auth/[...nextauth]/route.ts


import NextAuth from 'next-auth';  
import { authOptions } from '@/lib/authOptions';  

const handler = NextAuth(authOptions);  
export { handler as GET, handler as POST };

There is not much happening here, we expose the Route handler with the configuration we previously created. All requests to /api/auth/* (signIn, callback, signOut, etc.) will automatically be handled by NextAuth.js.


And now the setup of next-auth is done. But to try it out and create a "prod-like" experience we will do the following:

  • We will create a login page. This is not rocket science, just let the user arrive at the gate of the app. There will be only one button here, that will fire the sign-in and if the IdentityServer4 is setup well, this will redirect the user there.

  • We will create a sign-out button.

  • We will create a middleware, to make the whole experience smooth and robust.

    • If the user is not authenticated, can not access any route in the app except /login.

    • If the user is authenticated by the IdentityServer, we will open the gates and the user can access the app.

    • While being signed in, the user can’t go back to the login page.

    • If there is a sign out, we will redirect the user to the login page.

Enough talking, get back to code.

Creating a production like experience

To achieve the previously described functionality, first we have to code our login page. We can do this by creating a new folder in the app/ directory called login. In this folder create the following page.tsx:

app/login/page.tsx


import { getServerSession } from 'next-auth/next'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/authOptions'

export default async function LoginPage() {

    const session = await getServerSession(authOptions)

    if (session) {
        redirect('/')  
    }

    return (
        <div className="w-full h-full flex items-center justify-center">
           <LoginButton />
        </div>
    )
}

The first impression of you could be that this design is dog-water, and you are right. I just try to get rid of every single distraction and keep it simple.

In this file we get the session with the getServerSession function. But why Server session? Great question! You may noticed that this page is an async function. We can only define async functional components if we are in a Server Component. In Next app router, by default every component is a server component, so we don't have to write use "use server"; at the top of the file. Until we want to add interactivity to a component, we can keep it as "use server"; If we want to add interactivity (buttons for example) we need to switch to the "use client"; directive manually, otherwise we will run into an error.

Back to the explanation. We need to pass the auth options to the getServerSession function and if the user is authenticated (it is a not null nor an empty session) we will redirect the user to the app.

This page will have only one button, but to get the session on the server and have the interactivity of a button, we have to create a new Client component, called LoginButton.tsx:

components/LoginButton.tsx


'use client' // we have interactivity (the user can click the button)

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


export default function LoginButton() {

    return (
        <button onClick={() => signIn('identity-server4')}>
            Sign in with IdentityServer4
        </button>
    )
}
💡
It is a good practice to keep as much as we can on the server side of our authentication flow, simply because of security purposes. This is why we outsource the LoginButton to a new file, although the component is small and doesn't do a lot.

With this button placed in the Login page, we are almost there. Please style all the pages and components, it breaks my heart to not style all these things here.

Next up is to create our middleware to handle the redirection of users depending on there sessions existence. With a middleware we can protect our app from unauthenticated users. Create a middleware.ts file in the root of your project:

middleware.ts

import { withAuth } from 'next-auth/middleware'
import { NextResponse } from 'next/server'


export default withAuth(
 function middleware(req) {

    const isAuth = !!req.nextauth.token
    const isAuthPage = req.nextUrl.pathname.startsWith('/login')

    if (isAuthPage) {
      if (isAuth) {
        return NextResponse.redirect(new URL('/', req.url))
      }

      return null
    }

    if (!isAuth) {
      return NextResponse.redirect(new URL('/login', req.url))
    }
  },
  {
    callbacks: {
      authorized: () => true
    },
  }
)

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

This middleware uses the provided next-auth withAuth middleware. If the user in on the /login page and authenticated, we will redirect to /. If the user is not authenticated, we will redirect to the /login page. By doing this, unauthenticated users won't be able to access the app.

One thing that was a bit odd when I first used a middleware, was the matcher. The middleware will run on every single route by default, but we can exclude routes using the matcher attribute in the config object. We will be just fine with this matcher for now (and for most Next.js projects), you can read more about the withAuth middleware here: https://next-auth.js.org/configuration/nextjs#middleware.

The last thing we need to do, is to create a sign out button. Good news is that, this will be the easiest part of the whole setup. All we need is to create a simple handleSignOut function and call it on any buttons onClick event. Something like this:

'use client';

const YourComponent = () => {

  const handleSignOut = async () => {
    await signOut({ redirect: false });
    router.push('/login');
  };

  return (
      ...
     <button onClick={handleSignOut}>Log out</button>
     ...
  );

}

Aaand that's it. 🎉 We are done configuring authentication using Next.js (next-auth) and IdentityServer4.

Call to action

Thank you for reading all the way down here! If you have any questions or found a bug, please feel free to reach out at any given platform.

If you found this post helpful, please consider a like and/or a follow.

Cheers! 👋👋

0
Subscribe to my newsletter

Read articles from Gergo Starosta directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Gergo Starosta
Gergo Starosta

Hey! I am Gergo, a CS Student and a Frontend Engineer from Budapest, Hungary. I am writing about everything that excites me in the world of Frontend, AI, Career and so on..