Next.js Middleware in 2026: proxy.ts Migration, Auth Guards, and Rate Limiting
Konrad Bachowski
Tech lead, HeyNeuron
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.geoprocesses 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
/checkoutin the browser. Server logs and analytics tools see/checkout-controlor/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/loginindicates a brute-force attempt - Redirect rate on protected routes — sustained high redirect rate may mean session cookies are being stripped (misconfigured CDN, incorrect
sameSitepolicy) - 5xx from middleware — any unhandled error in
proxy.tsreturns a 500 and blocks the request; catch all exceptions and returnNextResponse.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.