Next.js 16's proxy.ts route protection breaks in five specific ways, and every one of them is invisible until production traffic hits. The matcher either doesn't exist or excludes the wrong paths, burning a Supabase auth call on every static asset request. The setAll cookie handler is skipped, so sessions never refresh and users get logged out at exactly the token TTL boundary. The next= parameter accepts protocol-relative URLs, opening a redirect to attacker domains. The proxy trusts getSession() despite the Supabase docs explicitly warning against it. A redirect response is created without forwarding the cookies that proxy just refreshed, silently logging the user out on the redirect destination. Each has a one-pass fix.
The Next.js proxy.ts authentication pillar covers the migration from middleware.ts, the basic Supabase auth implementation, and the two-layer security model (proxy + Server Actions). This post catalogues the implementation-level failure modes that the pillar's broad overview deliberately leaves for a deeper piece: the matcher behavior the Next.js 16.2.6 docs document explicitly, the cookie handler invariant the @supabase/ssr package depends on, the open-redirect attack class the defensive startsWith check addresses (and the bypass it misses), the Supabase-documented pivot from getSession() to getClaims(), and the response-object preservation rule that breaks redirects.
TL;DR:
- The matcher burns auth calls on static assets. The Next.js docs are explicit: without a matcher, proxy runs on every request including
_next/static,_next/image, andpublic/assets [1]. Each one hits Supabase. The fix is a negative-match pattern with all common static-asset prefixes excluded. - The setAll cookie handler is mandatory, not optional. Supabase calls
setAllwhenever the library needs to write cookies, including after a token refresh [2]. If you only implementgetAll, the refresh writes nowhere. Users get logged out at exactly the access-token TTL boundary. startsWith('/')is not enough to block open redirects. Anext=//attacker.comparameter starts with/(twice) but the browser parses//attacker.comas a protocol-relative URL pointing to a different origin. CWE-601 catalogs this as URL Redirection to Untrusted Site [5]. The fix is the additional!startsWith('//')and!startsWith('/\\')guards.getSession()in proxy is unsafe per the Supabase docs. Verbatim: "Never trustsupabase.auth.getSession()inside server code such as Proxy. It isn't guaranteed to revalidate the Auth token" [2]. UsegetClaims()(signature validation against published public keys, every call) orgetUser()(network call to the Auth server) instead.- A new NextResponse drops the refreshed cookies. Token refresh writes cookies onto the response object the
setAllhandler closed over. If you create a freshNextResponse.redirect(...)later in proxy, the refreshed cookies are on the wrong object. Forward them, or useresponse.cookiesto construct the redirect.
Table of contents
- Why does Next.js proxy.ts route protection break in production?
- How does Next.js 16 proxy.ts fit into the request lifecycle?
- What are the 5 proxy.ts route protection failure modes and how do you fix each?
- How do you wire route protection in proxy.ts the secure way?
- When should you not rely on proxy.ts at all?
- proxy.ts is the perimeter, not the policy
Why does Next.js proxy.ts route protection break in production?
Route protection in proxy looks deceptively simple. Three globs of paths, an authenticated check, a redirect on failure. The failure modes don't appear in the local-dev round trip because the proxy runs on a hot Node process with a warm Supabase session, and the matcher fires on a single request, not the dozens of asset requests a real page load produces. Production loads expose all of them at once.
The Next.js 16.2.6 docs are explicit about what proxy can and can't see [1]. Without a matcher, proxy runs on every request, including static files, image optimizations, and public/ assets. Server Functions are not separate routes in the execution chain; they are handled as POST requests to the route where they are used, which means a proxy matcher that excludes a path also skips the Server Function calls on that path. The runtime defaults to Node.js (no runtime config option is allowed in proxy files, and setting one throws an error). The codemod that renames middleware.ts to proxy.ts does NOT rewrite the function body, so any subtle bug in the original middleware is now subtly broken in proxy with no diff to review.
The five failure modes below are the ones we see most often when auditing Next.js + Supabase deployments. Each has a primary-source citation, each has a deterministic fix, and each is invisible in a local dev round trip because the symptoms only show up under production conditions: parallel asset requests on first paint, token refresh at the TTL boundary, redirect-aware attackers, the Supabase docs revision history, and the response-object lifecycle that the cookies API closes over.
How does Next.js 16 proxy.ts fit into the request lifecycle?
Proxy runs after headers and redirects from next.config.js and BEFORE filesystem routes, dynamic routes, and the rewrites chain [1]. The Next.js docs document the full execution order: headers → redirects → Proxy (rewrites, redirects) → beforeFiles → filesystem routes → afterFiles → dynamic routes → fallback rewrites. Proxy sits at the start of the application-controlled phase, which is why it's the right place for auth: it can short-circuit the entire downstream chain.
The Next.js 16 release notes are explicit: middleware is deprecated and renamed to proxy, and proxy defaults to the Node.js runtime [1]. The two changes together remove the historical friction with @supabase/ssr, which depends on Node.js APIs that the prior Edge-runtime middleware couldn't reliably provide. The codemod handles the file rename and the function-name rename. It does NOT touch the matcher, the cookie handlers, or the auth logic. Migrations from middleware.ts to proxy.ts carry every bug the original middleware shipped with.
One more thing the docs document directly: even when _next/data is excluded in a negative matcher pattern, proxy will still be invoked for _next/data routes [1]. Verbatim from the docs: "This is intentional behavior to prevent accidental security issues where you might protect a page but forget to protect the corresponding data route." The Next.js team treats route protection as a security boundary, not a hint. The proxy gets the data route whether you matched it or not.
What are the 5 proxy.ts route protection failure modes and how do you fix each?
The five failure modes below cover the implementation-level surface where most Next.js proxy.ts deployments break. Each is a documented attack class, a Supabase docs revision, or a Next.js runtime invariant. Each has a one-pass fix.
Failure 1: The matcher burns Supabase auth calls on every static asset request
Signature: First paint loads 12 static assets and shows 12 entries in the Supabase Auth logs for the same user, all within 80ms of each other. Pages feel slow on the first hit. The Supabase free-tier auth quota burns through faster than expected.
Root cause: The Next.js docs are explicit, verbatim: "Without a matcher, Proxy runs on every request, including static files (_next/static), image optimizations (_next/image), and assets in the public/ folder" [1]. Each request that reaches the proxy function runs the full createServerClient + getUser() sequence, including the network call to the Supabase Auth server [3]. A page with 12 static assets and no matcher fires 12 auth calls on first paint. Even with a matcher, common omissions (forgetting public/ icons, font subset paths, .json manifests, apple-touch-icon.png) leave a long tail of asset paths that still hit the proxy.
The Next.js docs frame the impact clearly: "Without a matcher, ... auth logic or redirects can unintentionally block CSS, JS, or images from loading" [1]. The behavior is documented, not accidental. Proxy is invoked for every matching request by design; the matcher is the only way to scope it.
Fix:
// proxy.ts
export const config = {
matcher: [
// Match every path EXCEPT the listed static-asset prefixes
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|json)$).*)',
],
}
Three rules for a tight matcher:
- Negative match the asset prefixes.
_next/staticand_next/imageare the Next.js prefixes;public/content shows up under root paths (/favicon.ico,/sitemap.xml,/robots.txt), not under/public/. - Negative match the asset file extensions. A user might add
apple-icon.pngorog-image.jpgtoapp/directly; those resolve at root. The trailing extension list covers them. - Add font and manifest extensions if you self-host fonts.
.woff,.woff2,.ttffor fonts;.jsonformanifest.jsonand web-app-manifest endpoints.
The docs also note one intentional exception: even when _next/data is excluded in a negative matcher, proxy will still run for _next/data routes [1]. That's by design; don't fight it. Your auth check needs to cover the data route alongside the page route, which is what the matcher exclusion would have skipped.
Failure 2: The setAll cookie handler is skipped and sessions never refresh
Signature: Users get logged out at exactly the access-token TTL boundary (default 1 hour on Supabase free tier; configurable on the dashboard). Local development looks fine because the dev server restarts before the TTL expires. Production support tickets describe "I was using the app, came back from lunch, had to log in again." The session was refresh-able the entire time, but the refresh wrote to nowhere.
Root cause: The Supabase docs are explicit, verbatim: "setAll is called whenever the library needs to write cookies, for example after a token refresh" [2]. The token refresh path inside @supabase/ssr constructs a new access token from the refresh token, then calls setAll to persist it. If you only implement getAll (the read path), the refresh never lands in the user's cookies. The next request still carries the OLD access token, which is now expired, and the session ends.
The docs explain why this matters in Next.js specifically, verbatim: "Next.js Server Components can't write cookies, you need a Proxy to refresh expired Auth tokens and store them" [4]. Server Components are read-only on cookies; the proxy is the only writable cookie path. Skip setAll in proxy, and there is no writable cookie path at all.
Fix: Implement setAll exactly as the Supabase docs example shows. Both the request cookies AND the response cookies need the new value, because the request cookies feed any downstream createServerClient calls inside the same proxy invocation and the response cookies are what the browser receives:
// proxy.ts (the cookie handler block, expanded for clarity)
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
// 1) Write back to the request cookies so further reads see the new value
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value)
})
// 2) Replace response with a fresh NextResponse.next that has the updated request headers
response = NextResponse.next({
request: {
headers: request.headers,
},
})
// 3) Write to the response cookies so the browser actually receives them
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options)
})
},
},
}
)
The three steps inside setAll are not optional. The request-cookie write keeps proxy-internal calls consistent; the response replacement clears any stale response object; the response-cookie write is what actually persists the refreshed session to the client. Skip any of the three and the symptom changes: skip the request write and a subsequent call inside the same proxy run sees the old token; skip the response replacement and any header changes you made earlier are dropped; skip the response cookie write and the refresh never reaches the browser. The Supabase guide refers to the response-cookie write directly, verbatim: "Passing the refreshed Auth token to the browser, so it replaces the old token. This is accomplished with response.cookies.set" [4].
Failure 3: The next= parameter accepts a protocol-relative URL bypass
Signature: A phishing email links to https://your-app.com/login?next=//attacker.com/credentials. The user logs in. After the post-login redirect, they land on https://attacker.com/credentials with a UI that mimics the dashboard.
Root cause: Most defensive snippets check that next.startsWith('/'). That is necessary but not sufficient. The URL //attacker.com starts with / (twice), and the WHATWG URL parser interprets //host as a protocol-relative URL [5]. The browser resolves //attacker.com/credentials against the current document's scheme, then navigates to attacker.com. CWE-601 catalogs this exact pattern as URL Redirection to Untrusted Site, also known as the open-redirect class [5].
Variations to watch for:
next=//attacker.com/x(the canonical protocol-relative bypass)next=/\\attacker.com/x(some URL parsers normalize\to/before checking)next=https://attacker.com/x(absolute URL; defeats thestartsWith('/')check entirely if the developer didn't add it)next=javascript:alert(1)(older browsers may not block; even modern ones throw a console warning rather than failing silently)
Fix: Layer the defensive checks. Reject any value that doesn't start with /, that starts with //, that starts with /\, or that contains a scheme:
// proxy.ts (open-redirect-safe next= handling)
function safeRedirectTarget(next: string | null): string {
if (!next) return '/dashboard'
if (!next.startsWith('/')) return '/dashboard'
if (next.startsWith('//')) return '/dashboard'
if (next.startsWith('/\\')) return '/dashboard'
// Block embedded schemes (defense in depth; modern browsers also block these)
if (/^\/[a-z][a-z0-9+\-.]*:/i.test(next)) return '/dashboard'
return next
}
// Usage in the auth-route redirect path:
if (authRoutes.some((route) => path.startsWith(route)) && user) {
const next = request.nextUrl.searchParams.get('next')
const destination = safeRedirectTarget(next)
return NextResponse.redirect(new URL(destination, request.url))
}
The function is allow-list shaped (start by assuming the value is unsafe, validate to OK), not deny-list shaped (assume safe, reject known-bad patterns). Allow-lists fail closed; deny-lists fail open as soon as a new bypass shows up. For belt-and-braces, consider also setting Referrer-Policy: no-referrer on the auth routes themselves so the next= value doesn't leak via the Referer header to third-party scripts loaded on the login page [6].
Failure 4: Trusting getSession() in proxy when the Supabase docs say not to
Signature: Code review or audit asks "why are you using getSession() here?" because the Supabase Next.js guide opens with a warning about it. Behavior in production is silent: getSession() returns a session for any user with a non-expired refresh token, including a user whose account was suspended, deleted, or had their tokens revoked. The proxy decides the user is authenticated, the Server Action decides the same, the request runs against the database with the wrong identity.
Root cause: The Supabase guide for Next.js server-side auth is explicit, verbatim: "Always use supabase.auth.getClaims() to protect pages and user data. Never trust supabase.auth.getSession() inside server code such as Proxy" [2]. The reason is documented: getSession() "isn't guaranteed to revalidate the Auth token" [2]. It reads the local session state from the cookies, which makes it fast but blind to revocation. A user whose tokens were revoked 30 seconds ago still has a valid-looking session in getSession() until the access token expires (default 1 hour).
getClaims() is the per-request safe choice, verbatim from the same Supabase guide: "it validates the JWT signature against the project's published public keys every time" [2]. Signature validation runs locally against the cached JWKS, no network round trip, but it catches the most common revocation cases (key rotation, project shutdown, signature forgery). Use getUser() only when you need the freshest possible user state; the docs flag explicitly that getUser() "performs a network request to the Supabase Auth server" [3], which is the network call you might want to skip on every static asset request (see Failure 1).
Fix: Pick the right call for the proxy job. For route protection (presence-of-session check on protected paths), getClaims() is the recommended call: signature-verified, no network round trip, cheap enough to run on every protected request. For sensitive operations that need to catch revocation within seconds, getUser() in the Server Action (NOT proxy) is the right place.
// proxy.ts (getClaims pattern, per Supabase docs)
const { data: claims } = await supabase.auth.getClaims()
const user = claims?.claims ?? null
// Continue with the same protected/auth/admin redirect logic the pillar shows
if (protectedRoutes.some((route) => path.startsWith(route)) && !user) {
return NextResponse.redirect(new URL('/login', request.url))
}
The pillar's example uses getUser() directly because it predates Supabase's documentation pivot to getClaims(). Both calls work; getClaims() is faster and now the documented default for proxy. If you're starting fresh, use getClaims(). If you have an existing getUser() proxy that works, keep it but be aware that you're paying one network call per protected request. The trade-off is: getClaims() won't detect a revocation that happened in the last hour without the JWKS being rotated; getUser() will. For a route-protection layer that defers real authorization to the Server Action, getClaims() is enough.
Failure 5: A new NextResponse for redirect drops the refreshed session cookies
Signature: Users log in, get refreshed at the TTL boundary, then get redirected to /dashboard. On /dashboard, the session is gone again. The pattern: random user log-outs during navigation, especially after a long idle period. Logs show a 302 from proxy followed by a 401 on the next request.
Root cause: The setAll cookie handler closes over the response variable in the proxy function's scope. When token refresh fires, the new cookies are written to whichever response object existed at the moment of the closure. If proxy later constructs a NEW response (typically NextResponse.redirect(...) for a route-protection redirect), that new response is a fresh object with no cookies. The refreshed access token was written to the OLD response, which is no longer being returned. The browser sees the redirect with no Set-Cookie header, keeps the OLD (expired) access token, and the next request fails auth.
The Supabase docs frame the cookie-set responsibility directly, verbatim: "Passing the refreshed Auth token to the browser, so it replaces the old token. This is accomplished with response.cookies.set" [4]. The implicit constraint: whatever response you return must be the one the cookies were written to.
Fix: Forward the refreshed cookies onto every fresh NextResponse you construct inside proxy. Two patterns work:
// Pattern A: forward cookies onto every redirect response
function redirectWithCookies(url: URL, sourceResponse: NextResponse): NextResponse {
const redirect = NextResponse.redirect(url)
sourceResponse.cookies.getAll().forEach((cookie) => {
redirect.cookies.set(cookie.name, cookie.value, cookie)
})
return redirect
}
// Usage:
if (protectedRoutes.some((route) => path.startsWith(route)) && !user) {
return redirectWithCookies(new URL('/login', request.url), response)
}
// Pattern B: construct the redirect via the existing response object
if (protectedRoutes.some((route) => path.startsWith(route)) && !user) {
// NextResponse.redirect doesn't take a base response, so use the Pattern A helper.
// OR, use NextResponse.next + a header rewrite if your platform supports that.
// Pattern A is the safer default.
}
Pattern A is the safer default and the one you'll see in most Supabase + Next.js auth examples that handle this edge case. The trade-off versus simply returning NextResponse.next() is one extra cookie iteration per redirect, which is negligible compared to the silent-logout symptom it prevents.
How do you wire route protection in proxy.ts the secure way?
Combine the five fixes into one proxy file. The version below mirrors the pattern SecureStartKit ships today in its middleware.ts (awaiting the codemod-driven rename to proxy.ts). Three route categories (protected, auth, admin), one cookie-handler block that satisfies the @supabase/ssr contract, getClaims() per Supabase docs, the safe-redirect helper for open-redirect resistance, and the cookie-forwarding helper for the response-object preservation rule.
// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
const protectedRoutes = ['/dashboard', '/settings', '/billing']
const authRoutes = ['/login', '/signup', '/reset-password']
const adminRoutes = ['/admin']
function safeRedirectTarget(next: string | null): string {
if (!next) return '/dashboard'
if (!next.startsWith('/')) return '/dashboard'
if (next.startsWith('//')) return '/dashboard'
if (next.startsWith('/\\')) return '/dashboard'
if (/^\/[a-z][a-z0-9+\-.]*:/i.test(next)) return '/dashboard'
return next
}
function redirectWithCookies(url: URL, source: NextResponse): NextResponse {
const redirect = NextResponse.redirect(url)
source.cookies.getAll().forEach((cookie) => {
redirect.cookies.set(cookie.name, cookie.value, cookie)
})
return redirect
}
export async function proxy(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value)
})
response = NextResponse.next({
request: {
headers: request.headers,
},
})
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options)
})
},
},
}
)
const { data: claims } = await supabase.auth.getClaims()
const user = claims?.claims ?? null
const path = request.nextUrl.pathname
if (protectedRoutes.some((route) => path.startsWith(route)) && !user) {
return redirectWithCookies(new URL('/login', request.url), response)
}
if (adminRoutes.some((route) => path.startsWith(route))) {
if (!user) {
return redirectWithCookies(new URL('/login', request.url), response)
}
const adminEmails = process.env.ADMIN_EMAILS?.split(',') ?? []
if (!adminEmails.includes(user.email as string)) {
return redirectWithCookies(new URL('/dashboard', request.url), response)
}
}
if (authRoutes.some((route) => path.startsWith(route)) && user) {
const next = request.nextUrl.searchParams.get('next')
const destination = safeRedirectTarget(next)
return redirectWithCookies(new URL(destination, request.url), response)
}
return response
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|json)$).*)',
],
}
This is the entire route-protection layer. Every fix from the five failure modes is present:
- Matcher excludes the common static-asset prefixes and file extensions (Failure 1)
- setAll writes to both request and response cookies and replaces the response object (Failure 2)
- safeRedirectTarget rejects
//,/\, and embedded schemes (Failure 3) - getClaims instead of getSession or getUser (Failure 4)
- redirectWithCookies forwards refreshed cookies to every redirect response (Failure 5)
Inspect this against the shipped middleware.ts to confirm: the protected/auth/admin patterns, the next.startsWith('//') guard, and the matcher exclusions are present in production. The two upgrades that this version layers on are the getClaims switch and the redirectWithCookies helper; the latter is what catches the silent-logout-on-redirect class.
When should you not rely on proxy.ts at all?
Proxy is a perimeter, not a policy. The Next.js docs say it explicitly, verbatim: "Server Functions are not separate routes in this chain. They are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path. Always verify authentication and authorization inside each Server Function rather than relying on Proxy alone" [1]. Translation: proxy can stop a page navigation, but it can't stop a direct POST to a Server Action endpoint on a path the matcher missed. The pillar Next.js proxy.ts authentication guide covers the full two-layer model.
The layered model: proxy redirects unauthenticated navigation away from protected pages; Server Actions verify identity and permissions on every privileged operation; Row Level Security on the database enforces the last line of defense. Each layer is independent. The proxy getting renamed from middleware.ts to proxy.ts in Next.js 16 doesn't change that. The matcher excluding a Server Action path doesn't change that. The proxy returning the wrong response object doesn't change that. The other two layers still hold.
If you're auditing an existing app, the proxy is the cheapest layer to fix (one file, the five failure modes above), the Server Action layer is the most important to verify (the pre-launch audit Check 3 catalogues the recurring patterns), and the RLS layer is the one that needs the most code review (one bad policy and the whole layer falls open; the RLS debugging walkthrough catalogues the symptoms).
proxy.ts is the perimeter, not the policy
Route protection in proxy is a narrow job done correctly: scope the matcher to non-asset paths, write the cookie handler exactly as the @supabase/ssr docs prescribe, reject open-redirect bypasses with an allow-list shaped helper, prefer getClaims() for the per-request check, and forward refreshed cookies to every fresh response. The five failure modes above are the implementation traps; the fixes are deterministic.
SecureStartKit ships a middleware.ts (awaiting codemod rename to proxy.ts) with the protected/auth/admin pattern, the setAll cookie handler, the open-redirect protection, and the getUser() call from the older Supabase docs era. The migration to getClaims() is a documented Supabase docs revision, not a SecureStartKit policy. If you're starting a new project on the SecureStartKit foundation or auditing an existing Next.js + Supabase deployment, walk the five failure modes against your middleware.ts (or proxy.ts) and fix the ones that need fixing. The Next.js security hardening checklist Step 1 starts at the proxy layer and works outward; that's the order to take an existing app through.
Built for developers who care about security
SecureStartKit ships with these patterns out of the box.
Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.
References
- proxy.js, Next.js Docs— nextjs.org
- Setting up Server-Side Auth for Next.js, Supabase Docs— supabase.com
- getUser(), Supabase JavaScript Reference— supabase.com
- Creating a Supabase client for SSR, Supabase Docs— supabase.com
- CWE-601: URL Redirection to Untrusted Site (Open Redirect)— cwe.mitre.org
- Referrer-Policy, MDN Web Docs— developer.mozilla.org
Related Posts
Supabase Password Reset in Next.js: 5 Failure Modes [2026]
Supabase password reset breaks in 5 ways. Referer leaks, session fixation, token reuse, weak OTP windows, email enumeration. The fix for each.
Supabase OAuth, Magic Links, MFA in Next.js [2026]
Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.
Pre-Launch Security Audit: 12 Checks That Matter Most [2026]
Pre-launch security audit for Next.js + Supabase: 12 highest-impact checks of 30, in audit order, with triage rules. Run weeks before launch.