The moment your AI app faces real users, two things become non-negotiable: you can’t let users send abusive or malicious input into the model unchecked, and you can’t let the model emit unsafe or off-brand content back. That’s guardrails — the safety layer around your AI. This article covers building them in .NET.
Be precise about what problem guardrails solve, though, because the word gets stretched to cover everything. Guardrails are not accuracy controls — they will not stop the model being wrong. They are the layer that keeps a system from producing output you’d have to apologise for, and from taking actions nobody authorised. Those are two different jobs, and confusing them is why a lot of “we added moderation” work ends up protecting nothing that mattered.
The three places guardrails live
Think of guardrails at three checkpoints:
- Input — before the user’s text reaches the model.
- Output — before the model’s response reaches the user.
- Scope — keeping the model on-topic and refusing what it shouldn’t do.
You want all three; skipping any leaves a gap. Skipping input moderation means you pay tokens to process abuse. Skipping output moderation means benign questions can still produce something you have to explain. Skipping scope enforcement means your support bot cheerfully writes Python, discusses competitors, and — in an agent with tools — does things you never intended.
Input moderation
Before calling your model, screen the input for abuse, harmful requests, or prompt injection attempts. A content-safety service gives you per-category signals you can threshold. With the official OpenAI SDK that’s ModerationClient:
using OpenAI.Moderations;
var moderator = new ModerationClient(
model: "omni-moderation-latest",
apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY"));
ModerationResult result = await moderator.ClassifyTextAsync(userInput);
if (result.Flagged)
{
return "Sorry, I can't help with that request.";
}
// safe — proceed to the model
ModerationResult exposes both an overall Flagged boolean and a ModerationCategory per harm type — Hate, Harassment, Violence, SelfHarm, Sexual, Illicit and their sub-categories — each carrying its own Flagged flag and a Score. That granularity is the useful part. The blanket Flagged is a decision someone else made about a general-purpose product; your app almost certainly wants a different one.
The Azure equivalent is Azure.AI.ContentSafety, which returns a severity score per category (Hate, Sexual, Violence, SelfHarm) rather than a boolean, on a scale from safe up to high-risk. It also offers Prompt Shields, a separate detector aimed specifically at jailbreak and indirect-injection attempts rather than harmful content — a different problem, and worth treating separately.
Whichever you use, wrap it behind your own interface. You will change provider or add a second one, and you don’t want that decision reaching into every call site:
public interface IContentModerator
{
Task<ModerationDecision> CheckAsync(string text, CancellationToken ct = default);
}
public readonly record struct ModerationDecision(
bool Blocked, string? Category, float Score);
This also protects your bill: you don’t spend tokens processing input you’re going to reject anyway. Moderation endpoints are dramatically cheaper than inference — for most providers, free or near enough — so a check that avoids one wasted completion pays for many checks.
Thresholds are a product decision, not a default
The single biggest mistake here is accepting whatever the service considers unsafe and shipping it. Categories are not equally relevant to every app, and the cost of a false positive varies enormously.
A mental-health support app that hard-blocks every self-harm signal has built something actively harmful — it refuses exactly the users it exists to serve. A children’s education app should be far stricter on sexual content than a general assistant. A security tooling product will trip violence and illicit-behaviour detectors constantly with entirely legitimate questions about malware analysis.
Set thresholds per category, in configuration rather than in code, and expect to tune them. And distinguish between block and flag: not everything needs a hard refusal. A middle tier — allow the response but log it for review, or route it to a human — is often the right answer for borderline content, and it gives you the data to move the threshold intelligently later.
What to do when the moderation service is down
This is the failure mode most teams discover in production. Your moderation provider times out or returns 503. Do you fail open (let the request through unchecked) or fail closed (reject it)?
There is no universally correct answer, which is exactly why it needs to be a deliberate decision rather than whatever your try/catch happens to do. Fail closed if you’re consumer-facing and the reputational cost of one bad output exceeds the cost of a brief outage. Fail open if the app is internal, behind authentication, and unavailability is the worse outcome. What you must not do is leave it accidental — a swallowed exception that silently disables your safety layer for the duration of an incident is the worst version of both options.
Whichever you choose, make it explicit and alarming:
try
{
var decision = await _moderator.CheckAsync(input, ct);
if (decision.Blocked) return Refusal(decision);
}
catch (Exception ex)
{
_logger.LogError(ex, "Moderation unavailable — failing closed");
_metrics.ModerationUnavailable.Add(1);
return Refusal(null); // deliberate policy, not an accident
}
Set a short timeout on the moderation call specifically. It sits on the critical path before inference, so its latency is added to every request; a check that normally takes tens of milliseconds should not be allowed to hang for thirty seconds because the provider is degraded.
Output moderation
The model can produce problematic content even from benign input. Screen the response before it’s shown:
var reply = await agent.RunAsync(userInput);
var decision = await _moderator.CheckAsync(reply.ToString(), ct);
if (decision.Blocked)
{
_logger.LogWarning("Blocked output in category {Category}", decision.Category);
return "I'm not able to provide a response to that.";
}
return reply;
For streaming responses, this is genuinely hard, and it’s worth stating the problem plainly rather than waving at it. Once you’ve streamed a sentence to the browser, you cannot un-stream it. By the time a classifier sees enough text to judge, the user has already read it.
There are three workable approaches, and they trade latency against safety.
Buffer the whole response, moderate, then render. Safest and simplest. You lose the entire benefit of streaming, which for a long answer means the user stares at a spinner for several seconds. Choose this when the content is high-stakes enough that a partial leak is unacceptable.
Moderate at sentence boundaries with a small render delay. Accumulate tokens, and each time you complete a sentence, check it before flushing it to the client. The user sees text arriving with a lag of one sentence rather than one token. This is the best general-purpose answer: you keep most of the perceived responsiveness and you never render unchecked text.
Stream freely and retract. Render immediately, and if the classifier fires, replace the message in the UI with a refusal. Lowest latency, and honest about its limitation — the user may have already read the content. Reasonable for off-brand or off-topic filtering, unreasonable for genuinely harmful content.
The sentence-boundary approach costs you one moderation call per sentence rather than per response, which is worth checking against your provider’s rate limits before you ship it. Batch where the API supports it.
Scope guardrails
Keep the model doing its job and nothing else. Two layers:
- Instruction-level — a firm system prompt: “You only answer questions about Contoso products. Politely decline anything else.” (See prompt engineering.)
- Code-level — because prompts aren’t a hard boundary, enforce the real limits in code: validate tool arguments, require confirmation for high-impact actions, and never let the model’s output bypass your authorization checks.
Tool validation is where scope guardrails earn their keep, because it’s the point where the model stops producing text and starts producing consequences. The rule is straightforward: treat tool arguments exactly as you’d treat an HTTP request body from an anonymous caller. Validate the shape, validate the range, and — critically — resolve authorisation from the authenticated session rather than from anything the model produced.
[Description("Get the order history for the signed-in customer.")]
public async Task<Orders> GetOrdersAsync(string customerId, CancellationToken ct)
{
// The model proposed customerId. The session decides who you are.
if (customerId != _currentUser.CustomerId)
{
_logger.LogWarning("Tool call attempted cross-customer access");
throw new UnauthorizedAccessException();
}
return await _orders.GetAsync(customerId, ct);
}
Better still, don’t put customerId in the tool signature at all — take it from the session and give the model no way to influence it. Every parameter you expose is a parameter an injected instruction can try to set.
What moderation does not catch
A content-safety service classifies harm. It has no opinion about most of the things that will actually embarrass you.
It will not tell you the model invented a refund policy. It will not notice that the response recommends a competitor, quotes a price that expired last year, or gives what a regulator would call financial advice. It will not catch a customer’s email address or order number being echoed back into a shared channel. None of these are “unsafe” in the classifier’s sense, and all of them are incidents.
Those checks are yours to build, and most of them are deterministic and cheap. Run a regex sweep for the patterns you know you must never emit — card numbers, national identifiers, internal ticket URLs. Keep a denylist of competitor names and forbidden claims. These cost microseconds, need no network call, and should run before you ever consider spending money on a classifier.
Where the check genuinely needs judgement — “did this answer stay within our published policy?” — a second model call scoring the response is a legitimate tool, but price it honestly: it roughly doubles your per-request cost and adds a full inference round trip to latency. Reserve it for high-stakes paths rather than applying it to every message.
Don’t rely on the prompt alone
The critical principle: a system prompt is a strong default, not a security control. A determined user can often talk a model past its instructions. So your guardrails must include code-enforced checks — input/output moderation and tool validation — not just careful wording. This is the same message as securing AI agents: enforce in code where you’re in control.
The test to apply is simple. For each rule in your system prompt, ask what happens if the model ignores it. If the answer is “the response is off-brand”, the prompt is adequate. If the answer is “money moves” or “data leaves”, the prompt was never the control, and you need to find the code path that is.
Log what you block
Record blocked inputs and outputs (minus sensitive data). It gives you an audit trail, shows you what users actually try, and lets you tune thresholds — too strict and you frustrate real users, too loose and things slip through. Pair it with observability so a spike in blocks is visible.
Two metrics are worth having from day one: block rate by category, and the rate at which blocks are later overturned on review. The first tells you when something has changed — a jump in one category usually means either an attack or a threshold that no longer matches how people are using the product. The second is the only honest measure of whether your thresholds are set sensibly, and it’s the number that stops “make it stricter” from being the answer to every incident.
Be careful what you store. Blocked content is by definition the content you least want sitting in a log aggregator that half the company can read. Store a hash and the category by default, retain the full text only where you genuinely need it for review, and put a retention policy on it.
When guardrails are overkill
For an internal tool used by twenty authenticated colleagues, full input and output moderation on every call is mostly cost and latency for a risk that doesn’t exist. The people using it are identifiable and accountable, and the output isn’t public.
What still applies, even there, is the third checkpoint. Scope enforcement and tool validation matter regardless of who the user is, because those protect against the model doing the wrong thing rather than against the user asking for it — and an injected instruction inside a retrieved document doesn’t care how trusted your users are.
Note: specific moderation services and their APIs vary by provider and change over time; verify against your chosen service’s docs. The architecture — moderate input, moderate output, enforce scope in code, and log — is stable and provider-independent.
Takeaway
Guardrails for a .NET AI app live at three checkpoints: screen user input before the model, screen the model’s output before the user, and keep the model in scope with both a firm system prompt and code-enforced limits. Use a content-safety service for the harm categories, but set thresholds per category as a product decision rather than accepting the defaults, and decide deliberately whether you fail open or closed when that service is unavailable. Handle streaming by moderating at sentence boundaries rather than pretending the problem doesn’t exist. Build your own deterministic checks for the things a classifier doesn’t care about, validate tool arguments against the session rather than the model, log what you block, and never treat the prompt as your only defence. That’s the difference between a demo and something you can safely put in front of real users.
