Secure User Authentication with Next.js, NextAuth.js, and AWS Cognito

Eduard Schwarzkopf
7. March 2023
Reading time: 9 min
Secure User Authentication with Next.js, NextAuth.js, and AWS Cognito

Introduction

For one of our projects, we have decided to use Next.js 13. As a backend developer, you can imagine how excited I am to work on this frontend. After reading through the docs, I decided to get started with a tutorial on YouTube.

From the NextJS tutorials that I watched, I got two takeaways:

  • All tutorials that don’t cover NextJS 13 are outdated and cannot be used.
  • All of the tutorials I watched were just video guidelines for the documentation.

Great. So here I am, with no tutorial on how to set up NextJS 13 with AWS Cognito. Which means:

 width=

Overview

Here is a diagram of what we’re going to build:

 width=

Note that the goal of this tutorial is not to give you a fully-fledged full-stack app, but rather a baseline to help you get started with an app that has secure authentication in place.

As you can see from the diagram, we’re going to use the following tools:

  1. NextJS 13 (experimental app directory)
  2. Next Auth
  3. AWS Cognito
  4. AWS SES

Since you’re here because you explicitly searched for this setup, I won’t explain what those services are.

You can find the repository here.

So without further ado, let’s get this setup started!

Disclaimer: Some frontend might got hurt during the process. I’m sorry.
 width=

Actually not.

Setting Up AWS

You probably want to send out confirmation e-mails to your users. For this, you’ve got two options. Cognito itself or SES. The difference between these two options is the state of your application. If you’re just testing or still developing. Skip the SES setup. Just pick Send email with Cognito in Step 4 of setting up Cognito and you are good to go. For a production environment, use SES. It’s not rocket science.

Setup SES

Login into your AWS Account and head over to Amazon SES.

On the left side, go to Verified identities and create a new identity.

 width=

Pick E-Mail and use an address that you’d want to use for authentication related stuff.

 width=

After you’ve created the identity, you’ll receive an e-mail from AWS to verify this address. Do it. Then you should see a nice and green checkmark. This means, you are good to go, using that address with Cognito.

Speaking of Cognito.

Setup Cognito

Now it is time to actually create the userpool. Head over to Cognito.

Click on Create user pool. Configure your user pool, pretty much, how you need it. For the sake of simplicity, I’m going to use the bare minimum.

If you are using SES, watch out in step 4. Learn from my mistakes! Leave the FROM sender name field empty, or use a validated e-mail address from SES. Otherwise, you’ll run into issues, where Cognito won’t be able to send out e-mails.

 width=

 width=

 width=

 width=

 width=

Perfect! A User pool is ready to receive users. Time to get into the fun part. The frontend. *sigh*

Integrating Next.js with NextAuth.js

Let’s get this over with as quickly as possible so I can go back into my world and make queries 0.1ms faster.

If you already have a Next.js app running, skip this. I’m going to start from scratch here:

npx create-next-app@latest –experimental-app

$ npx create-next-app@latest –experimental-app
✔ What is your project named? … next-with-cognito
✔ Would you like to use TypeScript with this project? … No / Yes
✔ Would you like to use ESLint with this project? … No / Yes
✔ Would you like to use `src/` directory with this project? … No / Yes
✔ What import alias would you like configured? … @/*

The next step is to install all packages with npm install. As soon as this is done, start the app with npm run dev.

When visiting http://localhost:3000, we are greeted with a fancy startup page. Good, good. Now we can start working.

Setting up NextAuth.js

It’s time to bring in the next player into this game: NextAuth.js. Just install it with npm install next-auth.

While this is being installed, go ahead and create a folder named /auth inside pages/api. Inside the auth folder, create a file with the following name: […nextauth].ts (yes, square brackets!). Your path to that file should now look like this: pages/api/auth/[…nextauth].ts

Open that file and place the following content inside:

import NextAuth from "next-auth";
import CognitoProvider from "next-auth/providers/cognito";
import { NextAuthOptions } from "next-auth";

export const authOptions: NextAuthOptions = {
  // Configure one or more authentication providers
  providers: [
    CognitoProvider({
      clientId: process.env.COGNITO_CLIENT_ID,
      clientSecret: process.env.COGNITO_CLIENT_SECRET,
      issuer: process.env.COGNITO_ISSUER,
    }),
  ],
};

export default NextAuth(authOptions);

Great, now TypeScript is complaining: Type ‘undefined’ is not assignable to type ‘string’. for clientId and clientSecret. Let’s fix that.

Create a file named process.d.ts in the root folder of this project. Paste this code inside:

declare namespace NodeJS {
  export interface ProcessEnv {
    COGNITO_CLIENT_ID: string
    COGNITO_CLIENT_SECRET: string
  }
}

This will tell TypeScript that our environment variables for COGNITO_CLIENT_ID and COGNITO_CLIENT_SECRETwill never be undefined. Speaking of which, it’s time to create them.

In the root folder, create a file named .env.local. Inside that file, you need to define the necessary variables:

COGNITO_CLIENT_ID = YOUR_CLIENT_ID
COGNITO_CLIENT_SECRET = YOUR_CLIENT_SECRET
COGNITO_ISSUER = https://cognito-idp.<REGION>.amazonaws.com/<USER_POOL_ID>

Now, you might wonder, “Where the heck do I get these values?”. Don’t worry, I’ll show you:

First, go back to AWS -> Cognito and select your user pool.

Here, you can directly find your User pool ID. Simply copy it and place it inside the URL. Do the same for the region where your user pool resides.

 width=

Next, let’s go for the COGNITO_CLIENT_ID and COGNITO_CLIENT_SECRET.

Click on the App integration tab and scroll to the bottom. You will see the App client list. Great! Here, you should find your app client that was auto-created when you created the user pool.

Click on it. This is the place where you want to be:

 width=

Now, simply copy your Client ID and Client Secret into your .env.local file.

That’s it! You are all set and ready to go!

 width=

I guess I also need to show you how to do the rest, right? Okay, fine. Let’s create a small demo with authentication and protect a view.

Implementing User Authentication

This section is extra, so enjoy it! First, create a new file inside app called providers.tsx and put the following code inside:

"use client";

import { SessionProvider } from "next-auth/react";

export default function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}

Since Next.js 13 uses server components as the default, you need to create this component as a client, so the SessionProvider can be used. Obvious, right? It surely was not for me. Anyway…

Next up, it’s time to use that SessionProvider in the root layout under app/layout.tsx:

import "./globals.css";
import Providers from "./providers";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      {/*
        <head /> will contain the components returned by the nearest parent
        head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head
      */}
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

With that in place, you can now make use of the useSession in any component that uses your root layout. Need me to show you? Why the heck not.

Create a new file under components/LoginButton.tsx and paste the following code in it:

"use client";
import { useSession, signIn, signOut } from "next-auth/react";

export default function Component() {
  const { data: session } = useSession();
  if (session && session.user) {
    return (
      <>
        Signed in as {session.user.email} <br />
        <button onClick={() => signOut()}>Sign out</button>
      </>
    );
  }
  return (
    <>
      Not signed in <br />
      <button onClick={() => signIn()}>Sign in</button>
    </>
  );
}

This is a component I proudly stole from the NextAuth.js docs. The only thing I’ve added was the “use client”; line so it can use the onClick event here.

As you can see, this will use the useSession hook to check for a session and render a sign-in or sign-out button if there’s a user inside the session object. Cool! Now, let’s make use of it.

Go over to app/page.tsx and add the following code:

...
import LoginButton from "@/components/LoginButton";

export default function Home() {
  return (
    <main className={styles.main}>
      <div style={{ border: "2px solid red", padding: "10px" }}>
        <LoginButton />
      </div>

      ...

With that highly crafted code, you can see a button that will render a Sign in or Sign out, depending on whether the user is signed in or not.

Just click “sign in”, “register”, and see your email address showing up.

That’s all. Use the useSession hook in every layout you like and show the desired content accordingly. Your imagination is the limit!

Conclusion

As a backend developer, I was excited ( ) to work on a frontend project using Next.js 13. Realizing that most of the tutorials were outdated and useless for my situation, I had to do it independently. I documented the process in this article, so you don’t have to go through the same struggle as I did.

The next step is up to you now. If you want to have an extra task: Count every next in this article and let me know on Twitter, how many you counted. (Ctrl+F is cheating!)