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:
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.
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 usecookieOptions
– Some additional cookie configuration. We’re settingcookieOptions.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:
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
:
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
:
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
:
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
:
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
:
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
:
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
:
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
:
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.