I Thought My Next.js 16 Auth Was Solid. One Afternoon…
    Neura MarketNeura Market/DeepSeek
    ChatGPTChatGPTClaudeClaudeGeminiGeminiCursorCursorGrokGrokPerplexityPerplexityDeepSeekDeepSeek
    CoPilotCoPilotStable DiffusionStable DiffusionMidjourneyMidjourney
    View All Directories
    OverviewRulesPromptsMCPsAgentsGamesBlogVideosGuidesCoursesCommunityTrending
    DeepSeekBlogI 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.

    // 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.

    # 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.

    // 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.

    // 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:

    // 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.

    // 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:

    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.

    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
    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught megemma

    Five Gemma-4 models, one accelerator: what porting E2B 31B to AWS Inferentia2 taught me

    I ported the whole Gemma-4 family — E2B, E4B, 12B, 31B, and the 26B-A4B MoE — to run on...

    X
    xbill
    Hey DEV, I'm Tobore. Let's actually connect.community

    Hey DEV, I'm Tobore. Let's actually connect.

    Hey DEV, I'm Tobore. Let's actually connect. I've been on here for a while now, mostly writing and...

    L
    Laurina Ayarah
    I burned through thousands of AI tokens. Then a friend did it for freeai

    I burned through thousands of AI tokens. Then a friend did it for free

    (yep, kinda clickbait, just for the funsies 😊) At the beginning of the year, I relaunched my...

    P
    Paulo Henrique
    Claude might be saturating your machineai

    Claude might be saturating your machine

    My laptop was sitting idle with the fan at full tilt. Nothing was running that I knew of. The culprit...

    S
    Sidhant Panda
    Automated GitHub Code Reviews Using Google Geminigithubactions

    Automated GitHub Code Reviews Using Google Gemini

    I Built a Thing! TL;DR — Google Gemini-based Pull Request reviews and Issue Triaging for...

    D
    Darren "Dazbo" Lester
    What is an "agentic harness," actually?ai

    What is an "agentic harness," actually?

    I've been hearing the word "harness" thrown around a lot lately. I assumed it just meant "the IDE" or...

    T
    Tilde A. Thurium

    Stay up to date

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

    Neura Market LogoNeura Market

    Discover the best AI prompts, plugins, and resources for DeepSeek 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.

    Neura Market

    Custom AI Systems & Services

    Our team of experienced AI builders will help build custom AI systems, workflows, and solutions for your business.

    Request custom work

    Ready-made automations for this

    Workflows from the Neura Market marketplace related to this DeepSeek resource

    • Automate Blog Content Creation with Notion MCP, DeepSeek AI, and WordPressn8n · $9.99 · Related topic
    • Automate Trustpilot Review Scraping and Sentiment Analysis with DeepSeek and OpenAIn8n · $14.99 · Related topic
    • Generate AI Videos from Scripts with DeepSeek, Synthesia, and Together.ain8n · $24.99 · Related topic
    • Compare Multi-Period Financial Data from Google Sheets with DeepSeek AI Analysisn8n · $14.99 · Related topic
    Browse all workflows