How To Get Over Your Trust Issues: A Beginner’s Intro to OBO Auth Flow

How To Get Over Your Trust Issues: A Beginner’s Intro to OBO Auth Flow

Sorry, not THOSE kinds of trust issues. I don’t have the license for that.

This post is an intro to On-Behalf-Of auth flow, an OAuth 2.0 grant mechanism that lets a service exchange a user’s incoming token for a new one, so it can call a downstream service while still acting as that user. The explanation and examples are high level and simplified so that you can follow along and understand even if you have little technical background in the realm of auth.

The premise

If you’re building a system where AI agents call other services on a user’s behalf, like other agents, LLMs, and MCP servers, then you’re bound to face this question:

When my agent calls something else, how does that something else know it’s me behind this request?

If the answer is obvious to you, then this might not be a useful post for you. But if, like me, this stumped you, then keep reading to see how I learned what OBO flow, what it replaces, is and how it solves this problem, at a high level.

The easy way

The first thing that came to mind for me when approaching the issue of auth when users call agents was to just give each agent its own credential, for it to pass through on every outbound call it makes. And whatever it’s calling (a downstream service or another agent, etc.) just checks the valid credential and says “yes, this is a legitimate caller, let ’em through”.

This is simple, and for a while, it worked fine while developing the system I was working on.

Where this is fine

At first, I didn’t see the need for another type of auth, because the only users of the system I was working on (which was basically an agent governance platform for developers) were onboarding simple agents, mostly just demo/test agents. I’ll give you an example so you understand what I mean.

Say I build an agent called WeatherBot. Its whole job is: when you ask it about the weather in some city, it tells you info about the forecast. To enable that functionality, I wire it up to a weather API that it calls with its own API key.

The request (abstracted a bit for simplicity) might look something like:

{

"caller": "weatherbot-agent",

"api_key": "wb_asdfghjk...",

"request": "forecast for Philadelphia"

}

The thing is, it doesn’t matter WHO is asking in this case. The forecast for Philly is the same whether I ask, you ask, or a cron job asks. So when WeatherBot makes the request, the API key that’s passed tells the weather service “a legitimate caller is asking,” and that’s the whole check that needs to happen. Nobody’s identity is relevant to the answer.

Where it breaks

Once the system started being used by real people onboarding real agents that needed to do real things, I began to realize that just passing a single simple credential through doesn’t actually work very well. To illustrate why, let’s walk through another example.

Say I build a second agent, BillingBot, and its job is to assist customers and customer service reps in interacting with a company’s billing and accounts system. A customer can ask BillingBot things like “what’s my current balance?” or “explain my last bill”.

I might start the same way I did with WeatherBot: one shared API key, passed through on every call to the billing service behind it:

{

"caller": "billingbot-agent",

"api_key": "bb_asdfghjkl...",

"request": "what's my balance?"

}

Take a moment to think about what problems this shared credential approach would have.

Problem 1: Whose account to look up?

Say 2 customers, Louella and Geoffrey, both use the company in question.

When Louella asks BillingBot “what’s my balance?”, BillingBot calls the billing service with the shared API key and… well, now what? The key doesn’t say anything about the request belonging to Louella. All it just says is “this is a legitimate call from BillingBot.”

So how will the downstream service know WHOSE account to look up? Brainstorming here, I could write some logic to try and determine the account number of the calling customer, or make the customer provide their account number somehow, then pass that in the request as a plain parameter:

{

"caller": "billingbot-agent",

"api_key": "bb_lasdfghjkl...",

"account_id": "louella-4471",

"request": "what's my balance?"

}

This feels fine… right? Well, up until you notice what security property it actually has. (None).

account_id in this case is just a field in a request body. Nothing really stops it from saying "geoffrey-5503" instead–like a bug in my own prompt handling, a confused conversation, or someone deliberately abusing the system. That’s because the billing service never actually verified WHOSE account this was supposed to be. It already decided to trust the request the moment the shared credential was verified.

So what’s stopping Louella from sending a request pretending to be Geoffrey, as long as she knows his account number?

Nothing. Or, just her morals, I guess. But we all know Louella–she has an evil heart. She can’t be trusted. Big troublemaker. Oh, how do I know she’s bad news? Well I don’t have proof, but she just gives me bad vibes, you know? Like a malicious aura. Anyway…

The point is that because it’s a shared credential, the request has no ability to tell you anything about who the call is actually coming from.

Problem 2: What permissions does the calling user have?

Say that hypothetically I somehow fix problem 1, such that the account ID is always correct, every time. That means, in our hypothical, when Louella makes a request, it’ll always use "account_id": "louella-4471", and when Geoffrey makes a request, it’ll always pass in "account_id": "geoffrey-5503".

Unfortunately, there’s still a second gap waiting right behind it. Can you see what it is?

I’ll give you a hint–customers aren’t the only type of user of this system.

Let’s look at Carlos, who works support for the company, and also uses BillingBot–but HE has different sets of permissions that regular customers like Louella and Geoffrey don’t have, such as ability to view ANY customers’ account, and perform elevated actions such as apply discounts to customer bills.

Using our shared credential model, when Carlos makes a request to apply a discount to Geoffrey’s account, it looks like:

{

"caller": "billingbot-agent",

"api_key": "bb_asdfghjkl...",

"account_id": "geoffrey-5503",

"request": "apply a 5% loyalty discount"

}

But with just one shared API key for the whole BillingBot agent, the billing service has exactly one bit of information: “is this a legitimate BillingBot call, yes or no.” There’s no way for it to additionally ask “okay, but is THIS specific person allowed to apply a discount?”, because the credential doesn’t represent a person at all–it represents the tool, and the tool’s permissions are whatever I hard-coded them to be, for EVERY caller, forever.

So there’s nothing stopping Louella from making the following request successfully using the BillingBot:

{

"caller": "billingbot-agent",

"api_key": "bb_asdfghjkl...",

"account_id": "louella-4471",

"request": "apply a 100% discount"

}

See? Told you Louella was a troublemaker.

Basically, there’s no way to express “reps can, customers can’t” using a credential that was never attached to an individual in the first place.

Problem 3: Auditability

Now you might be thinking: “What if instead of 1 shared credential for everyone using this agent, we had 2? 1 for customers to use, and 1 for reps to use?”. Yep, that was also my train of thinking at first, too.

But we still have the problem of not being able to tell WHO made the original request.

So let’s say someone made a change to Geoffrey’s account, upgrading his account from Tier 2 to Tier 1, which is more expensive. 2 weeks later, Geoffrey asks why his bill increased. For auditability, I probably programmed the bot to record what actions were performed when, and that is all fine and dandy–we can see when the upgrade request was made. Except, how do we know WHO made the actual request to upgrade Geoffrey’s account? Was it Geoffrey himself? Was it a customer service rep, like Carlos?

Yes, with the 2 different API keys solution, we’d be able to attribute the action to someone with elevated permissions (i.e. at least narrow it down to whether it was a customer service rep or not). But we wouldn’t have a way to know WHO exactly it was. And that makes it harder to get info we need, such as tracking down the rep to ask for context, or looking up the relevant conversation transcript.

And now Geoffrey is yelling at me because he claims he never asked to have his account upgraded. Gee, it’d sure be great to be able to see who the request came from so we know who to blame ask politely.

Something better

Now that you see the problem through my eyes, I won’t leave you hanging without a solution–that would be so mean of me. And I’m not Louella. Allow me to finally introduce: On-Behalf-of auth flow.

Going along with the earlier example, when Louella logs in and asks about her balance, her user context needs to survive all the way through to the billing service, not get flattened into “a request from BillingBot” the moment it crosses a service boundary. Same thing for Carlos, the customer service rep: when he applies a discount, it needs to arrive at the billing service still labeled “Carlos,” so the billing service can check HIS permissions, not some generic tool permission.

On-Behalf-Of (OBO) authentication is the mechanism for exactly this: instead of a static, shared credential, the system takes the user’s own already-verified login credential (an access token or identity token, in standard terms) and exchanges it for a new one (scoped to whatever’s being called next) while keeping the same person’s identity attached. This exchange is formalized in the industry as OAuth 2.0 Token Exchange (RFC 8693), and Microsoft’s specific implementation of it is literally called the On-Behalf-Of flow.

To OBO or not to OBO?

Ask yourself if the answer, or the permission to act, depends on which specific human is asking. The moment it does, a shared machine credential stops being able to properly handle auth. Then, it’s time for OBO implementation.

Breaking down what’s inside the token

The token we’re talking about is the thing you get when you log into something, like a website or application. To understand the auth flow, it helps to know what’s actually inside a token. These are usually JWTs (JSON Web Tokens), and they have a bunch of fields (called “claims”) that provide info about the user and the services involved. To wrap your head around this, we can informally represent what claims would be present in one (although keep in mind a real one is more complex):

  • Audience: What downstream service the token is for. Represented by the aud claim.
  • Requester: Which app asked for the token. Represented by the azp (authorized party) or client_id claim.
  • Permissions: What actions the token allows in the downstream service. Represented by the scope claim (called scp in Entra) for delegated permissions, and/or the roles claim.
  • User: Which human the token ultimately belongs to, which stays attached no matter how many times the token changes hands. In generic OAuth/OIDC, this is the sub claim. (In Entra, which we’ll get to later, sub is unique per app, so it changes at each hop. The oid claim is the one that stays stable.)

Based on that, here’s a mock OBO token Louella would get when she logs in to ask BillingBot about her balance:

{

"audience": "billingbot-agent",

"requester": "genericcompany-website",

"permission": "chat-with-billingbot",

"user": "louella@example.com"

}

We can use this simple representation of a token to wrap our head around how the token exchange process works in OBO flow.

How OBO actually works

Here’s a handy diagram so you can follow along in the next section where I explain it step by step:

sequenceDiagram
    actor Louella
    participant BillingBot
    participant IDP as IdP
    participant Billing as Billing Service

    Louella->>BillingBot: "What's my balance?"<br/>(+ her client token, Tc)
    Note over BillingBot: Tc: audience=billingbot-agent,<br/>user=louella@example.com

    BillingBot->>IDP: Authenticate as itself<br/>(its own credential)
    IDP-->>BillingBot: T1 (proof of BillingBot's own identity)
    Note over BillingBot: Only now can BillingBot attempt<br/>an exchange on Louella's behalf

    BillingBot->>IDP: Token exchange request<br/>(Tc + T1)
    IDP-->>BillingBot: BillingBot's token for Billing Service
    Note over BillingBot: audience=billing-service, user=louella@example.com—<br/>same user, new audience

    BillingBot->>Billing: Same original request<br/>(+ BillingBot's token for Billing Service)
    Note over Billing: Checks user identity<br/>and their permissions

    Billing-->>BillingBot: Louella's balance
    BillingBot-->>Louella: "Your balance is $42.00"

Now let’s actually walk through the process. Louella logs in to the company’s website and opens the chat window, and asks BillingBot for her balance. BillingBot now holds Louella’s token:

{

"audience": "billingbot-agent",

"requester": "genericcompany-website",

"permission": "chat-with-billingbot",

"user": "louella@example.com"

}

But to actually answer her, BillingBot needs to call the billing service–a completely different downstream service it doesn’t have a token for yet. So it needs a NEW token, scoped for the billing service, that still says “this is from Louella”, not “this is from BillingBot, acting generically” (like the shared credential version from before did).

That token exchange is performed by an identity provider (IdP), such as Microsoft Entra, which issues the new token scoped to the billing service, all without BillingBot ever seeing Louella’s password or needing her to log in again. For simplicity, I’m abstracting this step a bit–in practice, the IdP would independently verify both sides before it lets identity move forward: BillingBot firstly authenticating as itself, and secondly presenting Louella’s token alongside that proof.

Now BillingBot holds this OBO token, scoped to the billing service:

{

"audience": "billing-service",

"requester": "billingbot-agent",

"permission": "view-own-bill",

"user": "louella@example.com"

}

Then, when the billing service receives the request with that token, it can make the real decision with the full user context: “This is for Louella, she has view-only permissions in her own account, so look up the account that belongs to her, and only that one.” Geoffrey’s account is never even reachable, because the service knows precisely whose bill it’s allowed to show. And there’s no way for Louella, with that token, to try to apply a discount to her account, because based on that token, the downstream service knows she doesn’t have that permission.

And Carlos’s case runs through the exact same exchange, starting from HIS token:

{

"audience": "billing-service",

"requester": "billingbot-agent",

"permission": "apply-discount",

"user": "carlos@example.com"

}

Which gets passed with his actual API request:

{

"token": "<obo-token>",

"target_account": "louella@example.com",

"request": "apply a 5% discount"

}

The billing service looks at the token and checks: “Does carlos@example.com have apply-discount permission? If yes, proceed with the request.”

The account being modified is Louella’s, but the identity whose permissions actually get checked is Carlos’s.

Yay! This is exactly what we want.

OBO auth is ensuring that the user’s identity that travels with a request has to be whoever is actually RESPONSIBLE for the action, which isn’t always the same person the action is ABOUT.

Abstracting the example

To drive the point home, I’m including a more high-level abstracted version of the diagram that illustrates the process, for reference:

sequenceDiagram
    actor User
    participant Agent
    participant IDP as Identity Provider (IdP)
    participant Downstream as Downstream Service

    User->>Agent: Request + Tc (client token)
    Note over Agent: Tc: aud=agent, user=User —<br/>only lets it talk to the Agent itself

    Agent->>IDP: Authenticate as itself<br/>(its own credential)
    IDP-->>Agent: T1 (proof of Agent's own identity)
    Note over Agent: Only now can Agent attempt<br/>an exchange on User's behalf

    Agent->>IDP: Token exchange request<br/>(Tc + T1)
    IDP-->>Agent: Agent's token for Downstream Service
    Note over Agent: aud=downstream-svc, user=User —<br/>same user, new audience, identity carried forward

    Agent->>Downstream: Same original request<br/>(+ Agent's token for Downstream Service)
    Note over Downstream: Checks user identity<br/>and their permissions

    Downstream-->>Agent: Response scoped to User only
    Agent-->>User: Final response

Extending it further

Everything above was a single hop: BillingBot exchanged Louella’s client token (Tc) and its own proof (T1) for a new token scoped to one downstream service. But I’ve got a cool fact to share with you: nothing about this mechanism is limited to just one exchange. The same trick can be repeated at every service boundary, so each agent in the chain exchanges whatever token it was just handed for a new one scoped to the next service. And at every single hop, the user’s identity gets carried forward untouched.

sequenceDiagram
    actor User
    participant AgentA as Agent A
    participant IDP as Identity Provider (IdP)
    participant AgentB as Agent B
    participant Downstream as Downstream Service

    User->>AgentA: Request + Tc (client token)
    Note over AgentA: Tc: aud=agent-a, sub=User —<br/>only lets it talk to Agent A itself

    AgentA->>IDP: Authenticate as itself<br/>(its own credential)
    IDP-->>AgentA: T1 (proof of Agent A's own identity)
    Note over AgentA: Only now can Agent A attempt<br/>an exchange on User's behalf

    AgentA->>IDP: Exchange request<br/>(Tc + T1)
    IDP-->>AgentA: Agent A's token for Agent B
    Note over AgentA: aud=agent-b, sub=User —<br/>same user, new audience

    AgentA->>AgentB: Forward request<br/>(+ Agent A's token for Agent B)
    Note over AgentB: aud=agent-b, sub=User —<br/>Still says sub=User

    AgentB->>IDP: Authenticate as itself<br/>(its own credential)
    IDP-->>AgentB: T1 (proof of Agent B's own identity)
    Note over AgentB: Only now can Agent B attempt<br/>its own exchange

    AgentB->>IDP: Exchange request<br/>(Agent A's token for Agent B + T1)
    IDP-->>AgentB: Agent B's token for Downstream Service
    Note over AgentB: aud=downstream-svc, sub=User —<br/>same user, new audience again

    AgentB->>Downstream: Forward request<br/>(+ Agent B's token for Downstream Service)
    Note over Downstream: Checks user identity<br/>and their permissions

    Downstream-->>AgentB: Response scoped to User only
    AgentB-->>AgentA: Response
    AgentA-->>User: Final response

And this matters a lot once you’re not just calling a plain backend service, but chaining multiple AI agents together (an A2A, or agent-to-agent, call). Say Agent A can’t fully answer a request itself, so it calls Agent B, which in turn needs data from some other downstream service. Without OBO, that second hop is the notorious “shared credential” problem all over again, where Agent B would just see “a legitimate call from Agent A,” with no idea which human originally asked. but WITH OBO, Agent A exchanges its token for one scoped to Agent B, and when Agent B needs to call the downstream service, it exchanges its token for one scoped to the that.

Notice what never changes across all those hops: user=User. The audience narrows at every exchange in

agent-a → agent-b → ... agent-n → downstream service 

but the user identity riding along with it doesn’t degrade no matter how many agents or services are involved in between.

That’s the main takeaway: OBO gets each hop a token properly scoped to the one service it’s calling while keeping the real human’s identity attached the entire way down the chain.

That’s what makes OBO the right tool for multi-agent systems specifically: you can chain as many A2A calls as your architecture needs, and the downstream service at the end of the chain still gets to make its authorization decision based on the real human who kicked the whole thing off.

How this looks in the real world with Microsoft Entra Agent ID

Everything above is a simplified mental model. In the real world, Microsoft ships this pattern in Microsoft Entra Agent ID, an identity platform built for AI agents. Here are some of its real concepts, which map onto what we already covered:

  • Agent identity blueprint: The template agents are created from. It’s a first-class identity in Entra ID that holds the shared auth configuration, and the credential (a certificate, a client secret, or a federated identity credential backed by a managed identity) for every agent identity created from it.
  • Agent identity: The per-agent instance created from a blueprint (i.e. BillingBot). It’s the identity that performs the OBO exchange, but it doesn’t hold its own credential. It relies on a token its parent blueprint obtains for it.
  • The user’s client token (Tc): The access token the signed-in user’s client already holds, with its audience set to the agent identity blueprint.
  • The blueprint’s token (T1): Before the exchange, the blueprint authenticates with its credential and gets back T1, scoped to Entra’s token-exchange resource (api://AzureADTokenExchange/.default) and tied to the specific agent identity doing the exchange.
  • The OBO exchange: The agent identity sends both T1 and the user’s token (Tc) to Microsoft Entra ID using the jwt-bearer grant with Microsoft’s requested_token_use=on_behalf_of parameter, and gets back a resource token scoped to the downstream API.

To relate this back to my BillingBot scenario, that means BillingBot can’t just announce who it is. Its blueprint has to authenticate with a credential that’s completely separate from the login token Louella, Geoffrey, or Carlos gave it, and the resulting token has to be tied to BillingBot specifically. That proves it’s really BillingBot, and not an impostor agent masquerading as BillingBot. On top of that, Louella’s token has to have been issued for BillingBot’s blueprint from the moment she logged in (that’s the aud claim we covered earlier). If either check fails, the exchange gets rejected and BillingBot never gets a token scoped to the billing service.

Recap

So just to recap, heres what OBO solves:

  1. Whose account? Token carries the user’s identity, so billing service looks up the right account (instead of trusting a field in the request body)
  2. What are they allowed to do? Service checks the USER’s permissions, not a blanket tool permission, so Louella (a customer) and Carlos (a rep) can have different abilities.
  3. Who did it? The user’s identity travels with the request, so the service can log exactly who made it, and Geoffrey’s mystery upgrade has a name attached.

Conclusion

Thanks to OBO, the billing service no longer has to take BillingBot’s word for anything: every request carries a token saying who’s asking and what they’re allowed to do.

And finally, our long-standing trust issues are solved. (Well, the technical kind, anyway).

Leave a Reply

Profile image of Katrina

Hi, I’m Katrina!

I’m a software engineer at the beginning of my career with interests in social justice issues, tech, and finance. Here, you’ll find blog posts on whatever I feel like writing about. Welcome!

Latest Posts