Blog

Auth in Middleware, Or How I Learned to Stop Worrying and Love the Edge

Route-level authentication specifies which pages require authentication, keeping relevant logic together. Middleware-level authentication follows a Zero Trust model and simplifies group route authentication. The choice depends on your application architecture, but an additional authorization layer is needed for complete security.


Middleware and edge-computing are a hot topic this year. Popular frameworks like Next.js, Nuxt.js, SvelteKit and Astro each have an interpretation of middleware and edge computing.

Here we’ll explore our options for "authenticating users with middleware" — practices, patterns, and pitfalls. Opinions differ on whether “auth in middleware” is a good idea, making decisions on whether to implement it tough. This post will explore the two major approaches, note the pros and cons, and finally make some suggestions on how you should approach the problem. We’ll be using Next.js primarily as an example, however everything discussed here applies to any other framework that uses middleware.

But first, let’s get some terminology out of the way.

Middleware?

Middleware refers to software that acts as a bridge between an operating system or database and applications, especially on a network. In web frameworks like Next.js, middleware refers to code that processes requests and responses before they reach the main application logic or after the logic is executed. Middleware functions can perform various tasks:

  • Authenticating users
  • Logging requests for analytics
  • Handling cookies or session management
  • Modifying request or response objects

Middleware enhances modularity and separation of concerns by allowing developers to implement cross-cutting functionalities that are separate from the main application logic.

The problem

When checking to see if a user is logged in, there are two approaches you can take:

  1. Authenticate on the route level (e.g. in /app/page.tsx when using the Next.js App Router)
  2. Authenticate on the middleware level (e.g. in /app/middleware.ts)

To further demonstrate these two approaches, we’ll go through some code examples showing how to do both with the authkit-nextjs library.

Route-level authentication

In this approach, the check to see whether a user is logged in or not is performed on the route itself, amongst your page logic and JSX:

      
import Link from 'next/link';
import { getSignInUrl, getSignUpUrl, getUser, signOut } from '@workos-inc/authkit-nextjs';

export default async function HomePage() {
  // Retrieves the user from the session or returns `null` if no user is signed in
  const { user } = await getUser();

  if (!user) {
    // Get the URL to redirect the user to AuthKit to sign in
    const signInUrl = await getSignInUrl();

    return (
      <Link href={signInUrl}>Log in</Link>      
    );
  }

  return (
     <form
      action={async () => {
        'use server';
        // Terminate the session and redirect to the homepage
        await signOut();
      }}
     >
       <p>Welcome back {user?.firstName && `, ${user?.firstName}`}</p>
       <button type="submit">Sign out</button>
     </form>
  );
}
      
      

In app/page.tsx

In the above snippet, the call to getUser is what checks to see if a user is logged in. Behind the scenes authkit-nextjs checks the session cookie to see if it’s expired, and if not we return the stored User object. If the session is expired, the library will automatically attempt to re-authenticate via a refresh token. If there’s no logged in user or the refresh fails, we instead get null and proceed to render a sign in button.

If we want to ensure that this page can only be accessed when logged in, we would edit our getUser call slightly:

      
const { user } = await getUser({ ensureSignedIn: true });      

      

Let’s go over the pros and cons of this pattern.

Pros:

  • Explicit vs implicit - We don’t have to guess if this page requires authentication, it’s clear from the pattern that the page either has a logged in/out views or requires a logged in user to view at all.
  • Auth logic for the particular route is contained in a singular place - Updating or amending the authentication strategy for this page is easy to do as it’s all contained within the same page file.

Cons:

  • Prone to human error - On pages where you don’t explicitly need the User object but you do want the page to only render for logged-in users, it can be easy to forget to add the method call. This example is specific to authkit-nextjs but it applies to any other framework where you’d have a “check for logged-in user” method.
  • Potentially violates DRY principles - Don’t Repeat Yourself states that you should avoid code duplication and use abstractions instead. With route-level authentication you possibly have to go and edit every single route in the scenario where you need to make sweeping changes to your authentication system.

Middleware-level authentication

With middleware, the authentication check happens before the request hits your main application logic. Here’s how it’s done with authkit-nextjs:

      
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';

export default authkitMiddleware({
  middlewareAuth: {
    enabled: true,
    // Allow logged out users to view these paths
    unauthenticatedPaths: ['/', '/about', '/login', '/blog/*'],
  },
});

export const config = {
  // Don't match on API routes, static files and the favicon
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

      

In app/middleware.ts

Here in our middleware file we’re explicitly enabling middlewareAuth mode. What this means is that authentication is enabled on all paths in our application, with the exception of the paths explicitly excluded in our matcher. This means that if a user attempts to access any of the protected paths, they will automatically be redirected to the AuthKit login page. We also provide an escape hatch via unauthenticatedPaths for some routes where we’re okay with having logged out users, e.g. the homepage or the login page.

In the routes where you still want user data, you’d call getUser in the same way as seen before. Difference is that this pattern is the equivalent of forcing authentication on every page with this snippet we saw earlier:

      
const { user } = await getUser({ ensureSignedIn: true }); 

      

Now let’s look at the pros and cons.

Pros:

  • All your auth logic in one place - The middleware and corresponding matcher will tell you exactly which pages do and don’t require authentication without you having to look at every individual route’s source code.
  • Zero Trust, secure by default strategy - This pattern means all pages in our matcher, even those not yet written, are secured by default. Think of it as an allowlist for unauthenticated paths versus a blocklist for paths where a logged in user is required. This follows the security principle of building the highest wall possible and then intentionally opt-out when you realize it’s too high.
  • Group routes for authentication - Because we use a matcher with this pattern, it’s easy to enforce authentication on a group of routes rather than individually. For example we can add /account/* to our middleware matcher to blanket add authentication to all sub-routes of /account.

Cons

  • Opt pages in or out in a global file - The counter-argument to the first pro in the list, having all the auth logic in the same place can make it hard to determine if the page you’re specifically interested in is covered or not.
  • Cascading bugs and errors - If there’s a bug or mistake in your authentication code, it will cascade down to every page as opposed to only affecting one.

Which one should I use?

Before we get into the crux of the matter we should cover one pattern that you definitely should not use: authentication in a layout. The layout pattern is used for when you have components or styles that every page in your app has in common. For instance every page might use the same header and footer components. Rather than duplicating those amongst page components, you could have a Layout component that wraps a page with those common components.

It might be tempting to add your auth check here instead of either the page route or middleware. Since the layout is shared by multiple pages, it must be convenient to add auth there, right? Unfortunately not, since it’s possible to bypass a layout completely and retrieve a page’s content, which means also bypassing auth checks.

Ultimately, the question of route-level or middleware-level authentication really comes down to your app architecture and personal choice. If your route surface area is limited, i.e. you have just a few pages and no “subroutes.” then route-level auth is likely the best strategy.

If however you have a large application with many routes that have different authorization requirements, then having a centralized place for auth comes with benefits and perhaps fewer headaches.

The real lesson

Neither of these strategies should matter as long as you stick to the most secure approach of all: validating access at the data level. Simply put, the sensitive content likely lies in the data that you’re fetching, so it makes sense to embed the definitive auth check within your data layer.

The key here is the difference between authentication (AuthN) and authorization (AuthZ). The former determines whether a user is who they claim to be, the latter determines what the user can see or do.

Route and middleware based auth is great for checking the AuthN side of things, but to be truly secure you need to be verifying AuthZ when retrieving the data.

This is why we at WorkOS decided to add both route-level and middleware-level patterns to authkit-nextjs, allowing developers to choose the approach that best suits their needs.

In this article

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.