A.

Blog

 
 

Password protecting routes in Next.js App Router

Feb 12, 2024

As I was building out this site, I wanted to add password protection to my portfolio pages as some projects contain sensitive client work that I’d prefer not to be immediately available to just anyone. I looked around and didn’t find any solution that immediately stood out to me and then I thought to myself, “Hey, this might be a chance to learn more about auth and try out NextAuth.js!”. So that’s what I did.

In this post, I’ll go through how to add simple password protection to routes in Next.js using App Router and NextAuth.js (aka Auth.js). If you’d like to skip ahead and see the final results, you can check out the example repository on GitHub or play around with it in your browser on CodeSandbox.

Requirements

These were my requirements for this project:

  • Users should be prompted to enter a password when accessing a protected route
  • This password should be shared across users (i.e., every user should use the same password)
  • If a user entered the password for one protected route, they should be able to access another protected route without having to enter the password again
  • No database!
  • Leverage server components as much as possible

Initial setup

In your Next project, we’ll first install NextAuth (assuming you use npm):

npm install next-auth

Then, we’ll set up the required API routes that NextAuth needs to perform certain functions (such as sign in). We’ll use App Router’s Route Handlers to do this. Create the following route at /src/app/api/auth/[...nextauth]/route.ts:

import NextAuth from "next-auth";

const handler = NextAuth({
...
});

export { handler as GET, handler as POST };

Now we’re all setup. Next up, we’ll go ahead and configure NextAuth to authenticate using a shared password.

Configuring NextAuth

NextAuth ships with a bunch of providers which are basically different methods for hooking into NextAuth to authenticate. For example, they have a OAuth providers for popular services like Google and GitHub.

For our use case, we actually want to use the credentials provider which allows us to authenticate using arbitrary credentials.

We’re going to put our NextAuth configuration in a separate file because we’ll need to reference it when we actually go to protect our pages later. We’ll define the config in src/auth.ts:

import { type NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: 'password',
credentials: {
password: {
label: 'Password',
type: 'password',
},
},
async authorize(credentials, _request) {
if (credentials?.password == 'secret') {
return { id: '0' };
} else {
return null;
}
},
}),
],
session: {
strategy: 'jwt',
},
secret: 'thisisasecret',
};

What we’re doing here is setting up our provider in providers with CredentialsProvider from next-auth/providers/credentials and defining the following:

  • name — The text to display on the button on the default sign in page
  • credentials — The fields that will show up on the sign in page
  • authorize — A function to handle how to actually authorize users when they sign in

The authorize() function accepts two parameters credentials and request. In our case, we only need to use the credentials parameter which consists of an object of the fields that we defined in the provider config above. We defined password so we can access this with credentials?.password. For now, we’ll hard code our password as secret in this check.

NextAuth requires that the authorize() function return either a user object, false, or null with the later two representing a failed authentication (i.e., the wrong password being entered). In our check, we return { id: "0" } as a hard coded value to represent a user to NextAuth. Since all users will use the same password, this is fine.

We’re also going to specify the session strategy to use JWT (JSON Web Tokens) for session storage (so we don’t need a database!). This is how we’ll persist that a user has already been authenticated across protected routes.

Since we’re using JWTs, we also need to set a secret. For now, we’ll also hard code this to thisisasecret.

Then we’ll use our config back in our route handler in/src/app/api/auth/[...nextauth]/route.ts:

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

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

And with that, we’ve finished configuring NextAuth! If you head over to /api/auth/signin in your browser, you should see a sign in page with a password field that’s automatically generated by NextAuth. We’ll go over how to create our own custom sign in page, but next, we’ll create some protected routes to test everything out.

Protecting routes

There are two ways to protect routes: on the route itself or with middleware. Let’s go over how to do things on the route itself first.

Server components

We’ll create a server component route that we want to protect at src/app/protected/page.tsx:

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

export default async function Protected() {
const session = await getServerSession(authOptions);

if (!session) {
redirect('/api/auth/signin?callbackUrl=/protected');
}

return (
<main>
<h1>🔒 Protected page</h1>
<p>This page is password protected!</p>
</main>
);
}

We’re using getServerSession() and passing in our NextAuth config from before to get the user’s session. If there is a valid session, it will return the session object otherwise it will return null.

Then, we check for the valid session and redirect the user to the sign in page if not. This is done with the redirect() function provided by next/navigation.

Note that we’re also passing a callbackUrl as a URL parameter in the redirect. This tells NextAuth to send the user back to the callbackUrl once they’ve successfully authenticated. In this case, we want to send them back to this protected route so we’re passing in the route’s path (/protected).

And now we have our protected route. If you visit /protected in your browser, you should be redirected to the sign in page. After signing in, you should be able to view the page!

Client components

But what if you need to do this with a client component? Well, NextAuth also ships with methods for validating sessions on the client. Let’s create a separate route to demonstrate this. First, let’s create a client component layout at src/app/client-protected/layout.tsx:

'use client';

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

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

We’re wrapping our layout in SessionProvider from next-auth/react so we can use the useSession() hook in our client page component to validate the session.

Then, we can create our client component route at src/app/client-protected/page.tsx:

'use client';

import { useSession } from 'next-auth/react';

export default function Protected() {
const { status } = useSession({ required: true });

if (status == 'loading') {
return 'Loading...';
}

return (
<main>
<h1>🔒 Protected page</h1>
<p>This page is password protected!</p>
</main>
);
}

We’re using the useSession() hook to get the status of the the validation request. The hook accepts a required boolean parameter which defines if the the user must be authenticated or not to render the component. If the user is not authenticated, then it will automatically redirect the user to the sign in page.

We look at status to determine if the request has been validated yet. If not, we show a loading state. Once the request has been validated, we render the component.

Using middleware

Now let’s talk about using middleware to protect our routes. Middleware allows us to execute some code before a request is completed (i.e., it lets us intercept requests). So what we can do is check for a valid session when a user attempts to request a protected route. This is doubly great because it applies to both server and client components!

Middleware in Next is defined at src/middleware.ts:

import { withAuth } from 'next-auth/middleware';

export default withAuth({
secret: 'thisisasecret',
});

export const config = { matcher: ['/middleware-protected/:path*'] };

In our middleware, we are using the withAuth() middleware provided by NextAuth. We’re hardcoding our secret here again for simplicity.

Then, we define a matcher to match requests that I want to protect. For this example, we’re going to protect any path that begins with /middleware-protected so the pattern we use is /middleware-protected/:path* .

Now we’ll create our middleware protected route at src/app/middleware-protected/page.tsx:

export default function Protected() {
return (
<main>
<h1>🔒 Protected page</h1>
<p>This page is password protected with middleware!</p>
</main>
);
}

Note that we don’t need to use getServerSession() here like before because all of the session validation is done with our middleware. Now if you visit /middleware-protected you should be redirected to the sign in page. Once you’ve signed in, you should be redirected back to /middleware-protected as NextAuth is handling the callbackUrl for us automatically.

Since we’ve defined the middleware matcher as /middleware-protected/:path*, any route that you create under /middleware-protected will also be a protected route. Using middleware can make things a lot easier!

Creating a custom sign in page

NextAuth automatically creates a sign in page when you configure your providers. This is really great because it gets you up and running quickly, but there are very few levers for customization.

Luckily, creating your own sign in page is a breeze. First, we’ll create the sign in page itself at src/app/sign-in/page.tsx:

import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/auth';

export default async function SignIn() {
const session = await getServerSession(authOptions);
const cookieStore = cookies();

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

const csrfTokenCookie = `${
process.env.NODE_ENV == 'production' ? '__Host-' : ''
}next-auth.csrf-token`;
const csrfToken = cookieStore.get(csrfTokenCookie)?.value.split('|')[0];

return (
<form method="post" action="/api/auth/callback/credentials">
<input name="csrfToken" type="hidden" defaultValue={csrfToken} />
<label>
Password
<input name="password" type="password" />
</label>
<button type="submit">Sign in</button>
</form>
);
}

We’re using getServerSession() here again to check if we already have a valid session. If we do, we redirect the user to the homepage (duh, because they’re already authenticated!).

We need to get the CSRF token from NextAuth to actually send our authentication request to the API routes. Unfortunately, NextAuth doesn’t have a really good way to do this for server components (yet as of v4). Ideally you would either do this with getCsrfToken() or call signIn() via a server action, but those don’t work. 😢 getCsrfToken() returns a different value each time and signIn() can’t be used in server components.

To get around this, we’re going to grab the token from the user’s cookies because NextAuth drops a cookie with the CSRF token when we try to validate a session (which happens to occur whenever a user visits a protected route!).

We’ll use the cookies() function from next/headers to retrieve the next-auth.csrf-token cookie. We actually need to parse this value because it comes back to us in a abc123|abc123 format.

Then, we can render our form. The form action should point to /api/auth/callback/credentials which is the API route that is exposed from the credentials provider. We need to send our CSRF token so we’ll use a hidden input with the name csrfToken. We also need to send over the password so we’ll add a password input with the name password. And to close it all out, we’ll add a button to submit the form.

Lastly, we’ll want to set our custom sign in page as the “default” so any requests that need to redirect a user to a sign in page does so to our custom page. In our NextAuth config in src/auth.ts , we just need to add a pages configuration to point to our new /sign-in route:

pages: {
signIn: "/sign-in",
}

And there you have it, a custom sign in page. The old page that NextAuth generated for us will no longer be accessible and navigating to /api/auth/signin will redirect users to /sign-in instead.

Moving secrets

Remember how we hard coded our password and the JWT secret? 😬 Well, let’s fix that. First, let’s create an .env file to store our secrets as environment variables at the root of our project in a new file called .env.development.local:

NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=replace_with_your_secret
NEXTAUTH_PASSWORD=replace_with_your_password

We’re defining three environment variables:

  • NEXTAUTH_URL — The canonical URL for your project
  • NEXTAUTH_SECRET — A string to hash tokens, sign cookies, and generate keys. You can generate a reasonably strong secret by running openssl rand -base64 32.
  • NEXTAUTH_PASSWORD — The password we want our users to use to authenticate

These environment variables will be used when you run your project in development mode. If you’re deploying your project to production you can use a separate file named .env.local or .env.production.local with entirely different values.

Let’s use these environment variables in place of our hardcoded values. In src/auth.ts , we want to modify the authorize() function to:

async authorize(credentials, _request) {
if (credentials?.password == process.env.NEXTAUTH_PASSWORD) {
return { id: "0" };
} else {
return null;
}
}

We can also delete the secret parameter we defined here earlier because NextAuth will automatically use NEXTAUTH_SECRET if it’s defined.

Then in src/middleware.ts, we can also replace our hardcoded secret with:

export default withAuth({
secret: process.env.NEXTAUTH_SECRET,
});

And with that, we’re now keeping our secrets… secret! 🤫

Wrapping it up

To recap, we learned how to setup and configure NextAuth with the credentials provider, protect routes with getServerSession() for server components and useSession() for client components, protect routes with middleware, create a custom sign in page, and protect our sensitive secrets!

I had a nice time learning more about auth, but I felt that NextAuth is a bit rough around the edges. There were a lot of “gotchas” that aren’t clear and the docs aren’t always the best place to find information. I’m hoping that once they transition to Auth.js and v5, things will get much better. Eventually, I’ll find some time to migrate away from NextAuth into a homegrown solution.

Check out the GitHub repository for the full source code or head over to CodeSandbox to play around with the example project in your browser.

I’d also recommend watching this YouTube video from Lee Robinson, VP of Product at Vercel, for some additional insights on auth and Next!