Next.js 16 auth: proxy.ts is the wrong place
No. proxy.ts (formerly middleware.ts) is a routing and rewrite layer that Next.js may deploy to your CDN, detached from your rendering code — so it cannot be your authorization boundary. Use it for the logged-out redirect, and put the real check in the thing that touches data: the page, the route handler, the server action, and RLS underneath all three.
Next.js 16 renamed middleware to Proxy, and the rename is a hint about intent. If you have been gating /dashboard/* in that file and treating everything behind it as authenticated, this is the moment to stop.
Can I do my auth check in proxy.ts?
You can run one there, but you cannot rely on it. The Proxy documentation states the architecture directly:
"invoked separately of your render code and in optimized cases deployed to your CDN for fast redirect/rewrite handling, you should not attempt relying on shared modules or globals. To pass information from Proxy to your application, use headers, cookies, rewrites, redirects, or the URL."
Read that as a security statement, because it is one. The Proxy is a different execution context from the code that renders your page and reads your database. It may run on infrastructure in front of your application. It communicates with your app through request metadata — headers, cookies, the URL — all of which are inputs to your app, not facts about it.
Three consequences follow:
- Anything the Proxy "decides" reaches your app as a header or a cookie. If your page trusts
x-user-idbecause the Proxy set it, anyone who can reach your app directly can set it too. - A request that never routes through the Proxy is never checked. That is not hypothetical: CVE-2025-29927 was exactly that — a header that made Next.js skip middleware entirely, turning every middleware-only gate into no gate at all. The class of bug survives the rename, because the architecture is unchanged.
- Matchers are allow-lists you maintain by hand. Add
/admin/reportsnext sprint and forget the matcher entry, and the route is public. There is no compiler error for a route you forgot to list.
None of this makes the Proxy useless. It makes it the wrong last line.
What the Proxy is genuinely good for
Keep it, and keep it boring:
- Redirecting anonymous visitors to
/loginso they get a fast, clean bounce instead of a rendered shell. - Refreshing the Supabase session cookie so downstream reads see a fresh token.
- Rewrites, locale routing, A/B splits, canonical host redirects.
That first one is UX, not security — and naming it that way in your own head is most of the fix. A redirect improves the experience of users who are not logged in. It does not protect data from users who are.
Note one Next.js 16 change while you are here: Proxy runs on the Node.js runtime and that is not configurable, so a database call in there is now possible where it previously was not. Resist it. Per-request auth round trips in a layer that runs on every matched request is how you turn a cheap redirect into your slowest hop.
Where the check actually belongs
Put the decision next to the data, every time, with an identity you verified rather than one you were handed:
In pages and layouts. Resolve the user in the server component that renders protected content:
// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export default async function Dashboard() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
// …
}
getUser() revalidates the token with the Auth server; getClaims() verifies it against your project's JWKS. getSession() does neither on the server, so it is not a check.
In route handlers. Every app/api/**/route.ts is a public HTTP endpoint. The Proxy matcher may or may not cover it; the handler must not care.
In server actions. This is the gap that catches the most people, because actions feel like internal function calls and are not. A server action is a POST endpoint that anyone can invoke directly, whatever page it was imported into and whatever the matcher says.
In the database. RLS is the backstop that holds when all three of the above are wrong. If a query can only ever return the caller's rows, a missing check is a bug instead of a breach.
The pattern to search your repo for
"use server";
import { createClient } from "@supabase/supabase-js";
// /settings/* is already gated in proxy.ts, so this is only
// reachable by a signed-in user.
export async function cancelSubscription(subscriptionId: string) {
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
await admin
.from("subscriptions")
.update({ status: "canceled" })
.eq("id", subscriptionId);
}
- Warningapp/actions/billing.ts:15
Server Action without auth check
At the top of every Server Action, resolve and verify the current user (e.g. const { user } = await getUser(); if (!user) throw …) and authorize the specific operation before mutating data. If you do guard it with a custom helper, this warning is a false positive.
That comment is the whole vulnerability, written down. The action takes a subscription id from the caller, builds a service-role client that bypasses every RLS policy, and cancels it. A forged POST with someone else's subscription id cancels their plan — and because the client is service-role, the database will not stop it either. The Proxy gated a page; nothing gated this.
The fix is short, and it changes where the identity comes from:
"use server";
import { createClient } from "@/lib/supabase/server";
export async function cancelSubscription(subscriptionId: string) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Not authenticated");
// Scoped to the caller, and RLS still applies underneath.
const { error } = await supabase
.from("subscriptions")
.update({ status: "canceled" })
.eq("id", subscriptionId)
.eq("user_id", user.id);
if (error) throw error;
}
Two clauses instead of one, an ordinary anon-key client, and a verified user id. The .eq("user_id", user.id) is belt-and-braces on top of RLS — if the UPDATE policy is scoped correctly, the row was already unreachable.
A 60-second self-check
# 1. Server actions with no auth call in the file
grep -rlZ "use server" app/ | xargs -0 grep -Lc "getUser\|getClaims\|requireUser"
# 2. Route handlers that never resolve a user
grep -rln "export async function \(GET\|POST\|PUT\|DELETE\)" app/ --include=route.ts \
| xargs grep -Ln "getUser\|getClaims"
# 3. Anything trusting a header the proxy set
grep -rn "headers()\.get\|request.headers.get" app/ | grep -iE "user|role|admin|tenant"
Hits on 1 and 2 are the list to work through. Hits on 3 are worse than they look: a header is caller-controlled unless something in front of your app strips it, and "the Proxy sets it" is not that something.
GuardLayer flags the first shape automatically when the action writes to the database — the scan above is the real result for that server action — plus service-role keys that reach client-side code, permissive policies, and tables without RLS. It does not flag a route handler for never resolving a user, or a service-role client used in server code like the one above. Your proxy.ts gate is not checked the way middleware.ts is today either, which is one more reason not to put the decision there.
FAQ
Is authentication in Next.js proxy.ts secure? Not as your only check. The Proxy runs separately from your render code and may be deployed to a CDN, it communicates through caller-visible request metadata, and a routing bug or a forgotten matcher entry bypasses it silently. Use it for redirects; authorize where the data is.
Should I remove my auth check from proxy.ts? No — keep the redirect, it is good UX and it refreshes the session cookie. Just add real checks in pages, route handlers and server actions, and stop counting the Proxy as one.
Do server actions inherit the proxy's protection? No. A server action is a POST endpoint invocable directly by anyone. Matcher coverage of the page it lives on says nothing about who can call the action.
Can I pass the user id from proxy.ts to my app in a header? You can set the header, but your app must not trust it. Any caller reaching your app directly can send the same header. Resolve the user from the session where you use it.
Does Proxy running on Node.js mean I can query the database there? Technically yes in Next.js 16, since the runtime is Node and not configurable. It is still a bad idea: it adds a per-request round trip to a layer that runs on every matched request, and it does not make the layer a security boundary.
What does GuardLayer check here?
Server actions that mutate data with no detectable auth guard, API routes that pass request input to a dangerous sink, service-role keys reaching client-side code, and RLS gaps behind all of it. It does not yet apply its middleware-specific rules to proxy.ts.
Catch this before it ships — free
Scan your files for this and 35 other Next.js + Supabase issues, with the exact fix inline. Connect GitHub and GuardLayer checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.