Blog/Tutorials & Step-by-Step/Next.js Middleware for Authentication and Authorization
POST
November 05, 2025
LAST UPDATEDNovember 05, 2025

Next.js Middleware for Authentication and Authorization

Protect your Next.js app with middleware-based authentication, JWT verification, role-based access control, rate limiting, and more.

Tags

Next.jsMiddlewareAuthenticationSecurity
Next.js Middleware for Authentication and Authorization
4 min read

Next.js Middleware for Authentication and Authorization

In this tutorial, you will build a complete authentication and authorization layer using Next.js middleware. You will learn how middleware works, verify JWTs at the edge, implement role-based access control, protect both pages and API routes, add rate limiting, and handle geolocation-based routing. All of this runs before your page code even executes, keeping your application secure by default.

TL;DR

Create a middleware.ts file at the project root that intercepts requests before they reach pages or API routes. Use the jose library for edge-compatible JWT verification, check user roles against a route permission map, and return redirects for pages or 401 responses for APIs. Add rate limiting with an in-memory store or Upstash Redis for distributed deployments.

Prerequisites

  • Next.js 14+ with the App Router
  • Node.js 18+
  • A JWT-based authentication system (we use jose for edge-compatible token handling)
  • Basic understanding of HTTP headers and cookies
bash
npx create-next-app@latest auth-middleware --typescript --app --tailwind
cd auth-middleware
npm install jose

Step 1: Understand How Middleware Works

Next.js middleware is a single file at the root of your project that runs on every request matching a configured pattern. It executes on the Edge Runtime, which means it runs close to your users with minimal latency but has limited API access compared to Node.js.

typescript
// middleware.ts (project root)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  console.log("Middleware running for:", request.nextUrl.pathname);
 
  // Continue to the actual page/API route
  return NextResponse.next();
}
 
// Only run middleware on specific paths
export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization)
     * - favicon.ico (favicon file)
     * - public folder files
     */
    "/((?!_next/static|_next/image|favicon.ico|public/).*)",
  ],
};

The matcher config is critical for performance. Without it, middleware runs on every single request including static assets. The regex pattern above excludes Next.js internal paths and public files.

Step 2: Set Up JWT Verification

Since middleware runs on the Edge Runtime, you cannot use Node.js libraries like jsonwebtoken. The jose library is designed specifically for edge environments.

typescript
// src/lib/auth.ts
import { jwtVerify, SignJWT } from "jose";
 
const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET || "your-secret-key-min-32-chars-long!!"
);
 
export interface UserPayload {
  sub: string;           // user ID
  email: string;
  role: "admin" | "editor" | "user";
  iat: number;
  exp: number;
}
 
export async function verifyToken(token: string): Promise<UserPayload | null> {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return payload as unknown as UserPayload;
  } catch (error) {
    return null;
  }
}
 
export async function signToken(user: Omit<UserPayload, "iat" | "exp">): Promise<string> {
  return new SignJWT({ ...user })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("24h")
    .sign(JWT_SECRET);
}

The verifyToken function returns null on any failure instead of throwing. This makes the middleware logic cleaner since you can simply check if (!user) rather than wrapping everything in try-catch blocks.

Step 3: Define Route Permissions

Create a clear mapping of which roles can access which routes.

typescript
// src/lib/permissions.ts
type Role = "admin" | "editor" | "user";
 
interface RoutePermission {
  path: string;
  roles: Role[];
  exact?: boolean;
}
 
export const protectedRoutes: RoutePermission[] = [
  // Admin-only routes
  { path: "/admin", roles: ["admin"] },
  { path: "/api/admin", roles: ["admin"] },
 
  // Editor and admin routes
  { path: "/dashboard/posts/new", roles: ["admin", "editor"], exact: true },
  { path: "/dashboard/posts/edit", roles: ["admin", "editor"] },
  { path: "/api/posts", roles: ["admin", "editor"] },
 
  // Any authenticated user
  { path: "/dashboard", roles: ["admin", "editor", "user"] },
  { path: "/api/user", roles: ["admin", "editor", "user"] },
  { path: "/settings", roles: ["admin", "editor", "user"] },
];
 
export const publicRoutes = ["/", "/login", "/register", "/api/auth", "/blog"];
 
export function getRequiredRoles(pathname: string): Role[] | null {
  // Check if it is a public route first
  if (publicRoutes.some((route) => pathname.startsWith(route))) {
    return null; // No auth required
  }
 
  // Find matching protected route (most specific first)
  const sorted = [...protectedRoutes].sort(
    (a, b) => b.path.length - a.path.length
  );
 
  for (const route of sorted) {
    if (route.exact) {
      if (pathname === route.path) return route.roles;
    } else {
      if (pathname.startsWith(route.path)) return route.roles;
    }
  }
 
  // Default: require authentication for unmatched routes
  return ["admin", "editor", "user"];
}

Sorting routes by path length ensures the most specific route matches first. If you have both /dashboard and /dashboard/posts/edit, the edit route with its stricter role requirement will match before the general dashboard route.

Step 4: Build the Authentication Middleware

Now combine token verification and route permissions into the middleware.

typescript
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken, UserPayload } from "@/lib/auth";
import { getRequiredRoles } from "@/lib/permissions";
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Check if this route requires authentication
  const requiredRoles = getRequiredRoles(pathname);
 
  // Public route - allow through
  if (requiredRoles === null) {
    return NextResponse.next();
  }
 
  // Extract token from cookie or Authorization header
  const token =
    request.cookies.get("auth-token")?.value ||
    request.headers.get("authorization")?.replace("Bearer ", "");
 
  if (!token) {
    return handleUnauthorized(request, pathname);
  }
 
  // Verify the JWT
  const user = await verifyToken(token);
 
  if (!user) {
    // Token is invalid or expired
    const response = handleUnauthorized(request, pathname);
 
    // Clear the invalid cookie
    if (request.cookies.has("auth-token")) {
      response.cookies.delete("auth-token");
    }
 
    return response;
  }
 
  // Check role-based authorization
  if (!requiredRoles.includes(user.role)) {
    return handleForbidden(request, pathname, user);
  }
 
  // Attach user info to headers for downstream use
  const response = NextResponse.next();
  response.headers.set("x-user-id", user.sub);
  response.headers.set("x-user-email", user.email);
  response.headers.set("x-user-role", user.role);
 
  return response;
}
 
function handleUnauthorized(request: NextRequest, pathname: string): NextResponse {
  // API routes get JSON responses
  if (pathname.startsWith("/api/")) {
    return NextResponse.json(
      { error: "Authentication required" },
      { status: 401 }
    );
  }
 
  // Page routes redirect to login with a return URL
  const loginUrl = new URL("/login", request.url);
  loginUrl.searchParams.set("callbackUrl", pathname);
  return NextResponse.redirect(loginUrl);
}
 
function handleForbidden(
  request: NextRequest,
  pathname: string,
  user: UserPayload
): NextResponse {
  if (pathname.startsWith("/api/")) {
    return NextResponse.json(
      { error: "Insufficient permissions", requiredRole: "elevated" },
      { status: 403 }
    );
  }
 
  // Redirect to a "not authorized" page
  return NextResponse.redirect(new URL("/unauthorized", request.url));
}
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|public/).*)"],
};

Setting user information in response headers is a lightweight way to pass data from middleware to your server components and API routes. The downstream code can read x-user-id from the headers without re-verifying the token.

Step 5: Protect API Routes

Your API routes can now trust the headers set by middleware, but adding a helper function keeps things clean.

typescript
// src/lib/apiAuth.ts
import { headers } from "next/headers";
 
interface AuthenticatedUser {
  id: string;
  email: string;
  role: string;
}
 
export async function getAuthUser(): Promise<AuthenticatedUser | null> {
  const headersList = await headers();
  const userId = headersList.get("x-user-id");
  const email = headersList.get("x-user-email");
  const role = headersList.get("x-user-role");
 
  if (!userId || !email || !role) return null;
 
  return { id: userId, email, role };
}
typescript
// src/app/api/user/profile/route.ts
import { NextResponse } from "next/server";
import { getAuthUser } from "@/lib/apiAuth";
 
export async function GET() {
  const user = await getAuthUser();
 
  if (!user) {
    // This should not happen if middleware is configured correctly
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }
 
  return NextResponse.json({
    id: user.id,
    email: user.email,
    role: user.role,
  });
}

Step 6: Add Rate Limiting

Rate limiting in middleware prevents abuse before requests reach your application logic. For a single-server deployment, an in-memory store works. For distributed deployments, use Upstash Redis.

typescript
// src/lib/rateLimit.ts
interface RateLimitEntry {
  count: number;
  resetTime: number;
}
 
const rateLimitStore = new Map<string, RateLimitEntry>();
 
// Clean up expired entries periodically
setInterval(() => {
  const now = Date.now();
  for (const [key, entry] of rateLimitStore) {
    if (now > entry.resetTime) {
      rateLimitStore.delete(key);
    }
  }
}, 60_000);
 
export interface RateLimitConfig {
  windowMs: number;    // Time window in milliseconds
  maxRequests: number; // Max requests per window
}
 
export function checkRateLimit(
  identifier: string,
  config: RateLimitConfig
): { allowed: boolean; remaining: number; resetIn: number } {
  const now = Date.now();
  const entry = rateLimitStore.get(identifier);
 
  if (!entry || now > entry.resetTime) {
    // New window
    rateLimitStore.set(identifier, {
      count: 1,
      resetTime: now + config.windowMs,
    });
    return {
      allowed: true,
      remaining: config.maxRequests - 1,
      resetIn: config.windowMs,
    };
  }
 
  entry.count += 1;
 
  if (entry.count > config.maxRequests) {
    return {
      allowed: false,
      remaining: 0,
      resetIn: entry.resetTime - now,
    };
  }
 
  return {
    allowed: true,
    remaining: config.maxRequests - entry.count,
    resetIn: entry.resetTime - now,
  };
}

Integrate rate limiting into your middleware:

typescript
// Add to middleware.ts
import { checkRateLimit } from "@/lib/rateLimit";
 
// Inside the middleware function, before auth checks:
if (pathname.startsWith("/api/")) {
  const ip = request.headers.get("x-forwarded-for") || request.ip || "unknown";
  const rateLimitKey = `${ip}:${pathname}`;
 
  const { allowed, remaining, resetIn } = checkRateLimit(rateLimitKey, {
    windowMs: 60_000,   // 1 minute
    maxRequests: 60,     // 60 requests per minute
  });
 
  if (!allowed) {
    return NextResponse.json(
      { error: "Too many requests" },
      {
        status: 429,
        headers: {
          "Retry-After": String(Math.ceil(resetIn / 1000)),
          "X-RateLimit-Remaining": "0",
        },
      }
    );
  }
}

Step 7: Geolocation-Based Routing

Next.js middleware on Vercel provides geolocation data automatically. You can use it to redirect users to region-specific content or block access from certain regions.

typescript
// Add to middleware.ts
function handleGeolocation(request: NextRequest): NextResponse | null {
  const country = request.geo?.country;
  const city = request.geo?.city;
  const region = request.geo?.region;
 
  // Redirect to country-specific pages
  if (country && request.nextUrl.pathname === "/") {
    const localizedPaths: Record<string, string> = {
      DE: "/de",
      FR: "/fr",
      JP: "/ja",
    };
 
    const localizedPath = localizedPaths[country];
    if (localizedPath) {
      return NextResponse.redirect(new URL(localizedPath, request.url));
    }
  }
 
  // Add geo headers for downstream use
  const response = NextResponse.next();
  if (country) response.headers.set("x-user-country", country);
  if (city) response.headers.set("x-user-city", city);
  if (region) response.headers.set("x-user-region", region);
 
  return null; // Continue with normal flow
}

Geolocation headers are populated automatically when deployed to Vercel's edge network. For local development, these values will be undefined, so always use optional chaining.

Step 8: The Complete Middleware

Here is the full middleware with all features combined.

typescript
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken } from "@/lib/auth";
import { getRequiredRoles } from "@/lib/permissions";
import { checkRateLimit } from "@/lib/rateLimit";
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // 1. Rate limiting for API routes
  if (pathname.startsWith("/api/")) {
    const ip = request.headers.get("x-forwarded-for") || request.ip || "unknown";
    const { allowed, resetIn } = checkRateLimit(`${ip}:api`, {
      windowMs: 60_000,
      maxRequests: 60,
    });
 
    if (!allowed) {
      return NextResponse.json(
        { error: "Too many requests" },
        { status: 429, headers: { "Retry-After": String(Math.ceil(resetIn / 1000)) } }
      );
    }
  }
 
  // 2. Check route permissions
  const requiredRoles = getRequiredRoles(pathname);
  if (requiredRoles === null) {
    return NextResponse.next();
  }
 
  // 3. Extract and verify token
  const token =
    request.cookies.get("auth-token")?.value ||
    request.headers.get("authorization")?.replace("Bearer ", "");
 
  if (!token) {
    if (pathname.startsWith("/api/")) {
      return NextResponse.json({ error: "Authentication required" }, { status: 401 });
    }
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(loginUrl);
  }
 
  const user = await verifyToken(token);
 
  if (!user) {
    const response = pathname.startsWith("/api/")
      ? NextResponse.json({ error: "Invalid token" }, { status: 401 })
      : NextResponse.redirect(new URL("/login", request.url));
 
    if (request.cookies.has("auth-token")) {
      response.cookies.delete("auth-token");
    }
    return response;
  }
 
  // 4. Check role-based authorization
  if (!requiredRoles.includes(user.role)) {
    if (pathname.startsWith("/api/")) {
      return NextResponse.json({ error: "Forbidden" }, { status: 403 });
    }
    return NextResponse.redirect(new URL("/unauthorized", request.url));
  }
 
  // 5. Pass user info downstream
  const response = NextResponse.next();
  response.headers.set("x-user-id", user.sub);
  response.headers.set("x-user-email", user.email);
  response.headers.set("x-user-role", user.role);
 
  return response;
}
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|public/).*)"],
};

Next Steps

  • Replace in-memory rate limiting with Upstash Redis for distributed deployments
  • Add CSRF protection by validating origin headers for mutation requests
  • Implement token refresh logic that issues a new token when the current one is close to expiration
  • Add audit logging that records every access attempt with user, path, and outcome
  • Set up Content Security Policy headers in middleware for defense-in-depth

FAQ

What is Next.js middleware and when does it run?

Next.js middleware is a function in middleware.ts at the project root that runs on the Edge Runtime before every matched request, allowing you to rewrite, redirect, or modify responses before they reach your pages or API routes.

Can you use Node.js libraries in Next.js middleware?

No, middleware runs on the Edge Runtime which does not support Node.js APIs. Use edge-compatible libraries like jose for JWT verification instead of jsonwebtoken, and avoid fs, crypto.createHmac, or database drivers.

How do you protect both pages and API routes with middleware?

Use the matcher config to specify which paths the middleware should run on, then check the pathname inside the middleware to apply different logic for pages (redirect to login) versus API routes (return 401 JSON).

Is rate limiting in middleware effective?

Middleware rate limiting works for basic protection but uses in-memory storage that does not share across edge nodes. For production, use an external store like Upstash Redis which provides an edge-compatible client.

How do you pass user data from middleware to page components?

Set custom request headers in middleware using NextResponse.next() with modified headers. Server components and API routes can then read these headers to access user information without re-verifying the token.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.