Next.js Middleware Authentication in 2026: JWT Verification, Route Guards, and Edge Runtime
Implement Next.js middleware authentication: JWT verification at the edge, route guards, role-based redirects, workspace context injection, and performance-safe middleware patterns.
Next.js Middleware Authentication in 2026: JWT Verification, Route Guards, and Edge Runtime
Next.js middleware runs at the edge โ before the page renders, before Server Components execute, before the request even hits your origin server. This makes it the right place for authentication checks, redirects, and request transformation โ but the edge runtime has constraints that trip up most implementations.
This post covers JWT verification in edge middleware (no Node.js crypto โ use Web Crypto API), route guards with pattern matching, role-based redirects, injecting workspace context into request headers, and the common mistakes that break performance or create security holes.
Middleware Basics
// middleware.ts โ runs before EVERY matched request
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Runs at the edge โ no Node.js APIs, no file system, no DB
// Has access to: cookies, headers, URL, geo, IP
return NextResponse.next(); // Continue to the route
}
// Only run middleware on matched paths (avoid running on static assets)
export const config = {
matcher: [
/*
* Match all paths EXCEPT:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico
* - public files (images, fonts)
* - api routes that don't need auth
*/
"/((?!_next/static|_next/image|favicon.ico|public/|images/).*)",
],
};
JWT Verification at the Edge
The edge runtime doesn't have Node.js crypto module โ use the Web Crypto API (crypto.subtle):
// lib/auth/edge-jwt.ts
// Web Crypto API โ works in Edge Runtime and Node.js 18+
const JWT_ALGORITHM = "HS256";
const encoder = new TextEncoder();
function base64UrlDecode(str: string): Uint8Array {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
const binary = atob(padded);
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}
async function getKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
}
export interface JWTPayload {
sub: string; // userId
workspaceId: string;
role: string;
email: string;
exp: number;
iat: number;
}
export async function verifyJWT(token: string): Promise<JWTPayload | null> {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature
const key = await getKey(process.env.JWT_SECRET!);
const data = encoder.encode(`${headerB64}.${payloadB64}`);
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify("HMAC", key, signature, data);
if (!valid) return null;
// Decode payload
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64))
) as JWTPayload;
// Check expiry
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
} catch {
return null;
}
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Full Authentication Middleware
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyJWT } from "@/lib/auth/edge-jwt";
// Routes that don't need authentication
const PUBLIC_PATHS = [
"/",
"/login",
"/signup",
"/forgot-password",
"/reset-password",
"/pricing",
"/blog",
"/api/auth", // Auth endpoints (login, signup, refresh)
"/api/webhooks", // Stripe webhooks (separate auth)
];
// Routes that require specific roles
const ADMIN_PATHS = ["/admin"];
const BILLING_PATHS = ["/settings/billing", "/settings/usage"];
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(
(p) => pathname === p || pathname.startsWith(p + "/")
);
}
function isAdminPath(pathname: string): boolean {
return ADMIN_PATHS.some((p) => pathname.startsWith(p));
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip auth for public paths
if (isPublicPath(pathname)) {
return NextResponse.next();
}
// Get JWT from cookie (preferred) or Authorization header
const token =
request.cookies.get("auth-token")?.value ??
request.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
// Redirect to login with return URL
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
const payload = await verifyJWT(token);
if (!payload) {
// Invalid or expired token โ clear cookie and redirect
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("auth-token");
return response;
}
// Role-based route guard
if (isAdminPath(pathname) && payload.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
// Inject user context into request headers (available in Server Components)
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.sub);
requestHeaders.set("x-workspace-id", payload.workspaceId);
requestHeaders.set("x-user-role", payload.role);
requestHeaders.set("x-user-email", payload.email);
// Continue with enriched headers
return NextResponse.next({
request: { headers: requestHeaders },
});
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|public/|images/).*)",
],
};
Reading Injected Headers in Server Components
// lib/auth/server-context.ts
import { headers } from "next/headers";
export async function getServerUser() {
const headersList = await headers();
const userId = headersList.get("x-user-id");
const workspaceId = headersList.get("x-workspace-id");
const role = headersList.get("x-user-role");
const email = headersList.get("x-user-email");
if (!userId || !workspaceId) return null;
return { userId, workspaceId, role: role ?? "member", email: email ?? "" };
}
// Usage in Server Component โ no DB query needed for basic auth check
// (JWT payload was already verified in middleware)
export default async function DashboardPage() {
const user = await getServerUser();
if (!user) return null; // Middleware already redirected, this is a safety net
return <Dashboard workspaceId={user.workspaceId} role={user.role} />;
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Token Refresh in Middleware
Short-lived access tokens (15 min) + refresh tokens (30 days):
// middleware.ts โ with token refresh
import { verifyJWT, verifyRefreshToken, signJWT } from "@/lib/auth/edge-jwt";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (isPublicPath(pathname)) return NextResponse.next();
const accessToken = request.cookies.get("access-token")?.value;
const refreshToken = request.cookies.get("refresh-token")?.value;
// Try access token first
let payload = accessToken ? await verifyJWT(accessToken) : null;
// If access token expired but refresh token valid, issue new access token
if (!payload && refreshToken) {
const refreshPayload = await verifyRefreshToken(refreshToken);
if (refreshPayload) {
// Issue new access token (edge-compatible โ uses Web Crypto)
const newAccessToken = await signJWT({
sub: refreshPayload.userId,
workspaceId: refreshPayload.workspaceId,
role: refreshPayload.role,
email: refreshPayload.email,
});
payload = await verifyJWT(newAccessToken);
// Continue AND set new cookie
const requestHeaders = new Headers(request.headers);
if (payload) {
requestHeaders.set("x-user-id", payload.sub);
requestHeaders.set("x-workspace-id", payload.workspaceId);
requestHeaders.set("x-user-role", payload.role);
}
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.cookies.set("access-token", newAccessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 15, // 15 minutes
path: "/",
});
return response;
}
}
if (!payload) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
const response = NextResponse.redirect(loginUrl);
response.cookies.delete("access-token");
response.cookies.delete("refresh-token");
return response;
}
// Normal path โ inject headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.sub);
requestHeaders.set("x-workspace-id", payload.workspaceId);
requestHeaders.set("x-user-role", payload.role);
return NextResponse.next({ request: { headers: requestHeaders } });
}
Workspace Routing Middleware
For multi-workspace apps where the workspace is in the URL:
// middleware.ts โ workspace slug routing
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Detect workspace slug pattern: /[slug]/dashboard, /[slug]/settings, etc.
const workspaceMatch = pathname.match(/^\/([a-z0-9-]+)\/(.*)/);
if (workspaceMatch) {
const [, slug, rest] = workspaceMatch;
// Skip if slug is a reserved route
const RESERVED_SLUGS = ["api", "auth", "login", "signup", "admin", "_next", "public"];
if (RESERVED_SLUGS.includes(slug)) {
return NextResponse.next();
}
// Inject workspace slug for downstream use
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-workspace-slug", slug);
const payload = await verifyJWT(
request.cookies.get("access-token")?.value ?? ""
);
if (!payload) {
return NextResponse.redirect(new URL("/login", request.url));
}
requestHeaders.set("x-user-id", payload.sub);
return NextResponse.next({ request: { headers: requestHeaders } });
}
return NextResponse.next();
}
Common Mistakes
// โ Using Node.js crypto in middleware โ breaks edge runtime
import { createHmac } from "crypto"; // Not available in edge!
// โ
Use Web Crypto API
const key = await crypto.subtle.importKey(/* ... */);
// โ Querying database in middleware โ too slow for every request
const user = await db.user.findUnique({ where: { id: payload.sub } });
// โ
Trust JWT payload for basic checks; query DB only in Server Components
// Middleware validates the token; Server Components fetch full user data if needed
// โ Running middleware on static assets โ wastes edge compute
export const config = { matcher: "/:path*" };
// โ
Exclude static files
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|public/).*)" ],
};
// โ Not setting redirect with return URL โ users lose their deep link
return NextResponse.redirect(new URL("/login", request.url));
// โ
Preserve the intended URL
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
Performance Budget
| Operation | Edge Latency |
|---|---|
| Cookie read | <0.1ms |
| JWT signature verify (Web Crypto) | 0.5โ2ms |
| Header injection | <0.1ms |
| Database query | 10โ50ms (โ avoid) |
| Total acceptable middleware overhead | <5ms |
Cost and Timeline
| Component | Timeline | Cost (USD) |
|---|---|---|
| Basic JWT middleware | 0.5 day | $300โ$500 |
| Token refresh in middleware | 0.5โ1 day | $400โ$800 |
| Role-based route guards | 0.5 day | $300โ$500 |
| Workspace routing middleware | 0.5โ1 day | $400โ$800 |
| Full middleware auth system | 1โ2 weeks | $5,000โ$8,000 |
See Also
- Next.js App Router Caching โ Cache behavior with middleware headers
- SaaS Role-Based Access โ Fine-grained permissions beyond middleware
- SaaS Multi-Workspace โ Workspace routing patterns
- Next.js Server Components Patterns โ Using headers() from middleware
Working With Viprasol
We implement Next.js middleware authentication for SaaS products โ from simple JWT route guards through multi-workspace routing with token refresh. Our team has shipped middleware auth systems protecting hundreds of routes with sub-5ms overhead.
What we deliver:
- Edge-compatible JWT verification using Web Crypto API
- Route guard configuration with public/protected/admin patterns
- Token refresh flow without DB queries in middleware
- Workspace context injection via request headers
- Security audit: middleware bypass vectors, token storage, cookie flags
Explore our web development services or contact us to secure your Next.js application.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.