Toby Allen

Four more bugs: applying last week's lesson in the wrong place

· Auth0, MCP, AI Agents, AI-Assisted Development

This is part six of building the live demo for my apidays Australia 2026 workshop with Claude Code. Part five ended with on-behalf-of (OBO) token exchange finally working end to end, after two sessions spent chasing a resource_server_identifier mismatch that six separate checks had missed. Five days later I clicked "Submit an appeal" on the live app and it broke immediately, on what looked like the exact same error family. Four separate bugs turned out to be stacked underneath it, and the lesson from last time made the first one harder to find, not easier.

An error that was lying about what broke

The message on screen was: MCP tool call failed: Unexpected token 'O', "OBO token "... is not valid JSON. That's not a real failure - it's a side effect of an unrelated bug. The Model Context Protocol SDK serialises an uncaught exception's .message straight into the tool result's text field when a tool handler throws with no try/catch of its own, and the chat backend calls JSON.parse on that same field unconditionally, expecting real { result, traceEvents } JSON back. Feed it a plain-text error string starting with "OBO token exchange failed..." instead, and the parse fails on the word "OBO" - so the error you see on screen is a JSON syntax complaint, not a description of anything that actually went wrong underneath it.

submitAppeal, the one tool handler that calls a second, downstream OBO exchange, had never wrapped that call in a try/catch, unlike every other failure path in this codebase. I asked Claude Code to fix it at two layers: catch the real error directly in submitAppeal and return it as a structured { error }, the same shape every other failure already uses, and add a .catch() backstop to the shared trace-collecting wrapper in both MCP routes, so any future tool handler that forgets to catch its own errors still returns valid JSON instead of breaking the parse for an unrelated reason.

With that fixed, the real error surfaced for the first time: "This client cannot exchange access tokens for this audience". The exact error part five had just spent two sessions running to ground.

Checking the known cause first - and it was only half right

I went in expecting to find the same bug: the OBO client's resource_server_identifier set to the wrong value. Auth0's own OBO documentation has a requirement that isn't obvious from the dashboard - the client performing the exchange must be registered as the resource server matching the subject token's own aud, completely separate from the audience being requested. Get that wrong and every other setting can check out fine while the exchange still fails, confirmed against a live tenant in the previous post.

I checked the client-grants first, expecting to find the mismatch there. Both were correctly shaped - subject_type: "user", the right audiences. Then I checked the other setting part five names, the one that isn't about resource_server_identifier at all: Allow Skipping User Consent for Verifiable First-Party Clients, on the case service's own resource server this time, and it was off. I turned it on and retried. Different error came back, not success. That's the tell that there were two separate problems, not one - fixing a real gap and still failing means you haven't found the last cause yet, you've just cleared the first one off the pile.

The same lesson, but the assumption underneath it was wrong this time

The second problem was the actual resource_server_identifier mismatch - but not for the reason I'd walked in assuming. Part five's whole story rested on one fact: the exchanging client has to match the citizen's own login token's audience. I checked that, found it correct, and nearly moved on before it occurred to me to check what submit_appeal's OBO exchange actually hands in as its subject token.

It isn't the citizen's original login token at all. submit_appeal runs inside the MCP tool handler, one hop downstream of the chat backend's own OBO exchange to reach the MCP server in the first place - so by the time submit_appeal fires its own exchange to reach the case service, the token it's holding is already the output of the first exchange, audienced for the MCP API, not the citizen's original login audience. The single OBO client from part five was correctly registered for the first hop's audience and could never simultaneously be correct for the second hop's, because that field can't hold two values and can't be edited after the client is created.

The fix needed a genuinely new client - a second OBO identity, registered against the MCP audience specifically, with its own client-grant to reach the case service, and the exchange code split so each hop uses its own explicit client credentials instead of one shared pair. Two real OBO identities for two real hops, provably distinct rather than accidentally shared.

Two chained OBO exchanges each need their own dedicated client identity - the exchanging client's resource_server_identifier must match whichever token it's actually handed, not the destination it's heading toward, and a client correctly registered for the first hop can never also be correct for a second hop with a different subject token

Applying a lesson correctly still takes real work if the mechanism under it changes shape. "Check resource_server_identifier against the subject token" was the right instinct. Finding out that this subject token wasn't the one I assumed it was is what actually closed the gap - the earlier fix's own quiet assumption had to be found and named before it could be reapplied at all.

Two bugs nobody was even looking for

The exchange itself now returned a clean 200. The app's own call to its own case service route then returned a bare 500 - a same-deployment, same-domain request failing against itself. I replayed the exact route directly with a captured token and it worked cleanly, which meant the route's logic was fine and the problem was specifically in how the live app was reaching it, not what happened once it got there.

CASE_SERVICE_URL is a Vercel environment variable marked sensitive, which means it's write-only - once set, nobody, including me, can read it back through the dashboard or the CLI to confirm what it actually contains. Rather than debug a value neither of us could see, I overwrote it with the value it should have held and triggered a redeploy, since Vercel functions only pick up new environment variables at build time. The next appeal submission went through cleanly.

A smaller fifth gap turned up on the very next click: submitting an appeal a second time on an already-submitted case silently re-ran the whole check-and-write pipeline and told the citizen it had "successfully submitted" again, with no way to tell a fresh submission from a duplicate. A one-line status check before any of that work runs now returns a distinct alreadySubmitted flag, and the system prompt tells the model to phrase that case as "already submitted, still processing" instead of congratulating a repeat.

Reading a token closely found a different problem

Somewhere in the middle of this I decoded one of the exchanged tokens to explain its nested act claim in plain English - three levels of "this client exchanged for this client which exchanged for this client," each one preserving the citizen's own identity as sub the whole way through. Laid out clearly enough to actually read, it showed something else: the citizen is the subject on every single hop, in every token this app has ever minted. The Auth0 FGA model has had a distinct agent type since early in this project, with its own delegation relations, but that identity has only ever been the thing FGA checks against - never the thing a token asserts as its own subject. The agent has a name in the authorisation model and no identity anywhere in the actual credential layer.

Agents as Principal, currently in Early Access, is Auth0's answer to that exact gap. It registers an agent as its own first-class identity - a stable agent_id, linked to the M2M client that actually authenticates on its behalf - and embeds that identity into the resulting token, either as the token's own subject for autonomous calls or as an act claim alongside the human it's acting for in delegated flows. That second mode is the shape this app is missing: the agent showing up as an actor in its own right inside the chain, rather than the citizen's own identity carrying every hop unchanged. I've written it into the build plan as the next piece of work, not a same-session fix - it needs its own registration step, its own client linkage, and a proper look at how mcpAuth.ts and the case service's independent FGA check should read an agent-asserted identity once one actually exists in the token.

Final Thoughts

Four bugs, each one only visible once the previous one stopped hiding it: the parse error hid the real OBO failure, the OBO failure hid a stale environment variable, and clearing all three revealed a UX gap nobody could have tested for while the feature was still broken. None of them were caught by a type check or a build - every one needed a person clicking the actual button. Recognising a familiar error family got me looking in the right area fast. It didn't save me from having to re-check whether the specific fact the earlier fix depended on was still true this time. It usually is. This time it wasn't, and confirming that was the one step in this whole chain that couldn't have been skipped.

With the appeal flow working end to end, the next piece of build work is wiring up Agents as Principal, so the agent this app has been modelling in FGA becomes a real credential rather than just a name in a policy.