DEV Community

Ali Suleyman TOPUZ
Ali Suleyman TOPUZ

Posted on Originally published at topuzas.medium.com on

Who Authorized the Agent? Building Real Identity and Delegation for AI Agents in .NET

What RFC 8693 actually buys you that a diagram with boxes labeled “policy” never will

The gym story I wrote about last month left me with a checklist: does every write endpoint check ownership, is the action reversible, are you logging what an agent does on someone else’s data. I ran that checklist against a scheduling assistant I’d built for a client and found two endpoints that failed it. Fixing those took an afternoon. What took a lot longer, and is the actual subject of this article, is a question the checklist doesn’t answer at all: when that scheduling assistant’s calendar agent calls the meeting API on my client’s behalf, what identity is it actually using to make that call?

I’d been putting that question off with the same answer everyone puts it off with: the agent has an API key with a scope roughly shaped like “whatever it might plausibly need,” and there’s a “policy layer” somewhere that’s supposed to catch anything that goes wrong. I’ve read a dozen posts on agent governance this year, and most of them stop at exactly that level of detail. There’s a diagram. It has boxes. One box says “identity,” one says “policy,” one says “audit log,” and arrows connect them in a way that looks complete right up until you ask what’s actually inside any of the boxes. Nothing in those diagrams would have stopped the gym story, because nothing in them distinguishes the one question that story turned on: when an agent calls a downstream API on a user’s behalf, is it impersonating that user, or is it delegating on their behalf while remaining itself? Most systems I’ve worked on, mine included until recently, don’t even ask that question, let alone answer it in a way you could audit.

So I built the thing properly, in .NET, using RFC 8693, OAuth 2.0 Token Exchange, as the actual standard underneath the “identity” box instead of a diagram of one. This is the writeup: what the RFC actually specifies, a worked three-hop example with real C# and real JWTs, what it gets you when something goes wrong, and where it stops being enough on its own.

Impersonation and delegation are not the same failure mode

Here’s the distinction RFC 8693 draws, and it matters because the two failure modes it separates look identical from a diagram but are not identical in a database.

Impersonation means principal A is given all of principal B’s rights and is, within that context, indistinguishable from B. A downstream system that receives an impersonation token has no way to know an agent was even involved. Every log entry says “alice did this,” full stop, because as far as the token is concerned, alice did do it. This is what a bare bearer token handed to an agent gives you by default: the agent holds alice’s credential, or a credential minted to look exactly like alice’s, and every API it touches sees alice.

Delegation means A keeps its own identity, and it’s explicit in the token that B authorized A to act, specifically, on B’s behalf. The downstream system sees two identities in the same token: whose authority this is being exercised under, and who is actually holding the phone. That distinction is the entire difference between “we can’t tell whether alice or her calendar agent cancelled that meeting” and “alice’s calendar agent cancelled that meeting, acting under an authorization alice gave it, and here is the token that proves it.”

Most agent systems I’ve seen default to impersonation without anyone deciding that on purpose. It’s the path of least resistance: mint a token that looks like the user’s, hand it to the agent, move on. It works right up until you need to answer “did the agent do this, or did the user,” and you find out your logs can’t tell the difference because your tokens never could either.

What RFC 8693 actually specifies

Token Exchange defines a grant type for the standard OAuth token endpoint: urn:ietf:params:oauth:grant-type:token-exchange. A client POSTs to /token with a subject_token, the token representing whoever the action is really for, and gets back a new token, possibly narrower in scope, possibly for a different audience, possibly with a different actor attached. Here's the full parameter set:

TOKEN EXCHANGE REQUEST PARAMETERS (RFC 8693 section 2.1)
--------------------------------------------------------------------
Parameter Required What it carries
--------------------------------------------------------------------
grant_type yes urn:ietf:params:oauth:grant-type:
                                 token-exchange
subject_token yes the token for whoever the action is
                                 really for (the human, or a prior
                                 delegation token)
subject_token_type yes identifies the subject token's format,
                                 e.g. urn:ietf:params:oauth:token-
                                 type:access_token
actor_token no the token for whoever is actually
                                 making this call (the agent itself)
actor_token_type cond. required if actor_token is present
requested_token_type no the format you want back
resource / audience no where this token is meant to be used
scope no the scopes you're asking to be
                                 minted into the new token
--------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

The response carries access_token, issued_token_type, and token_type, same shape as any other token response, which is part of what makes this practical: nothing downstream needs to know a token exchange happened at all, it's still just a JWT arriving in an Authorization header.

The part that actually implements delegation lives inside the token payload, in two claims the RFC defines specifically for this purpose. The act claim says who is currently acting, as a nested JSON object, not a flat string:

{
  "sub": "alice@contoso.example",
  "act": { "sub": "triage-agent" }
}
Enter fullscreen mode Exit fullscreen mode

For a chain of delegations, act nests. The RFC is explicit about the ordering, and it's the opposite of what I guessed on my first read: "the outermost act claim represents the current actor while nested act claims represent prior actors. The least recent actor is the most deeply nested."

{
  "sub": "alice@contoso.example",
  "act": {
    "sub": "calendar-agent",
    "act": { "sub": "triage-agent" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Read that from the outside in and you get the chain in reverse chronological order: calendar-agent is acting right now, triage-agent acted before it, alice is who this is all ultimately for. may_act is the other half, a preemptive statement issued into a token that says who's allowed to become the actor before any exchange happens, which is what an authorization server checks against before it agrees to mint a delegation in the first place.

None of this is what a static API key gives you. A key has no subject, no actor, no chain, no expiry worth mentioning, and it’s usually shared across every call the agent ever makes, to every downstream service, for the life of the deployment. Token exchange gives you a new, short-lived, purpose-built token for every hop, with the delegation chain baked into the token itself instead of living in a wiki page somewhere describing what the key is “supposed to” be used for.

Building the three-hop chain

Here’s the scenario I built against: a user’s request goes to a triage agent, which calls a calendar agent, which calls a meeting-scheduling API. Each hop is its own service with its own credentials, and each one has to prove, cryptographically, that it’s allowed to be standing where it’s standing in that chain.

alice --request--> triage-agent --calls--> calendar-agent --calls--> meeting-api
         (subject) (exchanges (exchanges
                                      for calendar-agent for meeting-api
                                      audience) audience)
Enter fullscreen mode Exit fullscreen mode

I went looking for an off-the-shelf identity provider that implements the delegation half of this out of the box before writing any of it myself, because I’d rather not maintain a security token service if I don’t have to. Keycloak 26.2 shipped a genuinely standards-compliant Token Exchange implementation in mid-2025, which was promising. Then I read the fine print: Keycloak’s standard token exchange handles the subject_token half fine, but as of the version I tested, it does not emit an act claim at all, delegation via actor_token is still tracked as an open feature request, not a shipped one. Duende IdentityServer doesn't implement RFC 8693 natively either; their own docs point you at the extension grant mechanism and tell you to build the exchange logic yourself. So for this article I built a minimal token exchange service directly, which turned out to be the right call anyway, because it's the only way to actually show you what's happening to the token at each hop instead of trusting a product's marketing claims about it.

The nested actor claim, as a real type

using System.Text.Json.Serialization;
public sealed class ActorClaim
{
    [JsonPropertyName("sub")]
    public string Sub { get; set; } = default!;
    [JsonPropertyName("act")]
    public ActorClaim? Act { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The exchange service

This is the piece that stands in for the authorization server. It reads the incoming subject and actor tokens, checks a delegation policy, and mints a new token with the actor chain extended by one hop. I’m using Microsoft.IdentityModel.JsonWebTokens here rather than the older System.IdentityModel.Tokens.Jwt, it's the handler Microsoft actually recommends going forward, and its JsonWebToken type gives you TryGetPayloadValue, which deserializes a claim straight into a C# type through System.Text.Json, nested objects included, instead of handing you a flat string you have to re-parse.

// dotnet add package Microsoft.IdentityModel.JsonWebTokens
// dotnet add package System.IdentityModel.Tokens.Jwt
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
public sealed class DelegationPolicy
{
    // actorId -> the audiences that actor is allowed to exchange a token for.
    // This is the "may_act" check in practice: a lookup table today,
    // a policy engine call once you have more than a handful of agents.
    private readonly Dictionary<string, HashSet<string>> _mayActFor = new()
    {
        ["triage-agent"] = new HashSet<string> { "calendar-agent" },
        ["calendar-agent"] = new HashSet<string> { "meeting-api" },
    };
    public bool CanExchange(string actorId, string audience) =>
        _mayActFor.TryGetValue(actorId, out var allowed) && allowed.Contains(audience);
}
public sealed class TokenExchangeService(
    SigningCredentials signingCredentials,
    DelegationPolicy policy,
    TimeProvider timeProvider)
{
    private readonly JsonWebTokenHandler _handler = new();
    public string Exchange(
        string subjectToken,
        string actorToken,
        string audience,
        IEnumerable<string> requestedScopes)
    {
        var subjectJwt = _handler.ReadJsonWebToken(subjectToken);
        var actorJwt = _handler.ReadJsonWebToken(actorToken);
        var actorId = actorJwt.Subject;
        if (!policy.CanExchange(actorId, audience))
            throw new InvalidOperationException(
                $"{actorId} is not authorized to act on behalf of {subjectJwt.Subject} for audience {audience}");
        // Carry the existing delegation chain forward, if the subject token
        // already has one (this is hop 2 or later), and add this hop on top.
        subjectJwt.TryGetPayloadValue<ActorClaim>("act", out var priorAct);
        var newAct = new ActorClaim { Sub = actorId, Act = priorAct };
        var now = timeProvider.GetUtcNow();
        var descriptor = new SecurityTokenDescriptor
        {
            Issuer = "https://sts.contoso.example",
            Audience = audience,
            IssuedAt = now.UtcDateTime,
            Expires = now.AddMinutes(5).UtcDateTime,
            SigningCredentials = signingCredentials,
            Claims = new Dictionary<string, object>
            {
                ["sub"] = subjectJwt.Subject,
                ["act"] = ToClaimDictionary(newAct),
                ["scope"] = string.Join(' ', requestedScopes),
                ["jti"] = Guid.NewGuid().ToString("N"),
            }
        };
        return _handler.CreateToken(descriptor);
    }
    // SecurityTokenDescriptor.Claims serializes a Dictionary<string, object>
    // graph correctly as nested JSON, but it does not know how to serialize an
    // arbitrary POCO like ActorClaim placed directly as a claim value, that's
    // how the RFC 8693 reference implementations that predate this pattern
    // ended up with an act claim that serialized as empty arrays instead of
    // the actual nested object. Flatten it to dictionaries yourself and you
    // sidestep the whole problem.
    private static Dictionary<string, object> ToClaimDictionary(ActorClaim act)
    {
        var dict = new Dictionary<string, object> { ["sub"] = act.Sub };
        if (act.Act is not null)
            dict["act"] = ToClaimDictionary(act.Act);
        return dict;
    }
}
Enter fullscreen mode Exit fullscreen mode

What the token actually looks like at each hop

Hop 1, alice authenticates normally, no exchange involved yet:

{
  "iss": "https://idp.contoso.example",
  "sub": "alice@contoso.example",
  "aud": "triage-agent",
  "scope": "chat.invoke",
  "exp": 1799999900
}
Enter fullscreen mode Exit fullscreen mode

Hop 2, the triage agent exchanges alice’s token plus its own client-credentials token for a token scoped to the calendar agent:

{
  "iss": "https://sts.contoso.example",
  "sub": "alice@contoso.example",
  "act": { "sub": "triage-agent" },
  "aud": "calendar-agent",
  "scope": "calendar.read calendar.propose_slot",
  "exp": 1799999600
}
Enter fullscreen mode Exit fullscreen mode

Hop 3, the calendar agent takes that token as its own subject_token, adds its own actor_token, and exchanges again for a token scoped to the meeting API:

{
  "iss": "https://sts.contoso.example",
  "sub": "alice@contoso.example",
  "act": {
    "sub": "calendar-agent",
    "act": { "sub": "triage-agent" }
  },
  "aud": "meeting-api",
  "scope": "meeting.schedule",
  "exp": 1799999300
}
Enter fullscreen mode Exit fullscreen mode

The meeting API never talks to the triage agent, never talks to alice directly, and still receives a token that tells it, cryptographically, all three things it needs to know: this is for alice, calendar-agent is the one calling right now, and triage-agent is the one that started this chain. Reading that chain back out is the mirror image of building it:

public static class TokenChainReader
{
    private static readonly JsonWebTokenHandler Handler = new();
    public static (string Subject, ActorClaim? Actor) ReadDelegationChain(string token)
    {
        var jwt = Handler.ReadJsonWebToken(token);
        jwt.TryGetPayloadValue<ActorClaim>("act", out var act);
        return (jwt.Subject, act);
    }
    // Returns the chain in the order it actually happened: the subject first,
    // then each actor in the order it was granted delegation, oldest to newest.
    // The RFC nests newest-first, so this walks the chain and reverses it.
    public static IEnumerable<string> FlattenChain(string subject, ActorClaim? act)
    {
        var hops = new List<string>();
        for (var current = act; current is not null; current = current.Act)
            hops.Add(current.Sub);
        hops.Reverse();
        yield return subject;
        foreach (var hop in hops)
            yield return hop;
    }
}
Enter fullscreen mode Exit fullscreen mode

Calling FlattenChain on the hop 3 token gives you ["alice@contoso.example", "triage-agent", "calendar-agent"], alice first, then the delegation in the order it actually happened, calendar-agent last because it's the one holding the phone right now.

What this actually buys you

The audit question is the one I couldn’t answer honestly before I built this. “Which agent, acting on behalf of which user, called this specific downstream action” used to mean grepping application logs across three services and hoping the correlation IDs lined up. Now it’s a property of the token itself. The meeting API’s access log can record sub, the full act chain, and the jti on every request, and answering that question later is a query against that log, not an investigation across three codebases that may or may not have logged the right thing at the time.

Revocation is the other half, and it’s the part that made the static-API-key approach genuinely uncomfortable in hindsight. A shared key used by many call sites means revoking it means rotating it everywhere it’s used, which is exactly the kind of change nobody wants to make under incident pressure. Delegation enforced at exchange time is a policy change, not a secret rotation: pull calendar-agent out of _mayActFor["triage-agent"] and the very next exchange attempt for that pair fails, while every other agent relationship keeps working untouched. Tokens already issued still carry whatever their five-minute expiry was, which caps the blast radius of a bad delegation automatically, something a long-lived API key never does on its own.

What RFC 8693 does not solve

This is the section I want to be honest about, because it’s the one most “agent governance” content skips past in the other direction, treating a working token exchange as though it were the whole answer. It isn’t. RFC 8693 answers “who is this request really for, and who is actually making it.” It says nothing about whether that specific request should be allowed. My DelegationPolicy.CanExchange check above is a lookup table, and a lookup table is a policy decision point in the loosest possible sense, it doesn't reason about resource attributes, time of day, rate limits, or the interaction between what alice is allowed to do and what calendar-agent is allowed to do on her behalf.

That’s the job of a real policy engine sitting downstream of the token validation, not instead of it. Something like this, as a Rego policy an OPA sidecar evaluates once the meeting API has already validated the token’s signature, audience, and expiry:

package meeting.authz
import future.keywords.if
import future.keywords.in
default allow := false
allow if {
    input.action == "meeting.schedule"
    input.token.aud == "meeting-api"
    "meeting.schedule" in split(input.token.scope, " ")
    current_actor := input.token.act.sub
    current_actor in data.agent_permissions["meeting.schedule"]
    input.token.sub in data.subject_permissions["meeting.schedule"]
}
Enter fullscreen mode Exit fullscreen mode

That query needs the same three pieces of information every time: who this is for, who’s actually calling, and what they’re trying to do. Token exchange is what guarantees those first two are true and can’t be forged. The policy engine, whether that’s OPA, Cedar, or something simpler you wrote yourself, is what decides whether the third one should be allowed given the first two. Neither layer does the other’s job, and a system that has only the token exchange half still can’t answer “should this be allowed,” while a system that has only the policy half is trusting whatever identity claims land in front of it without any real means to verify who actually generated them.

I went into this project assuming the RFC would feel like overkill for three services and one client. It didn’t, mostly because the alternative I’d been living with, a single API key with a scope wide enough to cover everything the agent might plausibly need, was the thing that actually felt reckless once I’d built the version that doesn’t do that. The gym story was a reminder that agents will use whatever door is left open. This is what it looks like to stop leaving one open by default.

Further reading

Tags: dotnet, oauth2, ai-agents, identity, security, csharp, authorization

Top comments (0)