When one AI agent hands work to another, authorization quietly falls apart. A practical C# design for signed delegation chains, intersection authority, shared budgets and untrusted results, orchestrated with Dapr Workflow.

TL;DR When agents delegate to other agents, OAuth tokens record the chain but don't enforce it. This post shows a .NET design where every hand-off is a signed block that can only narrow authority, a YARP gateway enforces it on every tool call, and Dapr Workflow makes the whole thing durable. Full C# code included.

AI agents now send email, move money and change cloud settings, and they increasingly hand work to other agents. An orchestrator delegates to a specialist, which calls a tool, which may live at another company.

Several people have proposed binding agent authority to human intent (see Related work at the end). This post focuses on what's still missing for most teams: a concrete design you can build in .NET today, with code for each piece.

The problem

A user asks Agent A to do something. A delegates part of it to Agent B, and B calls a tool. At that tool, we should be able to answer:

  1. Did a human actually authorize this action?
  2. Through which chain of agents did it travel?
  3. Was each agent's authority narrower than the one before it?

Standard OAuth token exchange (RFC 8693) can record a chain with nested act claims, but a receiving system is told to consider only the top-level claims and the current actor. The chain is audit data, not authorization data. MCP and A2A solve connectivity, not authority.

The threats

Threat What happens
Privilege escalation B uses its own broad permissions to do what A couldn't
Confused deputy A tricks B into using B's access on A's behalf
Intent drift The task changes as it's reworded at each hop
Injected subtasks The task A sends B was shaped by a prompt injection A read
Poisoned results B's reply is untrusted content that A then acts on
Runaway fan-out One request spawns many sub-agents, each with a full allowance
Revocation lag The user cancels, but downstream agents keep working

The design in seven rules

  1. A user-signed root ticket anchors every chain. The user approves a plain-language scope and signs it (passkey or Entra ID). Every later block links back to it by hash.
  2. Intersection authority. At each hop: effective scope = parent scope ∩ requested scope ∩ delegatee's own permissions. A delegatee can never add its own powers, which removes the confused-deputy attack.
  3. Offline narrowing. Each holder signs the next block with its own key, so any agent can narrow authority without calling a server. Widening fails verification.
  4. Proof of possession. The final holder signs each request, so a stolen chain is useless without the holder's private key.
  5. Tree-wide budgets. Spend and action limits are tracked per root ticket, shared by every branch, not per token.
  6. Short lifetimes instead of revocation messages. Blocks expire in minutes. If the user cancels, the root stops being renewed and the whole tree stops.
  7. Results are untrusted. Every tool or sub-agent result is marked untrusted before the model sees it, and risky actions after untrusted input need extra checks.

Every decision also writes a signed receipt, which gives auditors a record of who authorized what.

Where each piece runs

Placement Job .NET building blocks
Gateway in front of tools and agents Verify chain, scope, budget, proof; write receipt ASP.NET Core middleware + YARP
Inside each agent Delegate with narrowing; sign requests; mark results untrusted Semantic Kernel filters, HttpClient handler
At the user Sign the root ticket WebAuthn passkeys or Entra ID; keys in Azure Key Vault
Orchestration across agents Durable hand-offs, human approval waits, retries, cancellation Dapr Workflow (Dapr.Workflow)
Transport between agent services Service identity and encryption Dapr service invocation with mTLS and access control policies

The gateway is the part that matters most: even a fully hijacked agent can't get a request through that the signed chain doesn't cover.

The code below is a simplified reference design for clarity. For production, use an audited attenuable token format such as Biscuit, keep keys in an HSM or Key Vault, and get a security review.

1. Scopes with intersection

using System.Security.Cryptography; using System.Text.Json; public sealed record Scope( string[] Actions, // e.g. "payments.send" string[] Recipients, // allowed payees or email addresses decimal MaxAmount, int MaxDepth, // how many more delegations are allowed DateTimeOffset Expires) { public const string Any = "*"; // only for an agent's own standing policy, never a user ticket static string[] Meet(string[] a, string[] b) => a.Contains(Any) ? b : b.Contains(Any) ? a : a.Intersect(b).ToArray(); static bool Covers(string[] parent, string[] child) => parent.Contains(Any) || child.All(parent.Contains); public Scope Intersect(Scope other) => new( Meet(Actions, other.Actions), Meet(Recipients, other.Recipients), Math.Min(MaxAmount, other.MaxAmount), Math.Min(MaxDepth, other.MaxDepth), Expires < other.Expires ? Expires : other.Expires); // A child scope must be inside its parent and use up one level of depth. public bool IsWithin(Scope parent) => Covers(parent.Actions, Actions) && Covers(parent.Recipients, Recipients) && MaxAmount <= parent.MaxAmount && MaxDepth < parent.MaxDepth && Expires <= parent.Expires; } 

The * wildcard lets an agent's standing policy say "any customer" while the user's ticket still names one, so the intersection comes out as the specific customer.

2. Signing helpers

Small wrappers around ECDsa (P-256) and SHA-256 for hashing, signing and verifying any record.

Show the Crypto helper class
public static class Crypto { static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public static byte[] Canonical<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, Json); public static string Hash<T>(T value) => Convert.ToHexString(SHA256.HashData(Canonical(value))); public static string PublicKey(ECDsa key) => Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()); public static string Sign<T>(ECDsa key, T value) => Convert.ToBase64String(key.SignData(Canonical(value), HashAlgorithmName.SHA256)); public static bool Verify<T>(string publicKey, T value, string signature) { using var key = ECDsa.Create(); key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _); return key.VerifyData(Canonical(value), Convert.FromBase64String(signature), HashAlgorithmName.SHA256); } } 

3. The delegation chain

Each block names the agent that holds it (HolderKey). The next block must be signed by that holder, so authority can only move forward along the chain, and only by the agent that currently holds it.

public sealed record Block(Scope Scope, string Task, string HolderKey, string PrevHash); public sealed record SignedBlock(Block Block, string Signature); public sealed record DelegationChain(IReadOnlyList<SignedBlock> Blocks) { public string RootId => Crypto.Hash(Blocks[0].Block); // The user signs the root and names the first agent as holder. public static DelegationChain Issue(ECDsa userKey, Scope scope, string intent, string firstAgentKey) { var root = new Block(scope, intent, firstAgentKey, PrevHash: ""); return new([new SignedBlock(root, Crypto.Sign(userKey, root))]); } // The current holder narrows and hands off. Rule 2: parent ∩ requested ∩ delegatee's own. public DelegationChain Delegate(ECDsa holderKey, Scope requested, Scope delegateeOwn, string subtask, string delegateeKey) { var parent = Blocks[^1].Block; if (parent.Scope.MaxDepth <= 0) throw new InvalidOperationException("Delegation depth exhausted."); var scope = parent.Scope.Intersect(requested).Intersect(delegateeOwn) with { MaxDepth = Math.Min(Math.Min(requested.MaxDepth, delegateeOwn.MaxDepth), parent.Scope.MaxDepth - 1) }; var block = new Block(scope, subtask, delegateeKey, Crypto.Hash(parent)); return new([.. Blocks, new SignedBlock(block, Crypto.Sign(holderKey, block))]); } } 

4. Verifying the chain

public sealed record VerifyResult(bool Ok, string Reason, Scope? Scope = null, string? HolderKey = null); public static class ChainVerifier { public static VerifyResult Verify(DelegationChain chain, string userPublicKey, DateTimeOffset now) { if (chain.Blocks.Count == 0) return new(false, "empty chain"); for (var i = 0; i < chain.Blocks.Count; i++) { var (block, sig) = (chain.Blocks[i].Block, chain.Blocks[i].Signature); var signer = i == 0 ? userPublicKey : chain.Blocks[i - 1].Block.HolderKey; if (!Crypto.Verify(signer, block, sig)) return new(false, $"bad signature at hop {i}"); if (block.Scope.Expires <= now) return new(false, $"expired at hop {i}"); if (i > 0) { var parent = chain.Blocks[i - 1].Block; if (block.PrevHash != Crypto.Hash(parent)) return new(false, $"broken link at hop {i}"); if (!block.Scope.IsWithin(parent.Scope)) return new(false, $"scope widened at hop {i}"); } } var last = chain.Blocks[^1].Block; return new(true, "ok", last.Scope, last.HolderKey); } } 

5. The gateway (ASP.NET Core + YARP)

The gateway sits in front of tool APIs and other agents. It checks the chain, the specific action, proof of possession and the shared budget, writes a receipt, and only then lets YARP forward the request.

public sealed record ToolAction(string Action, string Recipient, decimal Amount); public sealed record Receipt(string RootId, string ChainHash, ToolAction Action, string Decision, string Reason, DateTimeOffset At); // Starts each root at the root ticket's MaxAmount; implement with an atomic decrement in Redis or SQL. public interface IBudgetStore { Task<bool> TryConsumeAsync(string rootId, decimal amount); } public interface IReceiptLedger // append-only; e.g. a SQL ledger table { Task AppendAsync(Receipt receipt); } 
var builder = WebApplication.CreateBuilder(args); builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); builder.Services.AddSingleton<IBudgetStore, RedisBudgetStore>(); builder.Services.AddSingleton<IReceiptLedger, SqlReceiptLedger>(); var app = builder.Build(); var userKeys = app.Configuration.GetSection("TrustedUserKeys"); // user id -> public key app.Use(async (ctx, next) => { var budgets = ctx.RequestServices.GetRequiredService<IBudgetStore>(); var ledger = ctx.RequestServices.GetRequiredService<IReceiptLedger>(); // 1. Read the chain, the proof and the body. var chain = JsonSerializer.Deserialize<DelegationChain>( Convert.FromBase64String(ctx.Request.Headers["X-Delegation"].ToString()))!; var userKey = userKeys[ctx.Request.Headers["X-User"].ToString()]!; ctx.Request.EnableBuffering(); var body = await new StreamReader(ctx.Request.Body).ReadToEndAsync(); ctx.Request.Body.Position = 0; var action = JsonSerializer.Deserialize<ToolAction>(body, new JsonSerializerOptions(JsonSerializerDefaults.Web))!; async Task Deny(string reason) { await ledger.AppendAsync(new(chain.RootId, Crypto.Hash(chain), action, "deny", reason, DateTimeOffset.UtcNow)); ctx.Response.StatusCode = StatusCodes.Status403Forbidden; await ctx.Response.WriteAsync(reason); } // 2. Verify the chain back to the user's signature. var result = ChainVerifier.Verify(chain, userKey, DateTimeOffset.UtcNow); if (!result.Ok) { await Deny(result.Reason); return; } // 3. Proof of possession: the final holder signed this exact body. if (!Crypto.Verify(result.HolderKey!, body, ctx.Request.Headers["X-Agent-Proof"].ToString())) { await Deny("caller is not the chain holder"); return; } // 4. The action must fit the final scope. var scope = result.Scope!; if (!scope.Actions.Contains(action.Action)) { await Deny("action not allowed"); return; } if (!scope.Recipients.Contains(action.Recipient)) { await Deny("recipient not allowed"); return; } if (action.Amount > scope.MaxAmount) { await Deny("amount over limit"); return; } // 5. Shared budget across the whole delegation tree. if (!await budgets.TryConsumeAsync(chain.RootId, action.Amount)) { await Deny("tree budget exhausted"); return; } await ledger.AppendAsync(new(chain.RootId, Crypto.Hash(chain), action, "allow", "ok", DateTimeOffset.UtcNow)); await next(); }); app.MapReverseProxy(); app.Run(); 

Nothing here asks a model whether the request looks safe. If an injected instruction changes the payee or the amount, it simply doesn't match what was signed.

6. Inside the agent (Semantic Kernel)

Two small pieces. An HttpClient handler attaches the chain and signs each request with the agent's own key:

public sealed class DelegationHandler(ECDsa agentKey, string userId) : DelegatingHandler { public static readonly HttpRequestOptionsKey<DelegationChain> ChainKey = new("chain"); protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken ct) { if (!request.Options.TryGetValue(ChainKey, out var chain)) throw new InvalidOperationException("No delegation chain attached to this request."); var body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(ct); request.Headers.Add("X-User", userId); request.Headers.Add("X-Delegation", Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(chain)));Source:https://dev.to/kamal1910/securing-agent-to-agent-delegation-in-net-a-practical-design-44no

Related posts