Skip to content

Securing AI Agents in Practice

A working proof of concept: agent-scoped OBO tokens, RFC 8693 token exchange, and delegation chains that show exactly which agent acted for which user.

Part 1 was about the problem. Every agent shares the same service account, and when something breaks, your logs show one credential did something — but not which agent, not which human approved it, and not whether anyone approved it at all.

I argued that OAuth 2.0 fixes this by giving each agent its own identity, so the enforcement happens at the API layer instead of hoping your application logic holds up against prompt injection.

This article is the proof.

I took an existing hotel booking assistant and added the identity layer — agent-scoped tokens, the On-Behalf-Of flow, and a token exchange service for agent-to-agent delegation.

Then I added a taxi booking agent to demonstrate chained delegation across agents. The domain is different from the banking scenario in Part 1, but the identity flow is identical:

  • A reasoning agent decides.
  • An action agent executes.
  • A human authorizes.
  • The JWT carries all of it.

The Simple Case: Searching Hotels

Before getting into tokens and delegation, it helps to see what an agent looks like when there’s no security involved at all.

The assistant agent has a tool called SearchHotelsTool. A user asks ā€œfind me a hotel in Colombo for next week,ā€ the LLM figures out the parameters — location, dates, number of guests — and calls the tool. The tool hits the hotel API’s search endpoint. No token, no authorization, no identity. The API is public, and anyone can search.

Here’s the flow:

Sequence diagram: the assistant agent searches hotels through the public Hotel API with no token

Nothing interesting happens here from a security perspective — and that’s the point. The search endpoint is read-only, it doesn’t touch customer records or financial data, so there’s no reason to gate it behind authorization. Not every tool an agent calls needs a token.

But what happens when the user picks a hotel and says ā€œbook itā€? That’s where things change. The booking endpoint modifies data — it creates a reservation, potentially charges a payment method. The same agent, the same conversation, but now the action is sensitive. The agent shouldn’t be able to do this on its own.

Booking a Hotel: Where Identity Kicks In

The user found a hotel they like and says ā€œbook it.ā€ Same agent, same conversation — but now the LLM callsĀ BookHotelToolĀ instead ofĀ SearchHotelsTool, and this is where the flow changes.

BookHotelToolĀ is wrapped in aĀ SecureFunctionToolĀ with an auth configuration that says: this tool needs an OBO token withĀ create_bookingsĀ scope. When SecureFunctionToolĀ sees that configuration, it doesn’t just call the hotel API. It first checks whether it already has a valid token. If it doesn’t — and on the first booking, it won’t — it kicks off the On-Behalf-Of flow.

What happens next is an OAuth 2.0 authorization code flow with PKCE, but from the user’s perspective, it’s simple: a popup window appears asking them to log in and approve the booking permission. Behind the scenes, the auth manager generates an authorization URL, sends it to the frontend through the WebSocket, and waits. The frontend opens the popup. The user authenticates with the Identity Provider, approves the scope, and the IdP redirects back to the agent’s callback endpoint with an authorization code. The agent exchanges that code for a token. The popup closes. The user barely noticed.

The token that comes back is the interesting part. It’s not a generic service account token. It’s an OBO token that looks like this:

{
  "sub": "951e42a5-...",          // the user who approved the booking
  "act": {
    "sub": "360909c8-..."        // the assistant agent that executed it
  },
  "aud": "7f2c4d91-...",         // the hotel booking API
  "scope": "create_bookings"
}

subĀ is the user — the person who approved the booking in the popup.Ā act.subĀ is the agent — the assistant that’s actually making the API call.Ā audĀ is the hotel booking API — this token won’t work against any other API. AndĀ scopeĀ is justĀ create_bookings — nothing else.

SecureFunctionTool injects this token into the tool’s arguments, and the tool calls the hotel booking API with it in the Authorization header. The API validates the token, checks the signature against the IdP’s public keys, checks that the audience matches itself, checks that the scope includesĀ create_bookings, checks that the token hasn’t expired. If any of these fail, the request is rejected. The agent never gets to make the booking.

Here’s the full flow:

Sequence diagram: the user approves the create_bookings scope in a popup, the assistant agent receives an OBO token, and the Hotel API validates it before booking

The key thing to notice: the agent didn’t have a standing permission to book hotels. It acquired the permission at the moment it needed it, from a specific user, with a specific scope, for a specific API. The token expires. Next time, the user has to approve again. There’s no persistent access that could be exploited if the agent gets compromised.

And if you’re the one reviewing the audit logs, you can see exactly who authorized what, which agent executed it, and which API processed it. All from one token.

Agent-to-Agent Delegation: The Taxi Handoff

After the hotel is booked, the assistant agent offers the user an airport taxi. If the user says yes, something more interesting happens — the assistant agent hands off the task to a separate taxi booking agent.

This is where it stops being a single agent calling an API and starts looking like the three-agent pattern from Part 1. The assistant agent is now playing the role of the reasoning agent — it decided that a taxi is needed and chose which agent should handle it. The taxi booking agent is the action agent — it executes the booking. The user is still the human who has to approve the sensitive action.

But there’s a problem. The user already authorized the assistant agent to book the hotel. That OBO token has the assistant agent’s identity inĀ act.sub. Now a different agent — the taxi agent — needs to call a different API — the taxi booking API — on behalf of the same user. The taxi agent can’t reuse the hotel agent’s token because the audience is wrong, the scope is wrong, and the agent identity is wrong. But we also don’t want to pop up another login window and ask the user to authorize again from scratch.

This is where token exchange comes in. The assistant agent takes its existing OBO token — the one the user already approved — and passes it to a Token Exchange Service. The taxi agent presents this parent token along with its own credentials. The Token Exchange Service validates the parent token against the IdP’s public keys, verifies the taxi agent’s identity against a registry of known agents, and mints a new token. The new token keeps the user’sĀ subĀ from the original — the user’s authorization carries forward. But it wraps the delegation in a nestedĀ actĀ chain that shows exactly how the authority flowed:

{
  "sub": "951e42a5-...",            // same user from the hotel booking
  "act": {
    "sub": "0125c5e2-...",          // taxi agent — the one making this call
    "act": {
      "sub": "360909c8-..."         // assistant agent — the one that delegated
    }
  },
  "aud": "7f2c4d91-...",            // taxi booking API
  "scope": "taxi_booking",
  "delegation_chain": ["0125c5e2-...", "360909c8-...", "951e42a5-..."]
}

Read theĀ actĀ chain from outside in: the taxi agent (0125c5e2) is acting on behalf of the assistant agent (360909c8), which was acting on behalf of the user (951e42a5). TheĀ delegation_chainĀ field is the same information flattened into a list — easier to index in a log aggregator.

The taxi booking API validates this token the same way the hotel API did — signature, audience, scope, expiry. But it also gets something the hotel API didn’t: a full chain showing how the request got here. If an auditor asks ā€œwho authorized the taxi agent to book this ride,ā€ the answer is in the token: the user authorized the assistant agent, and the assistant agent delegated to the taxi agent. Nobody in the chain acted without authorization.

Here’s the full flow:

Sequence diagram: the assistant agent delegates to the taxi agent, which exchanges the parent OBO token for a chained token before calling the Taxi API

There’s one thing worth calling out. The user never saw a second login popup. They authorized the assistant agent once during the hotel booking, and that authorization carried through to the taxi agent via token exchange. The security didn’t come from asking the user to approve every single step — it came from the infrastructure enforcing that each agent can only do what its token allows, and the delegation chain recording how the authority was passed.

This is the part that RFC 8693 was designed for. And this is also where most Identity Providers fall short.

The IdP landscape for agent-to-agent delegation is still fragmented — RFC 8693 is optional, and most providers that claim support don’t implement the full delegation semantics. I’ll cover that in Part 3.

Video demo

Hotel Booking demo:

Chained-Agent demo (Hotel Booking and Taxi Booking): in progress