Next.js Middleware: Authentication and Routing

13 min read2412 words

After implementing Next.js middleware across dozens of production applications, I've learned that while middleware is incredibly powerful, recent security discoveries have fundamentally changed how we should approach authentication and authorization.

In early 2025, a critical vulnerability (CVE-2025-29927) was discovered that showed middleware-based authorization could be bypassed. This shifted the entire paradigm of how we use middleware for authentication. Let me share what I've learned about building secure, performant middleware in the post-vulnerability world.

The New Reality: Middleware Security Limitations

Before diving into implementation, it's crucial to understand the current security landscape. The recent CVE-2025-29927 vulnerability revealed that middleware alone cannot be trusted for authorization decisions.

Here's the fundamental principle I now follow:

Middleware is for routing and optimistic checks only. Never rely on middleware for data access authorization.

This means:

  • Good: Redirect unauthenticated users to login
  • Good: Add security headers or modify requests
  • Bad: Grant access to sensitive data based solely on middleware checks
  • Bad: Perform complex authorization logic in middleware

Understanding Next.js Middleware Fundamentals

Next.js middleware runs on the edge before your application renders, making it perfect for request-level operations:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
export function middleware(request: NextRequest) {
  // This runs on EVERY request that matches your matcher
  console.log('Middleware executing for:', request.nextUrl.pathname);
  
  return NextResponse.next();
}
 
// Configure which routes trigger middleware
export const config = {
  matcher: [
    // Match all paths except static files and API routes
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

The key insight is that middleware runs before page rendering, giving you the ability to:

  • Modify request/response headers
  • Redirect or rewrite URLs
  • Add authentication checks
  • Implement A/B testing
  • Handle internationalization

Secure Authentication Patterns

Based on the new security guidelines, here's how I implement authentication middleware:

Session-Based Authentication

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { decrypt } from '@/lib/session';
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Define protected routes
  const protectedRoutes = ['/dashboard', '/profile', '/settings'];
  const isProtectedRoute = protectedRoutes.some(route => 
    pathname.startsWith(route)
  );
  
  if (isProtectedRoute) {
    const sessionCookie = (await cookies()).get('session')?.value;
    
    if (!sessionCookie) {
      // No session cookie - redirect to login
      return NextResponse.redirect(new URL('/auth/login', request.url));
    }
    
    try {
      // Decrypt and validate session (lightweight check only)
      const session = await decrypt(sessionCookie);
      
      if (!session?.userId || session.expires < Date.now()) {
        // Invalid or expired session
        const response = NextResponse.redirect(new URL('/auth/login', request.url));
        response.cookies.delete('session');
        return response;
      }
      
      // Session exists - allow request to proceed
      // IMPORTANT: This doesn't grant data access, just routing access
      return NextResponse.next();
      
    } catch (error) {
      console.error('Session validation error:', error);
      const response = NextResponse.redirect(new URL('/auth/login', request.url));
      response.cookies.delete('session');
      return response;
    }
  }
  
  return NextResponse.next();
}
 
export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico|auth).*)',
  ],
};

JWT Token Authentication

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
 
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // API routes that require authentication
  if (pathname.startsWith('/api/protected/')) {
    const token = request.headers.get('authorization')?.replace('Bearer ', '');
    
    if (!token) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      );
    }
    
    try {
      // Verify JWT signature only (lightweight check)
      const { payload } = await jwtVerify(token, JWT_SECRET);
      
      // Add user info to headers for downstream consumption
      // This is optimistic - server components should re-verify
      const response = NextResponse.next();
      response.headers.set('x-user-id', payload.sub as string);
      response.headers.set('x-user-role', payload.role as string);
      
      return response;
      
    } catch (error) {
      return NextResponse.json(
        { error: 'Invalid token' },
        { status: 401 }
      );
    }
  }
  
  // Page routes requiring authentication
  if (pathname.startsWith('/dashboard')) {
    const token = request.cookies.get('token')?.value;
    
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
    
    try {
      await jwtVerify(token, JWT_SECRET);
      return NextResponse.next();
    } catch (error) {
      const response = NextResponse.redirect(new URL('/login', request.url));
      response.cookies.delete('token');
      return response;
    }
  }
  
  return NextResponse.next();
}

Proper Authorization: The Data Access Layer Pattern

Since middleware can't be trusted for authorization, implement a Data Access Layer (DAL) pattern:

// lib/dal.ts
import { cache } from 'react';
import { cookies } from 'next/headers';
import { decrypt } from './session';
import { redirect } from 'next/navigation';
 
// Cached session verification for performance
export const verifySession = cache(async () => {
  const cookie = (await cookies()).get('session')?.value;
  const session = await decrypt(cookie);
  
  if (!session?.userId) {
    redirect('/login');
  }
  
  return { isAuth: true, userId: session.userId, role: session.role };
});
 
// Data access functions with proper authorization
export async function getUserProfile(userId: string) {
  const session = await verifySession();
  
  // Verify user can access this profile
  if (session.userId !== userId && session.role !== 'admin') {
    throw new Error('Unauthorized');
  }
  
  // Fetch data from database
  return await db.user.findUnique({ where: { id: userId } });
}
 
export async function getAdminData() {
  const session = await verifySession();
  
  // Only admins can access this data
  if (session.role !== 'admin') {
    throw new Error('Admin access required');
  }
  
  return await db.adminData.findMany();
}

Use the DAL in your server components:

// app/dashboard/page.tsx
import { verifySession, getUserProfile } from '@/lib/dal';
 
export default async function DashboardPage() {
  // This will redirect if not authenticated
  const session = await verifySession();
  
  // This properly authorizes data access
  const user = await getUserProfile(session.userId);
  
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Role: {session.role}</p>
    </div>
  );
}

Advanced Middleware Patterns

Role-Based Route Protection

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
 
const ROLE_ROUTES = {
  admin: ['/admin'],
  moderator: ['/admin/moderate', '/reports'],
  user: ['/dashboard', '/profile'],
} as const;
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Check if route requires specific role
  const requiredRole = Object.entries(ROLE_ROUTES).find(([role, routes]) =>
    routes.some(route => pathname.startsWith(route))
  )?.[0];
  
  if (requiredRole) {
    const session = await getSession();
    
    if (!session) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
    
    // Optimistic role check (verify again in DAL)
    if (!hasRole(session.role, requiredRole)) {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }
  }
  
  return NextResponse.next();
}
 
function hasRole(userRole: string, requiredRole: string): boolean {
  const roleHierarchy = { user: 1, moderator: 2, admin: 3 };
  return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
}

Conditional Redirects and A/B Testing

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
export function middleware(request: NextRequest) {
  const { pathname, search } = request.nextUrl;
  
  // A/B testing for landing page
  if (pathname === '/') {
    const variant = getABTestVariant(request);
    
    if (variant === 'B') {
      return NextResponse.rewrite(new URL('/landing-b', request.url));
    }
  }
  
  // Feature flag-based routing
  if (pathname.startsWith('/new-feature')) {
    const hasAccess = checkFeatureFlag(request, 'new-feature');
    
    if (!hasAccess) {
      return NextResponse.redirect(new URL('/feature-not-available', request.url));
    }
  }
  
  // Maintenance mode
  if (process.env.MAINTENANCE_MODE === 'true') {
    const isMaintenancePage = pathname === '/maintenance';
    const isApiRoute = pathname.startsWith('/api');
    
    if (!isMaintenancePage && !isApiRoute) {
      return NextResponse.redirect(new URL('/maintenance', request.url));
    }
  }
  
  return NextResponse.next();
}
 
function getABTestVariant(request: NextRequest): 'A' | 'B' {
  // Check existing cookie first
  const existingVariant = request.cookies.get('ab-test-variant')?.value;
  if (existingVariant === 'A' || existingVariant === 'B') {
    return existingVariant;
  }
  
  // Assign new variant (50/50 split)
  const variant = Math.random() < 0.5 ? 'A' : 'B';
  
  // Set cookie (Note: This requires a response object)
  const response = NextResponse.next();
  response.cookies.set('ab-test-variant', variant, {
    maxAge: 30 * 24 * 60 * 60, // 30 days
    path: '/',
  });
  
  return variant;
}
 
function checkFeatureFlag(request: NextRequest, feature: string): boolean {
  // In production, you'd check against a feature flag service
  const userId = getUserIdFromCookie(request);
  return isUserInFeatureFlag(userId, feature);
}

Internationalization Routing

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de'];
const DEFAULT_LOCALE = 'en';
 
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Check if pathname already has a locale
  const pathnameHasLocale = SUPPORTED_LOCALES.some(locale =>
    pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );
  
  if (!pathnameHasLocale) {
    // Detect locale from headers or cookies
    const locale = detectLocale(request);
    
    // Redirect to localized version
    return NextResponse.redirect(
      new URL(`/${locale}${pathname}`, request.url)
    );
  }
  
  return NextResponse.next();
}
 
function detectLocale(request: NextRequest): string {
  // Check user preference cookie first
  const cookieLocale = request.cookies.get('locale')?.value;
  if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
    return cookieLocale;
  }
  
  // Parse Accept-Language header
  const acceptLanguage = request.headers.get('accept-language');
  if (acceptLanguage) {
    const preferredLanguages = acceptLanguage
      .split(',')
      .map(lang => lang.split(';')[0].split('-')[0])
      .filter(lang => SUPPORTED_LOCALES.includes(lang));
    
    if (preferredLanguages.length > 0) {
      return preferredLanguages[0];
    }
  }
  
  return DEFAULT_LOCALE;
}

Security Headers and CSP

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
export function middleware(request: NextRequest) {
  // Create response
  const response = NextResponse.next();
  
  // Security headers
  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('X-XSS-Protection', '1; mode=block');
  
  // Content Security Policy
  const nonce = generateNonce();
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.example.com",
    "frame-ancestors 'none'",
  ].join('; ');
  
  response.headers.set('Content-Security-Policy', csp);
  response.headers.set('X-CSP-Nonce', nonce);
  
  // HSTS for HTTPS
  if (request.nextUrl.protocol === 'https:') {
    response.headers.set(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains; preload'
    );
  }
  
  return response;
}
 
function generateNonce(): string {
  return Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64');
}

Performance Optimization

Efficient Path Matching

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
// Pre-compile regex patterns for better performance
const PROTECTED_ROUTES = /^\/(?:dashboard|profile|settings|admin)/;
const API_ROUTES = /^\/api\/(?:auth|protected)/;
const STATIC_FILES = /\.(jpg|jpeg|png|gif|ico|css|js)$/;
 
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Skip middleware for static files
  if (STATIC_FILES.test(pathname)) {
    return NextResponse.next();
  }
  
  // Handle API routes
  if (API_ROUTES.test(pathname)) {
    return handleApiAuth(request);
  }
  
  // Handle protected page routes
  if (PROTECTED_ROUTES.test(pathname)) {
    return handlePageAuth(request);
  }
  
  return NextResponse.next();
}
 
async function handleApiAuth(request: NextRequest) {
  // Lightweight API authentication logic
  const token = request.headers.get('authorization');
  if (!token) {
    return new NextResponse('Unauthorized', { status: 401 });
  }
  
  // Quick token validation (full validation in API route)
  if (!isValidTokenFormat(token)) {
    return new NextResponse('Invalid token', { status: 401 });
  }
  
  return NextResponse.next();
}
 
async function handlePageAuth(request: NextRequest) {
  // Lightweight page authentication logic
  const session = request.cookies.get('session')?.value;
  
  if (!session) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  
  return NextResponse.next();
}

Caching and Memoization

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
// Simple in-memory cache for session validation
const sessionCache = new Map<string, { valid: boolean; expires: number }>();
const CACHE_TTL = 60 * 1000; // 1 minute
 
export async function middleware(request: NextRequest) {
  const sessionId = request.cookies.get('session')?.value;
  
  if (sessionId) {
    // Check cache first
    const cached = sessionCache.get(sessionId);
    if (cached && cached.expires > Date.now()) {
      if (!cached.valid) {
        return NextResponse.redirect(new URL('/login', request.url));
      }
      return NextResponse.next();
    }
    
    // Validate session (expensive operation)
    const isValid = await validateSession(sessionId);
    
    // Cache result
    sessionCache.set(sessionId, {
      valid: isValid,
      expires: Date.now() + CACHE_TTL,
    });
    
    if (!isValid) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }
  
  return NextResponse.next();
}
 
// Clean up expired cache entries periodically
setInterval(() => {
  const now = Date.now();
  for (const [key, value] of sessionCache.entries()) {
    if (value.expires < now) {
      sessionCache.delete(key);
    }
  }
}, 5 * 60 * 1000); // Every 5 minutes

Error Handling and Debugging

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
 
export async function middleware(request: NextRequest) {
  try {
    return await handleRequest(request);
  } catch (error) {
    // Log error for debugging
    console.error('Middleware error:', {
      error: error.message,
      pathname: request.nextUrl.pathname,
      timestamp: new Date().toISOString(),
      userAgent: request.headers.get('user-agent'),
    });
    
    // Send error to monitoring service
    if (process.env.NODE_ENV === 'production') {
      reportError(error, {
        context: 'middleware',
        pathname: request.nextUrl.pathname,
      });
    }
    
    // Fail gracefully - don't block the request
    return NextResponse.next();
  }
}
 
async function handleRequest(request: NextRequest) {
  const startTime = Date.now();
  
  // Your middleware logic here
  const response = NextResponse.next();
  
  // Add performance timing header
  const processingTime = Date.now() - startTime;
  response.headers.set('X-Middleware-Time', processingTime.toString());
  
  return response;
}
 
function reportError(error: Error, context: Record<string, any>) {
  // Integrate with your monitoring service (Sentry, DataDog, etc.)
  if (typeof window !== 'undefined' && window.Sentry) {
    window.Sentry.captureException(error, { extra: context });
  }
}

Testing Middleware

// __tests__/middleware.test.ts
import { middleware } from '@/middleware';
import { NextRequest } from 'next/server';
 
// Mock NextRequest
function createMockRequest(url: string, options: any = {}) {
  return new NextRequest(url, {
    method: 'GET',
    headers: options.headers || {},
    cookies: options.cookies || {},
    ...options,
  });
}
 
describe('Middleware Authentication', () => {
  it('should redirect unauthenticated users', async () => {
    const request = createMockRequest('http://localhost:3000/dashboard');
    const response = await middleware(request);
    
    expect(response.status).toBe(307); // Temporary redirect
    expect(response.headers.get('location')).toBe('http://localhost:3000/login');
  });
  
  it('should allow authenticated users', async () => {
    const request = createMockRequest('http://localhost:3000/dashboard', {
      cookies: { session: 'valid-session-token' },
    });
    
    // Mock session validation
    jest.spyOn(require('@/lib/session'), 'decrypt').mockResolvedValue({
      userId: '123',
      expires: Date.now() + 86400000, // 24 hours
    });
    
    const response = await middleware(request);
    expect(response.status).toBe(200);
  });
  
  it('should handle invalid sessions', async () => {
    const request = createMockRequest('http://localhost:3000/dashboard', {
      cookies: { session: 'invalid-token' },
    });
    
    jest.spyOn(require('@/lib/session'), 'decrypt').mockRejectedValue(
      new Error('Invalid token')
    );
    
    const response = await middleware(request);
    expect(response.status).toBe(307);
    expect(response.headers.get('location')).toBe('http://localhost:3000/login');
  });
});
 
describe('Middleware Performance', () => {
  it('should complete within reasonable time', async () => {
    const request = createMockRequest('http://localhost:3000/dashboard');
    
    const startTime = Date.now();
    await middleware(request);
    const endTime = Date.now();
    
    expect(endTime - startTime).toBeLessThan(100); // Should complete in < 100ms
  });
  
  it('should not affect static file requests', async () => {
    const request = createMockRequest('http://localhost:3000/image.png');
    const response = await middleware(request);
    
    // Should pass through without modification
    expect(response.status).toBe(200);
    expect(response.headers.get('x-middleware-time')).toBeNull();
  });
});

Integration with Popular Auth Providers

NextAuth.js Integration

// middleware.ts
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';
 
export default withAuth(
  function middleware(req) {
    // Additional middleware logic
    const { pathname } = req.nextUrl;
    const token = req.nextauth.token;
    
    // Role-based access control
    if (pathname.startsWith('/admin') && token?.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', req.url));
    }
    
    return NextResponse.next();
  },
  {
    callbacks: {
      authorized: ({ token, req }) => {
        // Return true to allow access, false to redirect to login
        const { pathname } = req.nextUrl;
        
        // Public routes
        if (pathname.startsWith('/public')) return true;
        
        // Protected routes require authentication
        return !!token;
      },
    },
  }
);
 
export const config = {
  matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
};

Clerk Integration

// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
 
const isProtectedRoute = createRouteMatcher([
  '/dashboard(.*)',
  '/profile(.*)',
  '/admin(.*)',
]);
 
const isAdminRoute = createRouteMatcher(['/admin(.*)']);
 
export default clerkMiddleware((auth, req) => {
  // Protect routes that require authentication
  if (isProtectedRoute(req)) {
    auth().protect();
  }
  
  // Additional protection for admin routes
  if (isAdminRoute(req)) {
    auth().protect({ role: 'admin' });
  }
});
 
export const config = {
  matcher: [
    // Skip Next.js internals and all static files
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
};

The key to modern Next.js middleware is understanding its limitations and using it appropriately. Middleware excels at routing, redirects, and optimistic checks, but should never be your only line of defense for authorization. Always implement proper authorization in your Data Access Layer and server components.

Remember: middleware runs on the edge and should be fast, lightweight, and focused on request-level operations rather than complex business logic or data access authorization.