Introduction to Next.js Authentication
Authentication is a complex and nuanced topic. And with the introduction of server components, server actions, and middleware, there are a lot of new unknowns. In this video, we'll discuss the principles of authentication in modern Next.js apps. We will break it down into small, digestible steps, walk through the code, and explain the new features in APIs. We'll also highlight best practices and common pitfalls that you should be aware of. Let's get started.
Authentication usually starts with a sign up process. Let's create a form to capture the user's name, email, and password. The form invokes a server action when submitted. In a new file, we'll add the use server directive to create the server-side functions that can be called from our client-side code.
Let's create a new server action. It will take in our form state and data. We'll validate the incoming fields before any authentication logic. Here we're using Zod, but you can use whatever method you prefer. If validation fails, we'll return the errors early. Then back in our signup form, we'll capture the errors using React's useActionState and we'll conditionally render them next to their fields.
I've generated some UI for our app using v0. Let's quickly see our signup flow in action by submitting some empty fields. Nice.
Now, you may have noticed that while the form was being submitted, we changed the button text to "Submitting...". We can use the pending state returned by the useActionState hook to show feedback while an action's being processed.
Implementing Session Management with JWTs
Back in our signup action, let's create the user and insert them into our database. A quick disclaimer: we'll implement basic password and email auth to demonstrate the principles, but you might consider using an auth library or a third-party provider instead. Continuing, we can use bcrypt to hash the user password and an ORM to interact with our database. If you're using an auth library, you'd use their APIs here instead.
After successfully registering the user, we need to create a session that persists across requests so that the user doesn't have to log in every time they navigate to a new page. We create a file to keep our session management logic, including utility functions to create, verify, update, and delete sessions. We use a package called jose to help manage encrypting and decrypting JWT tokens. Next, we'll create a secret key to sign and verify the token, as well as a helper object to hold our cookie options.
createSession is a helper function for creating a new session. We'll use it after a user successfully logs in or signs up. The function sets the session expiration time, encrypts the session, sets the cookie, and redirects the user to their dashboard.
The verifySession function checks if the current user has a valid session. This function reads and decrypts the cookie to get the user's session. If it's not valid, it redirects them back to the login page. Otherwise, it returns the user's data.
In deleteSession, we'll simply clear the cookie and redirect the user to the login page. Finally, since these utilities contain sensitive information like our JWT secret, we can use React's server-only package to ensure this code is never accidentally imported into a client component. Now let's head back to our signup server action where we'll import and use the createSession function we just created.
Now when a user is successfully registered, their session will be created and they will be redirected to the dashboard.
Stateless vs. Stateful Sessions
This method is called a stateless session, which means we only store the user session in their local browser. Another approach you could take is to store sessions in the database. This provides additional benefits, such as giving users the option to immediately log out of all other devices.
Authorization with Middleware
Next, we'll need to decide what routes and data a user can access in our application based on their roles or permissions. This is called authorization. Unlike SPAs, which have a single entry point allowing you to wrap your app in an auth context provider, Next.js apps have multiple entry points and contexts we need to consider, such as layouts, pages, route handlers, and server actions.
We can handle some of the authorization logic in middleware. For example, we can check if the current route is protected. If it is, we'll retrieve the session information from the cookie, decrypt it, and redirect the user to the login page if they don't have a valid session. Otherwise, we proceed with rendering the page. Since middleware runs on every route, it's a good place to perform optimistic authentication checks. If the user tries to access a protected route but doesn't have a valid cookie, there's no point in proceeding.
However, there are some things to consider when using middleware. Middleware runs on every request, including prefetched routes, so it's important to keep the computation and data fetching to a minimum, for example, avoiding database queries that will block rendering. Middleware uses the Edge runtime, so libraries and code need to be Edge compatible. This is the reason we used jose earlier on.
The Data Access Layer (DAL) Pattern
Finally, for increased security, it's best practice to keep authorization logic close to where the data is fetched using a data access layer. There are several benefits to using a data access layer: it allows you to continually verify the user session as they interact with your application; it's easy to maintain and audit data access as your application scales; and it avoids authorization pitfalls, such as verifying in layout which might not re-render on navigation, forgetting to check the user is allowed to perform a mutation inside a server action, or having your data fetching functions and associated security rules drifting apart.
There are a few ways to implement a data access layer, and if you're not familiar with it, it can sound complex. But don't worry, we'll go through a simple example to demonstrate the idea. In a new file, let's create a function to fetch the user's information. This request should be protected, so we'll call the verifySession function we created earlier directly inside getUser before making any external database calls. Since verifySession redirects users with invalid tokens, it guarantees that no matter where getUser is called, it will always require a valid session.
Now, let's actually fetch the user's data. As a further optimization, we'll wrap getUser in React's cache function. This means we can call getUser in multiple components, but only one request will be made to our database when rendering a route. By moving authentication logic to the data layer, we're most secure by default. For example, even if we deleted the middleware file that includes the optimistic check, verifySession inside getUser would still redirect users without a valid session to the login page.
Adding the verifySession check to individual data fetching functions can be error-prone, so you may consider researching more robust data access layer patterns that use classes, higher-order functions, or third-party libraries.
Advanced Data Security: DTOs & Tainting
While we're talking about protecting your data, a good principle to follow is API minimization. That means returning the specific data your application needs, and if it's shown to the user, only the data they are authorized to see.
In our simple example, we can list specific fields to fetch. This means that the response will only contain the name and email fields, rather than the whole user object which might contain more sensitive data. However, if you don't have control over the API response or you want to ensure that your team doesn't accidentally expose data, you might consider using a pattern like Data Transfer Objects. This creates a filtering layer between your incoming data and the data exposed to your application for the current user and what is sent to the client. For example, we can create a function that takes in the whole user object but only returns specific fields. Then we can wrap incoming external data with it. This minimizes the fields we pass to the rest of our application, reducing the risk of accidental leaks and the size of our response if we pass the whole object to a client component as props.
Speaking of which, we might want to prevent some object properties from being passed to client components. For example, if we were using database sessions, the user object might contain a session token which we need to pass through our application, but we never want to accidentally pass it to the client. We can achieve this using React's taint unique value API, which accepts a message, object, and value. Now any attempt to pass the token to a client component will result in an error.
This data layer is a good place to implement additional filtering. For example, we might conditionally return some fields based on user permissions. Let's include an audit trail field if the user is an admin. Another example might be only returning another user's profile details if the current user is friends with them. We're showing the general principle in a simplified way, so I recommend researching data transfer objects and choosing the best method for your application.
Securing Mutations & Conditional UI
So far, we've talked about fetching data, but most applications also need to mutate data. In Next.js, we can mutate data with server actions. Under the hood, server actions create an internal API endpoint. That means we should treat them with the same security considerations as public-facing endpoints and verify if the user is allowed to perform any actions before continuing. For example, before performing a bulk mutation, we can check that the user has the required permissions.
Similarly, if we want to conditionally render components based on permissions, we can check the user's role before rendering. Since server components execute on the server, only the result of the render is sent to the client. Keep in mind, to be consistent with our principle, any fetches inside the admin or user dashboard should still check if the user has the correct permissions. This would help if, for example, we or another team member rendered the admin dashboard without any checks.
Lastly, auth context providers are a popular pattern in single-page applications. However, server components are rendered on the server and will not have access to the client-side provider's context. This means you can wrap your application in a context provider for any client-side data fetching and rendering, but you will still need to handle server-side authentication and authorization.
Summary of Best Practices
All right, we made it. This covers the main topics of authentication in Next.js apps. I hope it was helpful. Let's quickly summarize what we've just discussed.
Middleware can be used for optimistic, non-blocking checks, but should not be used for data fetching or compute-intensive checks. Layouts do not get re-rendered on navigation. Server actions are public endpoints. And authentication logic separate from data fetching is difficult to audit and can drift apart. This collectively means you should keep authorization logic close to where your data is fetched, minimize the data you return, and filter sensitive data before passing it to the client.
If you'd like to learn more, check out the Next.js documentation and feel free to play around with the complete example on GitHub. We will continue exploring this new era of Next.js and React concepts in future videos, so consider subscribing for more.