Skip to content
Codeloom
Next.js

Authentication Middleware Patterns in Next.js

Implement authentication middleware in Next.js to protect routes, redirect unauthenticated users, and manage JWT or session-based auth at the edge.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How Next.js middleware runs at the edge before every request
  • Protecting routes by checking cookies and JWT tokens
  • Redirecting unauthenticated users to a login page
  • Role-based access control in middleware
  • Combining middleware with NextAuth.js and other auth libraries

Prerequisites

  • Understanding of [Next.js middleware basics](/blog/nextjs-middleware-basics)
  • Familiarity with [JWT authentication concepts](/blog/jwt-authentication-explained)

Next.js middleware runs before every matched request, at the edge, before any page or API route executes. This makes it the ideal place to check authentication tokens, redirect unauthenticated users, and enforce access control rules without duplicating logic across every page.

How middleware works

Middleware lives in a single middleware.ts file at the root of your project (next to app/ or src/). It exports a function that receives a NextRequest and returns a NextResponse:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  // Runs before every matched route
  return NextResponse.next();
}

export const config = {
  matcher: [
    // Match all routes except static files and api
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

The config.matcher array controls which routes trigger the middleware. Without it, middleware runs on every request, including static assets.

Basic token-based authentication

The simplest pattern checks for an auth token in cookies and redirects to login if missing:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const publicRoutes = ['/login', '/signup', '/forgot-password'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Allow public routes
  if (publicRoutes.some((route) => pathname.startsWith(route))) {
    return NextResponse.next();
  }

  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'],
};

This redirects unauthenticated users to /login while preserving the original URL as a callback parameter so you can redirect back after login.

Validating JWT tokens in middleware

Checking that a cookie exists is not enough. You need to validate the token:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return payload;
  } catch {
    return null;
  }
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (pathname.startsWith('/login') || pathname.startsWith('/signup')) {
    return NextResponse.next();
  }

  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const payload = await verifyToken(token);

  if (!payload) {
    // Token is invalid or expired
    const response = NextResponse.redirect(new URL('/login', request.url));
    response.cookies.delete('auth-token');
    return response;
  }

  // Attach user info to headers for downstream use
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.sub as string);
  requestHeaders.set('x-user-role', payload.role as string);

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

We use jose instead of jsonwebtoken because middleware runs in the Edge Runtime, which does not support Node.js-specific APIs. The jose library works in all runtimes.

Role-based access control

Extend the middleware to check user roles for specific routes:

// lib/auth-config.ts
type RoutePermission = {
  path: string;
  roles: string[];
};

export const protectedRoutes: RoutePermission[] = [
  { path: '/admin', roles: ['admin'] },
  { path: '/dashboard/billing', roles: ['admin', 'billing'] },
  { path: '/dashboard', roles: ['admin', 'user', 'billing'] },
  { path: '/api/admin', roles: ['admin'] },
];

export function getRequiredRoles(pathname: string): string[] | null {
  // Find the most specific matching route
  const match = protectedRoutes.find((route) =>
    pathname.startsWith(route.path)
  );
  return match?.roles ?? null;
}
// middleware.ts
import { getRequiredRoles } from '@/lib/auth-config';

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const token = request.cookies.get('auth-token')?.value;

  const payload = token ? await verifyToken(token) : null;

  const requiredRoles = getRequiredRoles(pathname);

  if (!requiredRoles) {
    // Public route
    return NextResponse.next();
  }

  if (!payload) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const userRole = payload.role as string;

  if (!requiredRoles.includes(userRole)) {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }

  return NextResponse.next();
}

Order matters in the protectedRoutes array. More specific paths should come first because find returns the first match.

Integrating with NextAuth.js

If you use NextAuth.js, it provides its own middleware helper:

// middleware.ts
import { withAuth } from 'next-auth/middleware';

export default withAuth(
  function middleware(request) {
    const { pathname } = request.nextUrl;
    const token = request.nextauth.token;

    if (pathname.startsWith('/admin') && token?.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }

    return NextResponse.next();
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token,
    },
  }
);

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
};

The withAuth wrapper handles token verification automatically and injects the decoded token into request.nextauth.token.

Refreshing expired tokens

Middleware can silently refresh expired tokens before the page renders:

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  const refreshToken = request.cookies.get('refresh-token')?.value;

  if (!token && !refreshToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const payload = token ? await verifyToken(token) : null;

  if (!payload && refreshToken) {
    // Token expired, try to refresh
    const newTokens = await refreshAuthToken(refreshToken);

    if (!newTokens) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    const response = NextResponse.next();
    response.cookies.set('auth-token', newTokens.accessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 60 * 15, // 15 minutes
    });

    return response;
  }

  if (!payload) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

Setting security headers

While you are in middleware, set security headers on authenticated responses:

const response = NextResponse.next();

response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
  'Strict-Transport-Security',
  'max-age=31536000; includeSubDomains'
);

return response;

Performance considerations

Middleware runs on every matched request, so keep it fast:

  • Avoid database calls. Validate JWTs cryptographically instead of checking a database.
  • Use the Edge Runtime. Middleware always runs on the edge; make sure your dependencies are edge-compatible.
  • Scope the matcher. Do not run middleware on static assets, images, or public API routes.
  • Cache role lookups. If you must check roles from an external source, cache the results in a short-lived store.

Common mistakes

Importing Node.js modules. Middleware runs in the Edge Runtime. Libraries like bcrypt, jsonwebtoken, or anything that uses fs will fail. Use jose for JWT, and @noble/hashes for cryptography.

Redirecting in a loop. If your login page is protected by middleware, the user gets stuck in infinite redirects. Always exclude auth pages from the matcher.

Blocking the response. Middleware should return quickly. If a token refresh takes too long, the user sees a blank page. Set aggressive timeouts on any network calls.

Authentication middleware gives you a single, centralized place to enforce access control across your entire Next.js application. Combined with proper JWT validation and role checks, it provides a robust security layer that runs before any page logic executes.