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.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
josefor edge-compatible token handling) - ›Basic understanding of HTTP headers and cookies
npx create-next-app@latest auth-middleware --typescript --app --tailwind
cd auth-middleware
npm install joseStep 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.
// 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.
// 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.
// 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.
// 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.
// 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 };
}// 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.
// 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:
// 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.
// 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.
// 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.
Related Articles
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
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
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.