Skip to main content
Back to Articles
Next.jsFebruary 14, 20268 min read

Next.js Proxy Patterns for Authentication

Implement robust authentication in Next.js using proxy patterns for JWT validation, role-based access control, and edge-aware session handling.

Why Proxy for Auth

Authentication logic traditionally lives in individual page components or API route handlers. This approach leads to duplicated code, inconsistent enforcement, and the risk of accidentally exposing protected pages. In current Next.js projects, proxy-based request handling provides a cleaner centralized place for authentication checks before route rendering continues.

Proxy executes early in the request lifecycle and still works well with edge-aware authentication flows. This guide covers production patterns for proxy-based authentication, aligned with our server optimization best practices.

Basic Auth Proxy

JWT Token Validation

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";

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

const publicPaths = ["/", "/login", "/register", "/forgot-password", "/api/auth"];
const apiPaths = ["/api/"];

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

  // Allow public paths
  if (publicPaths.some((path) => pathname === path || pathname.startsWith(path + "/"))) {
    return NextResponse.next();
  }

  // Allow static assets
  if (pathname.startsWith("/_next/") || pathname.includes(".")) {
    return NextResponse.next();
  }

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

  if (!token) {
    if (apiPaths.some((path) => pathname.startsWith(path))) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }
    return NextResponse.redirect(new URL("/login", request.url));
  }

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);

    const response = NextResponse.next();
    response.headers.set("x-user-id", String(payload.sub));
    response.headers.set("x-user-role", String(payload.role));
    return response;
  } catch {
    // Token expired or invalid
    const response = apiPaths.some((path) => pathname.startsWith(path))
      ? NextResponse.json({ error: "Token expired" }, { status: 401 })
      : NextResponse.redirect(new URL("/login", request.url));

    response.cookies.delete("auth-token");
    return response;
  }
}

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

Role-Based Access Control

Multi-Level Authorization

// lib/auth-proxy.ts
interface RouteConfig {
  path: string;
  roles: string[];
  methods?: string[];
}

const protectedRoutes: RouteConfig[] = [
  { path: "/admin", roles: ["admin"] },
  { path: "/admin/users", roles: ["admin"] },
  { path: "/dashboard", roles: ["admin", "manager", "user"] },
  { path: "/settings", roles: ["admin", "manager"] },
  { path: "/api/admin", roles: ["admin"], methods: ["GET", "POST", "PUT", "DELETE"] },
  { path: "/api/reports", roles: ["admin", "manager"], methods: ["GET"] },
];

export function checkRouteAccess(
  pathname: string,
  method: string,
  userRole: string
): { allowed: boolean; reason?: string } {
  const matchedRoute = protectedRoutes.find(
    (route) => pathname === route.path || pathname.startsWith(route.path + "/")
  );

  if (!matchedRoute) {
    return { allowed: true };
  }

  if (!matchedRoute.roles.includes(userRole)) {
    return {
      allowed: false,
      reason: `Role '${userRole}' cannot access ${pathname}`,
    };
  }

  if (matchedRoute.methods && !matchedRoute.methods.includes(method)) {
    return {
      allowed: false,
      reason: `Method ${method} not allowed for role '${userRole}' on ${pathname}`,
    };
  }

  return { allowed: true };
}

Integrating RBAC into Proxy

// proxy.ts (updated with RBAC)
import { checkRouteAccess } from "./lib/auth-proxy";

export async function proxy(request: NextRequest) {
  // ... token validation from above ...

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    const userRole = String(payload.role);
    const { pathname } = request.nextUrl;

    const access = checkRouteAccess(pathname, request.method, userRole);

    if (!access.allowed) {
      if (pathname.startsWith("/api/")) {
        return NextResponse.json(
          { error: "Forbidden", message: access.reason },
          { status: 403 }
        );
      }
      return NextResponse.redirect(new URL("/unauthorized", request.url));
    }

    const response = NextResponse.next();
    response.headers.set("x-user-id", String(payload.sub));
    response.headers.set("x-user-role", userRole);
    return response;
  } catch {
    // ... error handling ...
  }
}

Session Management

Sliding Session with Token Refresh

// lib/session.ts
import { SignJWT, jwtVerify } from "jose";

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const TOKEN_LIFETIME = 60 * 60; // 1 hour
const REFRESH_THRESHOLD = 15 * 60; // Refresh when less than 15 min remaining

export async function createToken(user: {
  id: string;
  email: string;
  role: string;
}): Promise<string> {
  return new SignJWT({
    sub: user.id,
    email: user.email,
    role: user.role,
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(`${TOKEN_LIFETIME}s`)
    .sign(JWT_SECRET);
}

export async function shouldRefreshToken(token: string): Promise<boolean> {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    const exp = payload.exp || 0;
    const now = Math.floor(Date.now() / 1000);
    return exp - now < REFRESH_THRESHOLD;
  } catch {
    return false;
  }
}

export async function refreshToken(token: string): Promise<string | null> {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return createToken({
      id: String(payload.sub),
      email: String(payload.email),
      role: String(payload.role),
    });
  } catch {
    return null;
  }
}

Auto-Refresh in Proxy

// In proxy.ts, after successful token validation:
const needsRefresh = await shouldRefreshToken(token);

if (needsRefresh) {
  const newToken = await refreshToken(token);
  if (newToken) {
    const response = NextResponse.next();
    response.cookies.set("auth-token", newToken, {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: TOKEN_LIFETIME,
      path: "/",
    });
    response.headers.set("x-user-id", String(payload.sub));
    response.headers.set("x-user-role", String(payload.role));
    return response;
  }
}

This pattern silently extends active sessions without requiring the user to re-authenticate, while expired sessions redirect to login.

CSRF Protection

For applications using cookie-based sessions, add CSRF protection in middleware:

function validateCSRF(request: NextRequest): boolean {
  if (["GET", "HEAD", "OPTIONS"].includes(request.method)) {
    return true; // Safe methods don't need CSRF protection
  }

  const csrfToken = request.headers.get("x-csrf-token");
  const csrfCookie = request.cookies.get("csrf-token")?.value;

  if (!csrfToken || !csrfCookie) return false;
  return csrfToken === csrfCookie;
}

Rate Limiting by Auth Status

Apply different rate limits for authenticated and anonymous users:

// lib/rate-limiter.ts
const rateLimits = new Map<string, { count: number; resetAt: number }>();

export function checkRateLimit(
  identifier: string,
  limit: number,
  windowMs: number
): { allowed: boolean; remaining: number; resetAt: number } {
  const now = Date.now();
  const entry = rateLimits.get(identifier);

  if (!entry || now > entry.resetAt) {
    rateLimits.set(identifier, { count: 1, resetAt: now + windowMs });
    return { allowed: true, remaining: limit - 1, resetAt: now + windowMs };
  }

  if (entry.count >= limit) {
    return { allowed: false, remaining: 0, resetAt: entry.resetAt };
  }

  entry.count++;
  return { allowed: true, remaining: limit - entry.count, resetAt: entry.resetAt };
}

// In middleware:
const isAuthenticated = !!payload;
const identifier = isAuthenticated ? `user:${payload.sub}` : `ip:${request.ip}`;
const limit = isAuthenticated ? 1000 : 100; // per minute
const { allowed, remaining } = checkRateLimit(identifier, limit, 60000);

if (!allowed) {
  return NextResponse.json({ error: "Rate limit exceeded" }, {
    status: 429,
    headers: { "X-RateLimit-Remaining": String(remaining) },
  });
}

Testing Proxy Logic

import { describe, it, expect } from "vitest";
import { checkRouteAccess } from "./lib/auth-proxy";

describe("Route Access Control", () => {
  it("blocks non-admin from admin routes", () => {
    const result = checkRouteAccess("/admin/users", "GET", "user");
    expect(result.allowed).toBe(false);
  });

  it("allows admin to access admin routes", () => {
    const result = checkRouteAccess("/admin/users", "GET", "admin");
    expect(result.allowed).toBe(true);
  });

  it("allows all authenticated users to access dashboard", () => {
    const result = checkRouteAccess("/dashboard", "GET", "user");
    expect(result.allowed).toBe(true);
  });

  it("blocks unauthorized API methods", () => {
    const result = checkRouteAccess("/api/reports", "DELETE", "manager");
    expect(result.allowed).toBe(false);
  });
});

Best Practices

  • Always use httpOnly and secure flags on auth cookies
  • Set sameSite: "lax" or "strict" to prevent CSRF via cookies
  • Keep JWT payloads small — only include essential claims (user ID, role)
  • Use short token lifetimes (1 hour) with sliding refresh
  • Log failed authentication attempts for security monitoring
  • Handle token refresh transparently — users should never see auth interruptions
  • Combine middleware auth with infrastructure-level security for defense in depth

Proxy-based authentication provides a clean separation of concerns, consistent enforcement across all routes, and the performance benefits of early request handling.

Need help with this?

Our team handles this kind of work daily. Let us take care of your infrastructure.