Securing LLM Tool Calling in ASP.NET Core: Least Privilege for AI Agents

Search for a command to run...

No comments yet. Be the first to comment.
Retrieval-Augmented Generation lives or dies on one unglamorous step, and it is not the model or the vector database. It is chunking. When you get chunking documents for RAG in .NET wrong, the model r

Most teams reach for caching the moment an API gets slow, wire up Redis or output caching, and move on. But there is an older, lighter mechanism built into HTTP itself that solves two problems at once

If you shipped an AI agent in .NET over the last two years, there is a good chance it runs on Semantic Kernel. That was the right call at the time. But with Microsoft Agent Framework reaching GA in Ap

The first time an AI feature I shipped went down in production, nothing in my code had changed. The model provider had a bad afternoon: a wave of 429 Too Many Requests, then a stretch of 503s, and eve

Coding Droplets
298 posts
Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.
From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.
Tool calling is the moment an LLM stops being a text generator and starts touching your systems. Securing LLM tool calling in ASP.NET Core is therefore not really an AI problem - it is an authorization problem wearing a new hat. The model never executes anything itself. It emits a structured request that says "call issue_refund with these arguments," and your code decides whether that actually happens. In production I have seen teams register every service method they own as a tool, hand the whole set to an IChatClient, and ship it. That is functionally the same as giving an anonymous caller a service account with write access to everything, then hoping the prompt keeps them polite.
OWASP gave this failure mode a name: Excessive Agency (LLM06), damaging actions performed in response to unexpected, ambiguous, or manipulated model output. The rails below are the ones that survived contact with real traffic on our own support API. If you would rather see them wired together than described - the scoped tool registry, the argument validators, the approval round trip - the annotated implementation lives on Patreon as one running codebase instead of disconnected snippets.
Guardrails only click once you have watched the full loop: what the model asks for, what your code invokes, what goes back into the conversation. Chapter 11 of the AI-Powered .NET APIs course builds that loop first with AIFunctionFactory and real services, then locks it down with validation and human confirmation before anything destructive is allowed to run.
Tool calling is a privilege escalation path because it converts natural language into function invocation. Anyone who can influence the text going into the model can influence which function you call and with what arguments. That includes the end user, and it includes any document, email, or web page your API feeds into the context window.
Three properties make it different from a normal API surface:
The caller is a probabilistic system. Microsoft's own guidance is blunt about it: models can hallucinate arguments that were never described in your function definitions.
The attack surface is the sum of every registered tool, not the tool the user asked about. Register ten tools and every request carries ten potential actions.
The model is not a security boundary. Instructions like "never delete anything" are a product hint, not an access control. They live in the same text channel an attacker is writing to.
The practical rule we settled on: the model chooses intent, your code retains authority. Every rail below is an application of that split.
Here is the shape almost every first implementation takes. It works beautifully in a demo.
IChatClient client = baseClient
.AsBuilder()
.UseFunctionInvocation() // auto-invokes whatever the model requests
.Build();
var options = new ChatOptions { Tools = _allTools }; // every tool the app owns
UseFunctionInvocation() adds FunctionInvokingChatClient, which runs the whole request/invoke/respond loop for you. That convenience is exactly what makes the pattern dangerous: between the model asking and your database changing, there is no code of yours left to say no.
The specific incident that changed how we build these: an internal assistant had a cancel_subscription tool registered alongside a harmless get_account_summary. A customer pasted a support email into the chat box. The email contained a line addressed at the assistant. The model, doing precisely what it was designed to do, called the cancellation tool with the account ID it found in the surrounding context. Nothing was compromised in the classic sense - no auth bypass, no injection into SQL. The system did what it was permitted to do. That is the whole point of excessive agency, and it is why prompt injection defences alone do not close the gap. Filtering the input reduces the odds; constraining the tool removes the blast radius.
Stop treating the tool list as static configuration. Build it per request from the caller's identity, the conversation's purpose, and nothing else.
var options = new ChatOptions
{
Tools = _toolRegistry.For(user), // only what this principal may invoke
ToolMode = ChatToolMode.Auto
};
A read-only support session gets lookup tools. A verified account holder gets lookups plus their own write actions. An unauthenticated visitor gets nothing but retrieval. The tool a caller never receives cannot be called, no matter how creative the prompt is.
ChatToolMode (in Microsoft.Extensions.AI.Abstractions) gives you finer control than most teams realise. ChatToolMode.None disables tool use for that turn, RequireAny forces a tool call, and ChatToolMode.RequireSpecific("get_order") pins the turn to exactly one function. For a deterministic step in a workflow, pinning the tool is safer and cheaper than letting the model pick from a menu.
There is a cost argument too: tool definitions are serialised into every request and count against your token budget. Trimming the surface is both a security control and a bill reduction.
This is the rail teams skip most often. The tool name came from the model, and so did every argument. Arguments deserve the same suspicion as an HTTP request body from the public internet.
Two rules cover most of it:
Never accept an identity or ownership key from the model. If the model can pass a customerId, it can pass someone else's. Resolve identity server-side from the authenticated principal and let the model supply only the business parameter.
AIFunction getOrder = AIFunctionFactory.Create(
(string orderNumber, CancellationToken ct) =>
_orders.GetForCurrentUserAsync(orderNumber, ct), // tenant scoped inside
name: "get_order",
description: "Look up an order belonging to the signed-in customer.");
The tool signature is deliberately narrow. There is no tenant parameter to tamper with, because the repository derives it from the request context.
Validate before you act, not after. Run the same FluentValidation rules, range checks, and enum parsing you would run on a controller DTO. A hallucinated argument should produce a clean validation error the model can read and retry from, not an exception buried in a 500. Returning a helpful, non-leaky error string is genuinely better behaviour here: the model corrects itself on the next turn.
The classic confused deputy problem shows up here in full force. Your API has broad database permissions. The user has narrow ones. If the tool executes with the application's authority, the model has effectively become a privilege escalation service for whoever is typing.
Every tool invocation should carry the caller's principal. In ASP.NET Core that means resolving IHttpContextAccessor or an explicit ambient user context inside the scoped service the tool wraps, then enforcing the same policy the equivalent REST endpoint enforces. If POST /orders/{id}/refund requires the refunds:write policy, the issue_refund tool must fail the same authorization check - and it must fail inside the tool, not in a system prompt.
A useful test when reviewing an AI feature: delete the model from the picture and ask whether a plain HTTP client hitting these operations with the same credentials would be safe. If the answer is no, the model is not what made it unsafe.
Some actions should never happen on a model's say-so. Anything that moves money, deletes data, sends external communication, or changes production state belongs behind an explicit human confirmation.
Microsoft.Extensions.AI has first-class support for this. Wrap the function in ApprovalRequiredAIFunction and the invocation loop stops and hands control back to you:
AIFunction refund = AIFunctionFactory.Create(IssueRefundAsync);
AIFunction gated = new ApprovalRequiredAIFunction(refund);
When the model requests a gated function, FunctionInvokingChatClient does not invoke it. It replaces the call with a FunctionApprovalRequestContent in the response, which your API surfaces to the user:
var pending = response.Messages
.SelectMany(m => m.Contents)
.OfType<FunctionApprovalRequestContent>()
.ToList();
// pending[0].FunctionCall.Name and .Arguments are what you show the human
The user approves or rejects, you send requestContent.CreateResponse(approved) back as user content on the next turn, and only then does the function run. Requires the current Microsoft.Extensions.AI 10.x packages on .NET 10; the same wrapper works whether you are driving an IChatClient directly or an agent built on Microsoft Agent Framework.
Two things I would insist on in review: show the human the actual arguments, not a paraphrase of the model's intent, and make the approval expire. An approval token that stays valid for the rest of the session is a replay waiting to happen.
The last rail is the one that turns an incident into a five-minute investigation instead of a week of guessing.
Log every invocation with the tool name, the arguments, the resolved principal, and the outcome. When something goes wrong, the question is always "what did the model actually call, and on whose behalf."
Cap the loop. Automatic function invocation will keep going until the model produces a final answer. Set a maximum iteration count so a confused model cannot spend your budget in a retry storm.
Rate-limit per tool, not just per endpoint. ASP.NET Core's rate limiter partitioned by user plus tool name stops a single session from issuing forty lookups in a minute.
Alert on the dangerous ones. A refund tool firing ten times in an hour is a signal, regardless of whether each call was individually legitimate.
This is also where AI features stop being special. The same agent architecture decisions that determine whether you need an agent at all should determine how much authority that agent carries.
Run this before an AI feature with tools goes live:
Tools are selected per request from the caller's identity, not registered globally.
No tool accepts a tenant, customer, or user identifier as a model-supplied argument.
Every argument is validated with the same rules a public API endpoint would apply.
Tool execution runs under the caller's principal and re-checks the matching authorization policy.
Every write, send, delete, or payment action is wrapped in ApprovalRequiredAIFunction.
Approval prompts display real arguments and expire.
Automatic function invocation has a hard iteration cap.
Tool calls are rate-limited per user and per tool.
Every invocation is logged with principal, arguments, and outcome.
Retrieved content (documents, emails, tickets) is treated as untrusted input, because it reaches the same context window the tool decision is made from.
If you can only do three, do 1, 4, and 5. Scope, principal, approval. Those three remove most of the blast radius.
Yes, provided the tools are constrained the same way a public API is constrained. The risk does not come from tool calling itself but from granting the model authority the caller does not have. Scope tools per request, run them under the caller's principal, and gate destructive actions behind approval, and the security posture is comparable to a normal REST endpoint.
Do not put the ID in the tool signature. If the function takes a customerId parameter, the model can supply any value, including one it hallucinated from surrounding context. Resolve identity server-side from the authenticated principal and expose only the business parameter, such as an order number that is then scoped to that principal inside the repository.
Prompt injection is the delivery mechanism; excessive agency is the damage. Injection manipulates what the model decides to do. Excessive agency is the system having granted enough permission for that decision to matter. Input filtering reduces injection success rates but never reaches zero, which is why constraining the tool surface is the control that actually bounds the outcome.
No. ApprovalRequiredAIFunction, FunctionApprovalRequestContent, and FunctionApprovalResponseContent live in Microsoft.Extensions.AI, so the approval round trip works with a plain IChatClient and UseFunctionInvocation(). Agent Framework builds on the same types, so the pattern carries over unchanged if you later move to an agent.
Fewer than feels natural. Every tool definition is serialised into the request, consuming tokens and increasing the chance the model picks the wrong one. Registering only the tools relevant to the current context improves both accuracy and cost, and it shrinks the attack surface at the same time. When a step is deterministic, pin it with ChatToolMode.RequireSpecific instead of offering a menu.
Return a sanitised, structured message. The model can recover from "order number must be 8 digits" on the next turn, which is better behaviour than a hard failure. What must never reach the model is raw exception detail, connection strings, stack traces, or another tenant's data, since anything you return becomes part of the context an attacker may be able to read back.
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com