Free quote
Back to Blog
Article
October 9, 202617 min read

Next.js Middleware in 2026: proxy.ts Migration, Auth Guards, and Rate Limiting

KB

Konrad Bachowski

Tech lead, HeyNeuron

Next.js Middleware in 2026: proxy.ts Migration, Auth Guards, and Rate Limiting

Introduction

Next.js middleware runs before every matched request — before caching, before rendering, before your route handler boots. That makes it the cheapest place in your stack to redirect, rewrite, rate-limit, and bucket users for A/B tests. But it also makes it dangerously easy to misuse as an auth boundary.

In 2026, two things changed the middleware landscape. First, CVE-2025-29927 (CVSS 9.1) proved that middleware-only auth is a critical vulnerability — attackers could add a single HTTP header and bypass every authentication check ever written in middleware.ts. Second, Next.js 16 (released October 2025) deprecated middleware.ts in favor of proxy.ts, shifting from the Edge V8 sandbox to the full Node.js runtime by default.

This guide covers both migrations, the right patterns for auth guards and rate limiting, and four scenarios where you should not use middleware at all. If you're building or maintaining a Next.js application, this pairs well with the Next.js App Router best practices guide and the Next.js authentication guide — middleware is one layer of a properly secured application, not the whole story.


What Next.js Middleware Is — and Isn't

Middleware in Next.js is a function that runs at the network layer before your app code. On Vercel, it runs on Fluid Compute with a target latency under 10 ms. On self-hosted deployments, it runs on the same Node.js process as your server.

What it can do:
- Read and modify request headers and cookies
- Redirect or rewrite the incoming URL
- Return a response directly (useful for rate limiting)
- Bucket users into A/B cohorts by rewriting the URL

What it cannot do:
- Call your database (latency destroys the <10ms target)
- Read a POST request body
- Use Node.js-only APIs (in the Edge runtime — middleware.ts in Next.js 15 and earlier)
- Serve as your only auth check (see CVE-2025-29927 below)

Middleware matched too broadly hurts throughput. In benchmarks cited by the Next.js team, disabling middleware entirely increased request throughput by approximately 50%. Scope it tightly with the matcher config.


middleware.ts → proxy.ts: What Changed in Next.js 16

Next.js 16 introduced proxy.ts to replace middleware.ts. The change is intentional: the name clarifies that this file controls the network boundary and routing layer, not arbitrary application logic.

The key differences

middleware.ts (Next.js 15 and earlier) proxy.ts (Next.js 16)
Status Deprecated in Next.js 16 Stable, preferred
Runtime Edge V8 sandbox Node.js (full runtime)
Available APIs Edge-compatible only Full Node.js
Cold start Faster (V8 isolate) Slightly slower
Edge deployment Yes (Vercel Edge Network) Yes (Fluid Compute)

The middleware.ts file is still accepted in Next.js 16 for Edge runtime use cases — it just triggers a deprecation warning and will be removed in a future version.

Migration steps

The migration is mechanical. Three changes:

1. Rename the file:

middleware.ts → proxy.ts

2. Rename the exported function:

// Before (middleware.ts)
export default function middleware(request: NextRequest) { ... }

// After (proxy.ts)
export default function proxy(request: NextRequest) { ... }

3. The config export (matcher) stays identical:

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

That's it. No logic changes required. Use the automated codemod:

npx @next/codemod@canary upgrade latest

When to keep middleware.ts

Keep middleware.ts (Edge runtime) when you need sub-5ms latency globally and your logic is lightweight (JWT verification with jose, cookie reading, URL rewriting). If you need crypto, fs, or any heavy Node.js package, proxy.ts is the right choice because the full runtime is available.


The CVE-2025-29927 Lesson: Middleware Is Not an Auth Boundary

CVE-2025-29927 (CVSS 9.1, disclosed March 2025) is the most important security lesson for anyone using Next.js middleware for authentication.

The vulnerability: Next.js internally uses an HTTP header called x-middleware-subrequest to prevent infinite loops when middleware calls itself. Attackers discovered that by sending this header in an external request, they could skip middleware entirely — bypassing every authentication check implemented there.

Affected versions: 11.1.4 through 13.5.6, 14.0 through 14.2.24, 15.0 through 15.2.2.
Patched versions: 15.2.3+, 14.2.25+, 13.5.9+.

The fix patches the header handling, but the architecture lesson remains: middleware cannot be your only auth gate.

A second vulnerability, CVE-2026-44575 (CVSS 7.5), showed that .rsc requests could bypass middleware under certain route configurations. Defense in depth is not optional.

The three-layer auth model

Request
  ↓
[Middleware / proxy.ts] ← fast rejection: no session cookie? redirect to /login
  ↓
[Route Handler / Server Action] ← verify the session is valid, not expired
  ↓
[Data Access Layer] ← check that the authenticated user can access this resource

Each layer assumes the layer above it may have been bypassed.

JWT verification at the edge (safe pattern)

// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { joseVerify } from 'jose'  // edge-compatible

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

export default async function proxy(request: NextRequest) {
  const token = request.cookies.get('session')?.value

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  try {
    await joseVerify(token, JWT_SECRET)
    return NextResponse.next()
  } catch {
    // Token invalid or expired — clear cookie and redirect
    const response = NextResponse.redirect(new URL('/login', request.url))
    response.cookies.delete('session')
    return response
  }
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/api/user/:path*'],
}

Critical: This edge check rejects unauthenticated requests fast, but your Route Handlers and Server Actions must still validate the session before touching any data. Middleware is the first gate, not the only gate.


Rate Limiting at the Edge

Middleware is the correct place for a coarse, identity-agnostic abuse limit. A rejected request here never touches your database. For finer-grained per-user quotas, add a second check in the Route Handler.

Upstash Redis + sliding window

The Upstash Rate Limit library is edge-compatible and works in both middleware.ts and proxy.ts:

// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
})

export default async function proxy(request: NextRequest) {
  const ip = request.ip ?? '127.0.0.1'
  const { success, limit, reset, remaining } = await ratelimit.limit(ip)

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: {
          'RateLimit-Limit': limit.toString(),
          'RateLimit-Reset': reset.toString(),
          'RateLimit-Remaining': remaining.toString(),
        },
      }
    )
  }

  return NextResponse.next()
}

Rate limits by endpoint sensitivity

Endpoint Limit Window Why
/api/auth/login 5 requests 1 hour Brute-force protection
/api/auth/register 3 requests 1 day Spam account prevention
/api/auth/reset-password 3 requests 1 hour Credential stuffing protection
/api/* (general) 100 requests 1 minute General API abuse protection

Self-hosted Redis alternative

For on-premise deployments or when Upstash pricing doesn't fit (Upstash free tier: 10,000 commands/day; Pro: $0.20/100K commands), use ioredis in proxy.ts (Node.js runtime) with a Lua script for atomic increment-and-expire.


Redirects, Locale Detection, and Geo Routing

URL rewrites and redirects are where middleware genuinely excels — pure routing logic with no database calls.

Locale detection

// proxy.ts
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const locales = ['en', 'pl', 'pt']
const defaultLocale = 'en'

function getLocale(request: NextRequest): string {
  const headers = { 'accept-language': request.headers.get('accept-language') ?? '' }
  const languages = new Negotiator({ headers }).languages()
  return match(languages, locales, defaultLocale)
}

export default function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl
  const hasLocale = locales.some(l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)

  if (!hasLocale) {
    const locale = getLocale(request)
    return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
  }

  return NextResponse.next()
}

Geo-based routing

The request.geo object (available on Vercel; set manually on self-hosted via a CDN header) provides country and region data:

const { country } = request.geo ?? {}
if (country === 'US') {
  return NextResponse.rewrite(new URL('/us/home', request.url))
}

GDPR note: request.geo processes IP-derived country data. If you log or store geo data, it's personal data under GDPR Article 4(1). Apply data minimisation: store only the country code, not the IP + country combination.


A/B Testing with URL Rewriting

A/B testing in middleware avoids a client-side flicker (no JavaScript required) and doesn't add a redirect round-trip. The trick is to rewrite — show a different page without changing the visible URL.

// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export default function proxy(request: NextRequest) {
  const bucket = request.cookies.get('ab-checkout')?.value

  if (!bucket) {
    // Assign bucket on first visit
    const assignedBucket = Math.random() < 0.5 ? 'control' : 'variant'
    const response = NextResponse.rewrite(
      new URL(`/checkout-${assignedBucket}`, request.url)
    )
    response.cookies.set('ab-checkout', assignedBucket, {
      maxAge: 60 * 60 * 24 * 30, // 30 days
      httpOnly: true,
      sameSite: 'lax',
    })
    return response
  }

  return NextResponse.rewrite(new URL(`/checkout-${bucket}`, request.url))
}

export const config = {
  matcher: ['/checkout'],
}

The user always sees /checkout in the browser. Server logs and analytics tools see /checkout-control or /checkout-variant.


matcher Configuration: Scoping Middleware Tightly

Every request matching the matcher pattern runs through your middleware function — including static asset requests unless you explicitly exclude them.

Always exclude static assets:

export const config = {
  matcher: [
    /*
     * Match all request paths EXCEPT:
     * - _next/static (static files)
     * - _next/image (image optimization)
     * - favicon.ico
     * - Public folder files (.svg, .png, etc.)
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

For specific protected routes, be explicit:

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/api/:path*'],
}

This is the most impactful optimization available: a site with 10,000 image assets and a broad matcher runs middleware 10,000 more times per cache miss than it needs to.


Security Hardening Checklist

Before deploying middleware to production, verify:

  • [ ] Upgraded to Next.js 15.2.3+ or 16.x — patches CVE-2025-29927 x-middleware-subrequest bypass
  • [ ] Auth checks exist at the data layer — middleware rejection is the first gate, not the only gate
  • [ ] JWT secret is ≥32 bytes — stored in environment variable, never in source code
  • [ ] Session cookie flags set — httpOnly: true, secure: true, sameSite: 'lax'
  • [ ] Session timeout configured — 24h standard apps, 15 min admin panels
  • [ ] Rotate session ID on privilege escalation — new token after re-authentication or role change
  • [ ] Matcher excludes static assets — avoids unnecessary middleware runs on images and fonts
  • [ ] Rate limits on auth endpoints — login (5/hour/IP), register (3/day/IP), password reset (3/hour/IP)
  • [ ] Block x-middleware-subrequest at CDN/load balancer — defense in depth for older deployments not yet patched
  • [ ] No database calls in middleware — use Redis/KV for state; DB calls belong in Route Handlers

Cost Breakdown by Implementation Approach

Approach Build Cost Monthly Ops Cost Best For
DIY (Redis on VPS) 8–16 hrs $5–$15/mo Self-hosted Next.js, full control
Upstash Redis 2–4 hrs Free (10K cmds/day) / $0.20 per 100K Vercel deployments, low-traffic
Vercel KV 1–2 hrs Free (256K cmds/mo) / $0.30 per 100K Vercel-only, zero config
Freelancer (setup) — $800–$1,500 one-time Team without Next.js expertise
Agency (full audit) — $2,000–$5,000 one-time Post-CVE security remediation

ROI context: A single brute-force incident on an unprotected login endpoint can expose user credentials, trigger breach notification requirements under GDPR Article 33 (72-hour window), and cost $50K–$250K in remediation. Rate limiting at the edge costs under $10/month.


Observability: Monitoring What Middleware Does in Production

Middleware failures are silent by default — a bad redirect or a rate-limit bug can affect thousands of requests before anyone notices. Add structured logging:

// proxy.ts
export default async function proxy(request: NextRequest) {
  const start = Date.now()
  const response = await handleRequest(request)

  // Log to your observability platform (Axiom, Datadog, Grafana Cloud)
  console.log(JSON.stringify({
    url: request.nextUrl.pathname,
    method: request.method,
    status: response.status,
    durationMs: Date.now() - start,
    ip: request.ip,
    // GDPR: do not log full IPs in EU — hash them
  }))

  return response
}

Key metrics to track:

  • P99 middleware latency — target <10ms; >50ms signals a database call or heavy computation that doesn't belong here
  • Rate limit hit rate — sudden spike on /api/auth/login indicates a brute-force attempt
  • Redirect rate on protected routes — sustained high redirect rate may mean session cookies are being stripped (misconfigured CDN, incorrect sameSite policy)
  • 5xx from middleware — any unhandled error in proxy.ts returns a 500 and blocks the request; catch all exceptions and return NextResponse.next() as a safe fallback

For Next.js self-hosted deployments, a lightweight monitoring stack (Sentry for errors + Netdata for system metrics) covers most cases at near-zero cost.


When NOT to Use Middleware

Scenario 1: You need database access in the check
Middleware must run in under 10ms. A database round-trip is typically 5–50ms. Put any database-dependent auth logic in a Route Handler or Server Action, not middleware.

Scenario 2: Your deployment is a static export
next export / output: 'export' has no server runtime. Middleware does not run. Use a CDN-level WAF or a serverless edge function instead.

Scenario 3: You're proxying large file uploads
Middleware runs before the request body is parsed, but it cannot read or buffer POST bodies (Edge runtime limitation). For large uploads, validate at the Route Handler level after the stream arrives.

Scenario 4: You want to run different middleware per route segment
Next.js 16 supports only a single proxy.ts file at the project root. If you find yourself writing a 400-line switch statement, split logic into composable helper functions called from a single proxy — or move per-route logic into the relevant Route Handler.


FAQ

Is middleware.ts gone in Next.js 16?

No — middleware.ts is deprecated, not removed. It still works for Edge runtime use cases in Next.js 16 but will trigger deprecation warnings. Rename to proxy.ts and rename the exported function to proxy for the preferred path. The middleware.ts file will be removed in a future major version.

Can I use prisma or drizzle in proxy.ts?

In proxy.ts (Node.js runtime), yes — technically. But you shouldn't. Database calls in proxy functions break the <10ms latency target and negate the performance benefit of intercepting requests early. Use Redis or Vercel KV for state that middleware needs to read.

Does CVE-2025-29927 still affect me if I'm on Next.js 16?

No. CVE-2025-29927 was patched in Next.js 15.2.3 and all 16.x releases. If you're on an older version, the fix is to upgrade. As a temporary mitigation, block the x-middleware-subrequest header at your CDN or load balancer before requests reach Next.js.

How do I test middleware locally?

Use next dev and test with curl or your browser. For unit tests, mock NextRequest from next/server and call your proxy/middleware function directly. For integration tests, use Playwright with baseURL set to your local dev server — page.goto('/protected') should redirect to /login when no session cookie is set.

What's the difference between a redirect and a rewrite in middleware?

A redirect (308/301/302) sends the browser to a new URL — visible in the address bar, costs a round-trip. A rewrite serves a different page internally while keeping the original URL in the address bar — invisible to the user, no extra round-trip. Use redirects for auth (you want the user to see /login), use rewrites for A/B tests and locale routing.

Can I share middleware logic with Route Handlers?

Yes. Extract the shared logic into a utility function in lib/auth.ts and import it from both proxy.ts and your Route Handlers. This is the correct pattern — route-level auth validation should mirror the middleware check but is not replaced by it.

Does middleware run on API Routes?

Yes, if the matcher pattern includes /api/:path*. This is intentional for rate limiting and auth on API endpoints. If you have public API routes, exclude them explicitly in the matcher.

How much latency does proxy.ts add compared to middleware.ts?

On Vercel, both run on Fluid Compute and target <10ms. proxy.ts (Node.js) has a slightly higher cold start than middleware.ts (Edge V8 isolate) because the Node.js process is heavier. In practice, the difference is negligible after the first request — Vercel keeps instances warm for high-traffic applications. On self-hosted deployments, proxy.ts reuses the existing Node.js process with no cold start penalty.



Conclusion

Next.js middleware (and its Next.js 16 successor proxy.ts) is most valuable when it does one thing: make a fast routing or rejection decision before the rest of your stack wakes up. The migration from middleware.ts to proxy.ts is a rename, not a rewrite. The CVE-2025-29927 lesson is architectural: treat middleware as the first gate, implement auth checks at every layer below it.

For production Next.js applications, the highest-ROI middleware investments are:
1. Session validation with early redirect (saves full page render on unauthenticated requests)
2. Rate limiting on auth endpoints (prevents credential stuffing and brute-force attacks)
3. Locale detection and geo routing (zero-JavaScript, zero-flicker i18n)

If you're building a Next.js app and need help with architecture, security hardening, or performance optimization, HeyNeuron's development team works with React and Next.js applications for clients across Europe and North America.

Further reading in this series:
- Next.js App Router best practices
- Next.js authentication guide with Better Auth
- Next.js ISR and on-demand revalidation
- Next.js Core Web Vitals optimization
- Next.js self-hosting guide
- Next.js i18n with App Router


Stay up to date with AI and automation

Subscribe to our newsletter to receive specific tips and tools once a week. Join over 2,000 subscribers.

Your data is safe. Zero spam.