DEV Community

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

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

Shubhra Pokhariya on June 10, 2026

A few months ago I was doing a final pre-release review on a client project before handing it over. Protected routes were protected. Unauthenticat...
Collapse
 
adamthedeveloper profile image
Adam - The Developer

Maybe this is my backend brain talking, but I always thought of this as an IDOR / ownership check problem rather than an auth problem.

A valid JWT tells me who you are. A protected route tells me where you can go. Neither tells me whether invoice abc123 actually belongs to you.

Most of the backends I've worked on would just scope the query from day one:

SELECT *
FROM invoices
WHERE id = $1
  AND user_id = $2
Enter fullscreen mode Exit fullscreen mode

That said, I can definitely see how people building mostly in Next.js could get a false sense of security once the route guards and JWT checks are working. The lesson is a good one: route protection != resource authorization.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Yeah, IDOR is the right framing. I was looking at it from "my auth setup should have caught this" but the missing piece was just ownership at the data layer.

On backend work you scope the query and move on. Here the route guard was working, JWT was valid, nothing threw errors, so it felt done. That's where I went wrong.

If I'd scoped the query from the first commit, this never happens.
Route protection != resource authorization. Much cleaner way to put it.

Collapse
 
99tools profile image
99Tools

Great reminder that authentication and authorization are not the same thing. The invoice example is a perfect illustration of how easy it is to feel "secure" when route protection and JWT validation are working, while object-level access control is still missing. I especially liked the three-layer mental model: route → page → data. That's a framework a lot of Next.js developers should adopt before shipping to production. Thanks for sharing this real-world lesson.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

It all looked fine at the route level, so I stopped there. Only realized the gap once I checked it at the data layer.
The three-layer model only clicked after that. Before that I was basically treating auth as one thing.

Collapse
 
itskondrat profile image
Mykola Kondratiuk

the gap between 'auth is configured' and 'auth is correct' is where almost every audit I've run has found something. role-based tests pass; object-level authorization is always the hole.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

This is such a precise way to put it. Role-based tests are easy to write and they pass, so you ship feeling good. Object-level auth doesn't even show up in most test suites because nobody thinks to test "can user A access user B's record" they just test "can a logged-in user access this route."

And the audits finding it every time makes total sense. It's almost invisible until you look for it specifically. The proxy was green, the JWT was valid, no errors anywhere. The hole was just quietly there.

Honestly the invoice thing would have never surfaced in normal QA. You'd have to deliberately try to access someone else's record to catch it. Most devs, myself included clearly, don't think to do that when everything else is working.

Collapse
 
itskondrat profile image
Mykola Kondratiuk

yeah and the reason it's not in the suite is usually that nobody wrote the helper to express the rule - you end up with test utilities built around 'request succeeds' not 'user A can't see user B's data'. once you add it you also realize the access rule isn't cleanly defined anywhere in the app either

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya

The helper doesn't exist because the rule wasn't explicit in the code until the fix. My getInvoice before the incident had no userId parameter, the test suite couldn't enforce ownership because the function didn't even accept it. The rule was "user navigated here so they probably own it," which isn't testable.

Thread Thread
 
itskondrat profile image
Mykola Kondratiuk

missing the param means the function can't express the invariant at all — ownership was an assumption of the call site, not a contract of the function. that's what makes it invisible until you're in an access incident.

Collapse
 
alexshev profile image
Alex Shev

Auth has a way of looking solid until you test the transitions: expired sessions, role changes, cached pages, middleware order, and client/server mismatch. The best auth reviews I have seen treat it like a state machine instead of a login form. Every edge between states needs a clear expected behavior.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

The state machine framing is really good. I was basically thinking about auth as a login form the whole time, check the token, check the role, done. But that misses everything that happens in between states, and that's exactly where the gaps live.

The invoice bug was kind of proof of that. The states were all technically correct, valid session, right role, proper JWT. The edge between "user can access this route" and "user can access this specific record" just had no defined behavior. Nobody had thought about it.

Expired sessions and role changes are the ones that get people most often I think. Role changes especially because you embed the role in the JWT and then update it in the DB and now they're out of sync until the token expires. That's a real gap that looks fine until it isn't.

Collapse
 
alexshev profile image
Alex Shev

Exactly. Auth usually fails in the transitions, not the happy-path checks. The record-level edge you described is the one teams miss because every individual piece looks valid: session exists, role is right, JWT parses. I like treating it as a state machine because it forces you to name the weird edges: role changed mid-session, ownership changed, token is valid but stale, and route access is not object access.

Collapse
 
alexshev profile image
Alex Shev

That route-vs-record edge is the perfect example. Auth bugs often survive because every individual state looks reasonable in isolation. The dangerous part is the transition or the join between concerns: session is valid, role is valid, record ownership is assumed. I have started to like tests that name those edges directly, because otherwise nobody feels responsible for them.

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper

Auth is difficult to understand and has many patterns, and it's quite complicated, so it's hard for me to handle. Nice work debugging auth in Next.js 16! 👍

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Yeah, auth can get pretty tricky. Glad you found it useful, thanks! 🙂

Collapse
 
circuit profile image
Rahul S

Hit almost the exact same pattern on a multi-tenant SaaS app — proxy looked bulletproof, but any authenticated user could pull other tenants' records by guessing UUIDs. The three-layer model is spot on. One thing worth double-checking though: the x-user-id header forwarding creates a subtle trust boundary. If proxy.ts has a matcher gap on a route (which you mentioned can happen), the Server Component still reads headers().get("x-user-id") — but now there's no proxy that ran to set it. If a client sends their own x-user-id header on an unmatched route, the Server Component trusts a value it didn't verify. The data layer's AND user_id = $2 would scope queries to the spoofed ID, but getUserPermissions(userId) returns someone else's permissions entirely. Re-reading the JWT directly from the cookie in the Server Component (instead of trusting the forwarded header) closes that gap even when the proxy misses.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Sharp catch.

If the proxy misses a route, the Server Component trusts a header that was never set by anything. Client sends their own x-user-id and everything downstream just accepts it.

Data query still scopes to that ID so records don't leak, but getUserPermissions is now running against the wrong user. Would be brutal to debug in production.

Reading the JWT directly from the cookie in the Server Component fixes it. More work, but you're not depending on the proxy having run.

I was trying to avoid verifying the token twice. Not worth it if the header can be spoofed.
Wouldn't have spotted that until it caused problems.

Collapse
 
voltagegpu profile image
VoltageGPU

Nice catch on the session token handling! I've run into similar issues when moving auth logic to edge runtimes — the subtleties in how tokens are scoped and refreshed can easily slip through testing. It's a good reminder that even with Next.js' improvements, custom auth flows need careful scrutiny, especially when combined with async data loading.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Yeah the edge runtime token stuff is genuinely easy to miss. You move auth logic over, basic flows work fine, and the subtle cases like refresh timing or how cookies scope across routes just don't show up until something breaks in a weird way.

The async data loading point is real too. You can have the token handling right but if the permission check and the data fetch aren't in sync you still end up in a bad state.

Custom auth flows just need that extra pass. Framework improvements help but they don't catch the edge cases specific to how your app is wired up.

Collapse
 
davidloibner profile image
David Loibner

Nice example. . I think this is the same kind of trap as treating access to a route or tool as permission for the concrete effect. The outer layer can be correct, but the actual record or action still needs its own check at the point where it becomes real.

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Exactly this. Access to a route is not the same as permission for what that route actually does. I had that backwards and it cost me a real bug to see it.

The "point where it becomes real" is the right way to think about it. For me that was the SQL query. That's where the record either gets returned or it doesn't. Everything before that is just getting you to the door, it doesn't decide what's behind it.

Once I added the AND user_id = $2 it was obvious in hindsight. The check belongs at the data, not at the route.

Collapse
 
mudassirworks profile image
Mudassir Khan

the "only built one layer and called it done" framing is the exact pattern we keep seeing in Next.js Server Actions. the action checks session.user.role === 'admin' and calls it authorized tbh. the underlying updateInvoice(id, data) call never verifies that the invoice actually belongs to that user.

we added a thin assertOwnership(resourceId, userId) wrapper that runs before any mutation in Server Actions. two lines, catches the whole class of IDOR bugs. deployed it after hitting the exact same invoice by id issue you described.

are you enforcing this at the Server Action level or lower down in the data layer?

Collapse
 
shubhradev profile image
Shubhra Pokhariya

Server Actions have the same blind spot. Role check passes and it feels done, but the mutation is wide open to any ID someone passes in.

I'm enforcing it at the data layer. The ownership check lives inside the SQL itself, same AND user_id = $2 pattern. Every data function that touches user-specific records takes userId as a required param so there's no way to call it without scoping the query. The assertOwnership wrapper is a solid pattern too, especially if you want one consistent call site across a lot of actions instead of relying on every function getting the param right. Both approaches get you to the same place.

Collapse
 
gidschge profile image
Gideon Rüscher

Interesting