A.

Blog

Revisting password protecting routes in Next.js

Oct 31, 2024

Back in February, I wrote up a post on how to password protect routes in Next.js with NextAuth (now Auth.js). While everything worked with the implementation I ended up with, something about the it felt off to me. Using Auth.js felt like such overkill given the complexity and size of the library relative to the scope of the use case.

I set out to look for a more simplified solution and ended up discovering iron-session, a small stateless session library for JavaScript. If you’re familiar with JWTs, then what iron-session brings to the table is not so different. It essentially stores session data in encrypted cookies, which can then be decoded on the server. It’s all stateless meaning that the state of the authorization is held with the session cookie itself.

If you’d like to skip ahead and see a full working example, check out the repository on GitHub or play around with it live on CodeSandbox.

Requirements

I mainly wanted to update the underlying implementation so I left the requirements the same:

  • 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

For this particular example, I’ll be using Next.js 15.

Initial setup

It’s important to note that iron-session is much slimmer than Auth.js. It doesn’t come with many bells and whistles; mainly because it doesn’t have to. Its API consists of only seven methods and for this particular use case, we’ll only be using two of them.

To being our setup, we’ll first install iron-session in our Next.js project:

npm install iron-session

Next, we’ll set up some small utilities for use to handle our session data more easily. I created an auth.ts file in src/utils, but you can put this wherever you want.

import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";

export interface SessionData {
isAuthenticated: boolean;
}

export const sessionOptions: SessionOptions = {
password: process.env.IRON_SESSION_SECRET!,
cookieName: "_auth",
cookieOptions: {
secure: process.env.NODE_ENV == "production",
},
};

export async function getSession() {
const cookieStore = await cookies();
return await getIronSession<SessionData>(cookieStore, sessionOptions);
}

Let’s walk through what we’re doing in this file. First, we’re defining SessionData which is a TypeScript interface for what our session data looks like (i.e., what we’ll be storing in our encrypted cookie). Since we only care if the user is authenticated or not, the shape of our session cookie is rather simple and consists of just one boolean property: isAuthenticated.

Then, we’re defining our sessionOptions which is the session configuration that we’ll pass to iron-session. This consists of:

  • password – The private key we’ll use to encrypt our session cookie. This key must be at least 32 characters long. We’re referencing this via an environment variable which we’ll specify in a bit.
  • cookieName – The name of the cookie to use
  • cookieOptions – Some additional cookie configuration. We’re setting cookieOptions.secure to be conditional based on if we’re in production or not (assuming production is an HTTPS environment).

Finally, we’re defining getSession which is a function to get the contents of the session cookie. We’ll use this repeatedly in the various ways below to protect a given route.

Environment variables

Now that we have iron-session installed and set up, we’ll also need to specify some environment variables which will house our private key and the actual password we want to use.

Create a .env.local file at the root of your Next.js project with the following:

IRON_SESSION_SECRET="SOME_32_CHARACTER_SECRET"
IRON_SESSION_PASSWORD="YOUR_PASSWORD"

Sign in

Now we actually start getting into the password protection experience. First up is the sign in process. For this example, we’ll leverage a sign in page at /sign-in that is a server component powered by a server action.

Create a new file at src/app/sign-in/actions.ts:

import { getSession } from "@/utils/auth";
import { redirect } from "next/navigation";

export async function auth(formData: FormData) {
"use server";

const session = await getSession();
const shouldAuthenticate =
formData.get("password") == process.env.IRON_SESSION_PASSWORD;
const redirectPath = (formData.get("redirect") as string) || "/";

session.isAuthenticated = shouldAuthenticate;

await session.save();

if (!shouldAuthenticate) {
redirect(`/sign-in?redirect=${encodeURIComponent(redirectPath)}`);
}

redirect(redirectPath.at(0) == "/" ? redirectPath : "/");
}

We’ve defined a function called auth which will take in two pieces of form data:

  • password – The password the user is attempted to authenticate with
  • redirect – The page that we should redirect the user to if they successfully authenticate

In the auth function, we’re checking if the password the user submitted matches what we’ve defined in our environment variable. If it does, then set the isAuthenticated property in our session cookie to true and redirect the user to the specified redirect path.

Now let’s hook this server action to some UI. Create a new file at src/app/sign-in/page.tsx:

import { getSession } from "@/utils/auth";
import { redirect } from "next/navigation";
import { auth } from "./actions";

interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function SignIn(props: Props) {
const searchParams = await props.searchParams;
const session = await getSession();

if (session.isAuthenticated) {
redirect("/");
}

return (
<form action={auth}>
<input
name="redirect"
type="hidden"
defaultValue={searchParams.redirect}
/>
<label>
Password
<input name="password" type="password" required autoFocus />
</label>
<button type="submit">Sign in</button>
</form>
);
}

On our sign in page, we first check if the user is already authenticated. If they are, then we’ll redirect them to the root page.

We then have a form which submits to our auth server action that we created earlier. In the form, we have two fields: a hidden field to store the redirect path and a password field to allow users to input the password.

Protecting routes

We’ll first go over how to protect routes at each individual route for both server and client components.

Server components

Server components are actually quite easy because we can re-use the plumbing we’ve setup already. Let’s create a protected server component at src/app/protected/page.tsx:

import { getSession } from "@/utils/auth";
import { redirect } from "next/navigation";

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

if (!session.isAuthenticated) {
redirect("/sign-in");
}

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

All we’re doing here is checking the isAuthenticated value in the session cookie. If the value is false, then redirect the user to the sign in page. If the value is true, then render the page content.

Client components

Client components are a bit more difficult. Since everything here happens on the client, we actually need to make a separate server request to check the session cookie.

Let’s create our API endpoint at src/app/api/session/route.ts:

import { getSession } from "@/utils/auth";

export async function GET() {
const session = await getSession();

return Response.json(session);
}

The endpoint exposes a single GET handler which returns the contents of the user’s session cookie.

Next, we’ll create a small hook to call this API and check if the user is authenticated. Create a new file at src/utils/use-auth.ts:

import { useEffect, useState } from "react";
import { SessionData } from "./auth";
import { redirect } from "next/navigation";

export function useAuth() {
const [session, setSession] = useState<SessionData | null>(null);
const isAuthenticated = session && session.isAuthenticated;
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
async function fetchSession() {
try {
const res = await fetch("/api/session");
setSession(await res.json());
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}

fetchSession();
}, []);

useEffect(() => {
if (!isLoading && !isAuthenticated) {
const redirectPath = window.location.pathname;

redirect(`/sign-in?redirect=${encodeURIComponent(redirectPath)}`);
}
}, [isLoading, isAuthenticated]);

return { isAuthenticated, isLoading };
}

This hook does two things: make a request to /api/session to get the contents of our session cookie and redirect the user to the sign in page if they are not authenticated. We’re also returning an isLoading property since we’re making a network request so we can appropriately render a loading state when the request is in flight.

Now we can put this all together on our client component page. Create a client component at src/app/client-protected/page.tsx:

"use client";

import { useAuth } from "@/utils/use-auth";

export default function Protected() {
const { isLoading } = useAuth();

if (isLoading) {
return "Loading...";
}

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

Here we’re doing something very similar to what we did for the protected server component page, but instead of grabbing the session value of isAuthenticated via getSession, we’re relying on the useAuth hook that we wrote earlier.

Since useAuth handles checking the isAuthenticated property and redirecting the user for us, we only need to leverage the returned isLoading property to appropriately render a loading state while the request is made.

Using middleware

Middleware is a great route to go when password protecting routes in bulk because it allows you to specify which routes should hit this authentication moment using a matcher (essentially a glob syntax for picking routes).

We’ll define how to handle this in src/middleware.ts:

import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/utils/auth";

export async function middleware(request: NextRequest) {
const session = await getSession();
const redirectPath = new URL(request.url).pathname;

if (!session.isAuthenticated) {
return NextResponse.redirect(
new URL(
`/sign-in?redirect=${encodeURIComponent(redirectPath)}`,
request.url,
),
);
}

return NextResponse.next();
}

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

Again, here we’re doing something quite similar to how we defined our server component password protection. First we’re checking for the isAuthenticated property on the session cookie. If it returns as false, then we redirect the user to the sign in page and also pass in the route that the user was trying to access as a redirect query parameter so the sign in page can redirect the user back to this page once they’ve successfully authenticated.

Then we just define our matcher to match any route that begins with /middleware-protected/, including any nested routes.

To demonstrate this, we can define a new page 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>
);
}

Wrap up

We walked through setting up iron-session, creating a sign in page, and protecting routes both at the route itself and with middleware.

Overall, I much prefer iron-session of the version of Auth.js that I used before. I’m sure there have been improvements in Auth.js since then, but for this particular use case, it just felt like overkill.

You can check out a fully working example on GitHub or play around with it live on CodeSandBox.