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):
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
:
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
:
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 pagecredentials
— The fields that will show up on the sign in pageauthorize
— 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
:
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
:
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
:
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
:
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
:
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
:
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
:
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:
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
:
We’re defining three environment variables:
NEXTAUTH_URL
— The canonical URL for your projectNEXTAUTH_SECRET
— A string to hash tokens, sign cookies, and generate keys. You can generate a reasonably strong secret by runningopenssl 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:
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:
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!