I Thought My Next.js 16 Auth Was Solid. One Afternoon Proved Otherwise. — CoPilot Blog
    Neura MarketNeura Market/CoPilot
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityCoPilotCoPilot
    DeepSeekDeepSeekStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsBlogVideosGuidesCoursesCommunityPluginsTrendingGenerate
    CoPilotBlogI Thought My Next.js 16 Auth Was Solid. One Afternoon Proved Otherwise.
    Back to Blog
    I Thought My Next.js 16 Auth Was Solid. One Afternoon Proved Otherwise.
    nextjs

    I Thought My Next.js 16 Auth Was Solid. One Afternoon Proved Otherwise.

    Shubhra Pokhariya June 8, 2026
    0 views

    A few months ago I was doing a final pre-release review on a client project before handing it...

    A few months ago I was doing a final pre-release review on a client project before handing it over. Protected routes were protected. Unauthenticated users got redirected. Tokens expired correctly. I had tested it three different ways and everything held up. I was confident enough to stop thinking about it. Then I found something. A user with a valid session, the right role, and a correct JWT could still request another user's invoice if they knew the ID. No error in the logs. No failed requests. The auth was technically correct at every layer I had actually built. The problem was I had only built one layer and called it done. That afternoon changed how I think about Next.js 16 authentication entirely. And I have been building it differently ever since. ## One Thing to Check Before Writing a Line of Auth Code If you are coming from Next.js 15, `middleware.ts` is deprecated in Next.js 16 and replaced by `proxy.ts`. Same location in your project, different filename, different exported function name. `middleware.ts` still works for Edge runtime use cases but will be removed in a future version. ```ts // Before: middleware.ts export function middleware(request: NextRequest) { ... } // After: proxy.ts export function proxy(request: NextRequest) { ... } ``` The auth-relevant change is the runtime. `middleware.ts` defaulted to Edge runtime, which had limited crypto support. Verifying JWTs in Edge required lighter libraries and sometimes workarounds depending on which algorithms your tokens used. `proxy.ts` runs on Node.js runtime in Next.js 16. `jose` works completely. Any standard JWT library works. It removes the Node-crypto limitations that existed in the Edge runtime, which is a massive improvement for auth specifically. ```bash # Handles the rename and all other Next.js 16 breaking changes npx @next/codemod@canary upgrade latest # Only the middleware migration if that is all you need npx @next/codemod@canary middleware-to-proxy . ``` After running it: verify `proxy.ts` exists in your project root and the exported function is named `proxy`. Remove `middleware.ts` to avoid ambiguity. While it is still supported for Edge runtime, it is deprecated in Next.js 16 and the framework expects `proxy.ts` going forward. Keeping both files can lead to confusion about which one is actually handling requests. One important constraint: `proxy.ts` is designed for redirects, rewrites, header modifications, and direct responses. It is not intended for slow data fetching or full session management, and should not be used as your primary authorization layer. This is by design to keep the proxy focused on fast network boundary concerns. ## Why proxy.ts Is Not Your Security Layer (Even Though It Looks Like One) This is the thing almost every Next.js auth tutorial gets wrong. Not in an obvious way. In the way that passes all your tests and breaks in production. `proxy.ts` runs at the network boundary before anything renders. It reads your session cookie, verifies the JWT, checks the role, and redirects or continues. Fast, essential, genuinely useful. Every auth setup needs it. But it only sees URL patterns. When it decided my user could access `/dashboard/invoices`, its job was done. It had no idea which specific invoice ID was in the URL, who owned that invoice, or whether the person requesting it had any right to see it. From the proxy's point of view, the URL matched, the role matched, the token was valid. Green light. That gap is the invoice incident. The proxy was correct. It was just correct about the wrong thing. ```ts // proxy.ts: role-based route protection, correct and valuable const ROLE_ROUTES: Record<string, string[]> = { "/admin": ["admin"], "/dashboard": ["admin", "user", "moderator"], } // The proxy sees /dashboard/invoices/abc123 and approves it // It has no way to know if abc123 belongs to this user // Not a proxy bug. A category mismatch. ``` The proxy cannot answer ownership questions. Nobody can answer ownership questions from a URL pattern alone. You need the database for that. And the database is not where the proxy runs. Treating `proxy.ts` as the complete security layer is not a configuration mistake. It is a mental model mistake. And it is the one almost every auth tutorial makes by stopping there. ## The Check That Runs Even When Your Proxy Has a Gap Server Components render when the page renders. That sounds obvious, but the implication is worth sitting with: Server Components run after the proxy and act as a second layer of enforcement. Even if a route slips past a misconfigured proxy matcher gap, this check still runs. This is also where you catch the thing the proxy structurally cannot catch: permission-level decisions that depend on your database. ```tsx // app/dashboard/billing/page.tsx import { headers } from "next/headers" import { redirect } from "next/navigation" import { getUserPermissions, getUserInvoices } from "@/lib/data" export default async function BillingPage() { const headerStore = await headers() const userId = headerStore.get("x-user-id") // This check runs even if the proxy had a gap on this route if (!userId) { redirect("/login") return } // Role came from the JWT, fast, no DB call needed // Permissions need a DB call because they change more often than roles const permissions = await getUserPermissions(userId) if (!permissions.includes("billing:read")) { redirect("/unauthorized") } const invoices = await getUserInvoices(userId) return <BillingView invoices={invoices} /> } ``` The split between roles and permissions is intentional. Roles are stable enough to embed in a JWT. The proxy reads them without touching the database. Permissions change when you update access settings and you do not want to wait for a JWT to expire before that change takes effect. Roles in the proxy, permissions in the Server Component. That boundary matters. The `x-user-id` header gets there because the proxy sets it before forwarding the request: ```ts // Inside proxy.ts after JWT verification const requestHeaders = new Headers(request.headers) requestHeaders.set("x-user-id", payload.sub as string) requestHeaders.set("x-user-role", role) return NextResponse.next({ request: { headers: requestHeaders }, }) // Headers go on the request, not the response // Server Components read incoming request headers via headers() // Setting them on the response sends them to the browser instead // Your pages never see them if you get this backwards ``` I got this backwards the first time I wrote it. No error anywhere. The `headers()` call in the Server Component just returned null for both fields and the page redirected everyone to `/login` including fully authenticated admins. Spent an embarrassing amount of time on that one. ## The Backstop That Would Have Caught the Invoice Bug No Matter What Even if the proxy had a gap and the Server Component had a gap on the same day, this layer still holds. Every data function that returns user-specific data takes a `userId` and uses it inside the query. Not as a convenience. As the actual access control. ```ts // lib/data.ts // This is what my data layer looked like before the incident export async function getInvoice(invoiceId: string) { return db.query( "SELECT * FROM invoices WHERE id = $1", [invoiceId] ) } // Anyone with any valid session could call this with any ID // No error. No log entry. Just data returned to whoever asked. // This is what it looks like now export async function getInvoice(invoiceId: string, userId: string) { const invoice = await db.query( "SELECT * FROM invoices WHERE id = $1 AND user_id = $2", [invoiceId, userId] ) if (!invoice) { throw new Error("Not found") } return invoice } ``` The same error for "not found" and "not authorized" is intentional. Different errors let an attacker enumerate which IDs exist in your system. One generic error closes that information leak. The ownership check lives inside the SQL. It cannot be misconfigured away. It cannot be missing from a route someone added last week. It cannot have a matcher gap. The authorization is in the query itself and the query runs every single time. This is the layer that was missing from my app. The other two were correct. This one did not exist. ## What All Three Layers Look Like on One Request When an authenticated user hits `/dashboard/invoices/abc123`: ```plaintext Request arrives proxy.ts Reads auth_tokens cookie (written by your auth API on login) Verifies JWT (full Node.js runtime in Next.js 16, jose works completely) Checks role against ROLE_ROUTES Wrong role? Redirect to /unauthorized Correct role? Set x-user-id, x-user-role on request headers, continue app/dashboard/invoices/[id]/page.tsx Reads x-user-id from forwarded headers, no re-verification needed Checks billing:read permission against DB Missing permission? redirect('/unauthorized') Has permission? Call getInvoice(id, userId) lib/data.ts: getInvoice(invoiceId, userId) SELECT * FROM invoices WHERE id = $1 AND user_id = $2 No row? throw new Error('Not found') Row exists? User owns it. Return data. Component renders. ``` Three independent checks. A bug at any one of them does not open a door because the other two are still running. One setup requirement worth knowing before you build this: the proxy reads the session from a cookie. If your auth client stores tokens in localStorage by default, the proxy cannot see them at all and will redirect every authenticated request straight to login. Make sure your auth setup writes tokens to a cookie. The proxy template in the full implementation reads a cookie named `auth_tokens` set on login. The invoice incident had no bug in the proxy. No bug in the Server Component. One missing `AND user_id = $2` in a data function. That was the whole thing. ## The Mental Model in One Sentence The proxy decides who can reach a route. The Server Component decides who can render a page. The data layer decides who can see a record. All three are required. Any one of them alone will eventually have a gap. Two of them will eventually have a gap on the same day and you will be glad the third one exists. The invoice incident was not a proxy failure. Not a Server Component failure. It was a missing data layer. The first two layers were correct. The third one did not exist. That is the auth system most Next.js tutorials hand you. One layer. The other two are never mentioned. This post is part of the **Next.js 16 Auth: From Broken to Production-Safe** series. The next post covers building the complete `proxy.ts` gate with working code: JWT verification with `jose`, the role routing config, the matcher pattern that most setups get wrong, and why the header forwarding direction breaks silently when you get it backwards. The full implementation with all three layers, token refresh, Route Handler protection, and the complete Auth Guard client-side setup is at [shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security](https://shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security). Has anyone else hit a data layer gap like this? I kept assuming the proxy covered everything until it very clearly did not. Curious how common this actually is across different projects.

    Tags

    nextjswebdevjavascriptsecurity

    Comments

    More Blog

    View all
    Minimalist EKS: The Easy Waykubernetes

    Minimalist EKS: The Easy Way

    Amazon EKS manages the Kubernetes control plane, but you remain responsible for provisioning the...

    J
    Joaquin Menchaca
    Never forget to enter the Stern Grove lottery again!ai

    Never forget to enter the Stern Grove lottery again!

    Browser automation with Playwright, Python, GitHub Actions, and Entire to auto-enter San Francisco Stern Grove concert lotteries each week!

    L
    Lizzie Siegle
    A Free Screenshot Editor That Never Uploads Your Imagetypescript

    A Free Screenshot Editor That Never Uploads Your Image

    A free screenshot and image editor that runs entirely in your browser. Keeping every edit reversible and handling big phone photos, in plain TypeScript and Canvas2D.

    M
    Martin Stark
    I built a CLI to break my highlights out of Apple Booksshowdev

    I built a CLI to break my highlights out of Apple Books

    A macOS CLI + MCP server that exports Apple Books highlights to Markdown and gives AI assistants direct access to your reading notes.

    A
    Andrey Korchak
    A Developer's Guide to Agent Hooks in Antigravity CLIai

    A Developer's Guide to Agent Hooks in Antigravity CLI

    Motivation To be quite honest, "Hooks"—the shell commands we trigger at specific points...

    T
    Tanaike
    Tactical vs. Strategic Agentic AI Development — A Playbook for Developersagents

    Tactical vs. Strategic Agentic AI Development — A Playbook for Developers

    The Strategic Engineer: Why Writing Code Is No Longer Your Most Valuable Skill ...

    A
    Adewumi Saheed Adewale

    Stay up to date

    Get the latest CoPilot prompts, rules, and resources delivered to your inbox weekly.

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for CoPilot and more.

    Content Types

    • Rules
    • Prompts
    • MCPs
    • Agents
    • Guides

    Platforms

    • ChatGPT Directory
    • Claude Directory
    • Gemini Directory
    • Cursor Directory
    • Grok Directory
    • Perplexity Directory
    • DeepSeek Directory
    • CoPilot Directory
    • Stable Diffusion Directory
    • Midjourney Directory
    • All Directories

    Resources

    • Blog
    • Documentation
    • Help Center
    • Marketplace

    Legal

    • Privacy Policy
    • Terms of Service

    © 2026 Neura Market. All rights reserved.

    |

    Not affiliated with any AI platform vendors.