Toby Allen

Three bugs Claude Code and I only caught by checking the wrong layer

· Auth0, Next.js, AI Agents, AI-Assisted Development

I've been working with Claude Code over the last week to add Auth0 Organizations coverage to a demo suite I run for showing off Auth0 session and token patterns. Three separate bugs turned up along the way, in three different parts of the stack, and each one has the same shape once you line them up: the verification I had in front of me looked thorough, and it was still checking a different layer to the one where the actual failure lived. I'll be explicit throughout about who did what, the same as I have been in earlier posts about working this way.

This post isn't a walkthrough of the Organizations demo itself - that's a separate post, still to come once a later piece of it is finished. This one is about the three moments where "I checked it and it's fine" turned out to be true and irrelevant at the same time.

The bug curl couldn't see

Claude built an Organizations B2B login demo - org selection on login, an invitation-based signup flow, org-scoped role display - and verified it with curl before handing it back to me: the login route correctly forwarded an organization parameter into the /authorize call, the page rendered, tsc and eslint were both clean. Everything green.

I clicked through it in a browser and found two things curl had no way of catching.

The first: redeeming an invitation link landed me on the site's homepage instead of back on the demo. Auth0's own generated invitation URL looks like .../login?invitation=...&organization=...&organization_name=... - and it never includes a returnTo parameter, because Auth0 has no way of knowing what routes this particular app has. The @auth0/nextjs-auth0 SDK's login handler reads returnTo off the query string, and when it isn't there, falls through to the client's own signInReturnToPath default of /. The fix was one line - set signInReturnToPath explicitly on the client - but curl checking the /authorize redirect had no reason to ever notice a parameter that only matters several steps later, at the end of a flow curl never runs.

The second was sharper. Org-scoped role display kept showing "none assigned," even after I'd confirmed - by pasting the actual Auth0 Action log output into the conversation, not by guessing - that the post-login Action had fired correctly and event.authorization.roles genuinely contained the role I'd assigned. The claim really was on the ID token. Claude went and read finalizeSession() in the SDK's own source rather than the documentation, and found that @auth0/nextjs-auth0 strips session.user down to a fixed allowlist of standard claims by default - sub, name, email, org_id, and a handful of others - unless you configure a beforeSessionSaved hook, in which case that hook fully replaces the default filtering rather than adding to it. Any custom claim an Action stamps onto the ID token simply vanishes before it reaches your application code, for a reason that has nothing to do with your Auth0 tenant configuration at all.

There was a third layer folded into the same debugging session, smaller but worth naming: after Claude fixed the beforeSessionSaved issue, the very next login attempt still showed "none assigned." The already-active session cookie from before the fix was still around, and beforeSessionSaved only runs once, at the point a session gets created - not on every subsequent read. Signing out and back in was the actual fix; the code fix alone wasn't enough until the stale session was cleared.

All three of these are the same underlying problem. curl-level verification checks the shape of a single request and response. Neither bug lived there - one needed a completed, multi-step redirect flow, and the other needed a real browser session that persists across requests, the same layer where the eventual bug actually showed up.

A curl request checking one HTTP response on the left, contrasted with a browser session persisting cookies and following redirects across multiple steps on the right, with a red X marking where each of the two bugs actually lived

The audit that found nothing, on purpose

The second story starts with a question rather than a bug: does anything in this demo suite already show off Auth0's RBAC permissions landing on an access token, checked by a real API call? I didn't know the answer offhand - the suite has close to twenty separate demo pages by this point - so I asked Claude to find out properly rather than guess from memory of what's "probably" in there.

Claude grepped every file under the app, lib, and component directories for permissions, RBAC, checkPermission, and every other pattern a real authorisation check might use, then read every single API route in the suite - not a sample, all of them - to see whether any route branches on a decoded token's permissions claim rather than just checking that a session exists. The answer came back clean: no, nowhere. The one RBAC mention anywhere in the codebase was a line of descriptive prose in an existing demo, explaining what a real downstream API could do with the identity it receives - not a description of what that demo's own mock API actually did.

What made this useful rather than just thorough was what came out of it almost for free. That same existing demo's API route already decoded the access token for display purposes, purely so the UI could show it - which made it the obvious backend pattern to build a genuine RBAC demo on top of, rather than starting from nothing. A different kind of research question to the ones I usually ask Claude to check - not "is this specific claim about Auth0 true," but "does anything in a codebase this size already do this" - and it needed a completeness argument rather than a single verdict: every route, not just the plausible-looking ones.

The header that looked right and did nothing

The third bug is the sharpest of the three, and it's not Auth0-specific at all.

The demo suite has a sidebar showing session state across a handful of "regular identity" demos - sign in anywhere in that group, see it reflected there, revoke sessions from any tab. Some newer demos, including the new Organizations ones, deliberately use their own isolated test identities instead, and are excluded from that sidebar's tracked list on purpose. Correct by design - except the sidebar still renders everywhere, and on those excluded demos it shows "Not signed in on any pattern yet," which reads as a bug the moment you're looking straight at a page you're plainly logged into.

Fixing the copy without ripping out the sidebar's legitimate cross-tab use meant getting the current route into the root layout, which Next.js App Router doesn't hand to layouts directly. The documented pattern is middleware setting a custom header, and a Server Component reading it back with headers(). Claude's first attempt did exactly that - and got it backwards. It tagged the response object's headers in the middleware, right before returning it. That typechecked clean. It produced no error at runtime. And it did precisely nothing.

headers() in a Server Component reads the request headers as received by the server - not headers middleware attaches to the outgoing response. A response header changes what the browser gets back on the wire; it has zero effect on what continues into the page-render pipeline the layout actually reads from. Both objects are called .headers, both feel equally plausible as "the place middleware writes and the app reads," and the wrong one produces no signal whatsoever that anything's wrong.

The real fix had a second wrinkle specific to this codebase: several of the middleware's own branches don't build their own response at all - they return whatever NextResponse an Auth0 SDK client's .middleware() call constructs internally, with no knowledge of this app's headers and no way to hand it extra options. Claude ended up wrapping the entire per-route dispatch logic, and, only for the one case that actually needs the header - a normal page continuing to render, since a redirect triggers a whole new browser request anyway - reconstructing a fresh tagged response afterwards using NextResponse.next({ request: { headers } }), the documented way to forward a modified request header through to the render pipeline, and manually copying across anything the original response had already set so session cookies don't quietly disappear in the process.

Final Thoughts

Three different debugging sessions, three different parts of the stack, and the same failure mode underneath all of them: a verification pass that was individually correct and still couldn't have found the bug, because it was checking a layer above or below the one where the problem actually lived. curl can confirm a redirect URL is well-formed and tell you nothing about what happens three steps later in a real browser. A type check can confirm code compiles and tell you nothing about which of two identically-named .headers objects you just wrote to. None of these were caught by looking harder at the same evidence - each one needed a different kind of check, at the layer the bug actually lived in, not the layer that happened to be easiest to automate.

The Organizations demo itself, invitation flow and all, is still getting a proper write-up once the last piece of it - a self-service invite flow using Auth0's newer My Organization API - is done. This post was really about the three moments in between that were worth remembering on their own.