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
httpOnlyandsecureflags 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.
Related Articles
Deploying Next.js 16 to Kubernetes: The Complete Production Guide
A complete guide to deploying Next.js 16 to Kubernetes in production, including multi-stage Dockerfile, K3s deployment manifests, health checks, HPA, Cloudflare Tunnel integration, environment variables, and Prisma in containers.
Next.jsNext.js on K8s: Solving the 5 Most Common Production Issues
Five common production issues when running Next.js on Kubernetes and how to fix each one: missing CSS with standalone output, image optimization in containers, ISR with shared cache, Node.js memory leaks, and graceful shutdown.
Next.jsHow We Run Next.js at Scale on K3s with Zero Downtime
A production-grade guide to running Next.js on K3s with zero downtime — container registry, CI/CD pipelines, rolling updates, Cloudflare CDN and Tunnel, Prometheus monitoring, and automated cache purging.