
Learn how to prevent cross-site scripting (XSS) in ASP.NET Core with encoding, CSP, and sanitization. Complete guide with C# code examples — read now.
Cross-site scripting (XSS) has appeared in every OWASP Top 10 list since the project began, and it remains one of the most commonly reported vulnerabilities in web applications today. If you build web apps with ASP.NET Core, the good news is that the framework gives you strong cross-site scripting prevention out of the box — but only if you understand where those protections apply and, more importantly, where they silently stop applying. This complete guide explains what XSS is, how attackers exploit it, and exactly how to prevent it in ASP.NET Core using output encoding, Content Security Policy, HTML sanitization, and secure cookie configuration.
What Is Cross-Site Scripting (XSS)?
Cross-site scripting is an injection attack where an attacker gets their own JavaScript to run in another user's browser, in the security context of your site. Because the script runs on your origin, it can read session cookies, steal anti-forgery tokens, capture keystrokes on login forms, deface pages, or silently perform actions as the logged-in victim.
There are three main types of XSS attack:
- Stored (persistent) XSS — the malicious payload is saved to your database (for example, in a comment or profile field) and served to every user who views that page. This is the most dangerous type because one injection can compromise thousands of users.
- Reflected XSS — the payload arrives in the request itself (a query string or form field) and is echoed back in the response. The attacker delivers it via a crafted link.
- DOM-based XSS — the vulnerability lives entirely in client-side JavaScript, for example when code writes
location.hashintoinnerHTML. The server never sees the payload.
The root cause in all three cases is the same: untrusted data is placed into a page without proper encoding for the context where it lands. That framing matters, because it tells you the fix is not "filter bad characters on input" — it is "encode correctly on output." Input filtering is a useful defense-in-depth layer, but attackers are endlessly creative with encodings, and blocklists always leak. Context-aware output encoding is the fix that actually holds.
How Razor Protects You by Default
Razor, the view engine used by ASP.NET Core MVC, Razor Pages, and Blazor, HTML-encodes everything you render with the @ syntax. This is your first and strongest line of defense, and it costs you nothing:
@model CommentViewModel
@* SAFE: Razor HTML-encodes Model.Text automatically *@
<p>@Model.Text</p>
@* If Model.Text is "<script>alert('xss')</script>",
the browser receives:
<script>alert('xss')</script>
— displayed as text, never executed. *@
The encoding is deliberately aggressive: Razor encodes not just <, >, and &, but also quotes and many non-ASCII characters. Why so aggressive? Because encoding a character that didn't strictly need it is harmless, while failing to encode one that did is a vulnerability. The framework chooses the failure mode that can't hurt you.
Where the Protection Stops: Html.Raw and Friends
Every XSS vulnerability in a Razor application starts with someone opting out of encoding. The most common escape hatches are:
@* DANGEROUS: renders the string verbatim, no encoding *@
@Html.Raw(Model.Description)
@* DANGEROUS: HtmlString is treated as pre-encoded *@
@(new HtmlString(Model.Description))
Treat every Html.Raw call in your codebase as a security review item. The rule is simple: never pass user-controlled data to Html.Raw. It exists for rendering trusted, developer-authored markup — a CMS snippet you wrote, a sanitized output (more on that below) — never for anything that originated in a request body, query string, header, or database column that users can write to.
A quick audit command worth running on any codebase:
// From a terminal in your repo root:
// grep -rn "Html.Raw" --include="*.cshtml" .
// Review every hit: where does that value come from?
JavaScript Context: The Classic Mistake
HTML encoding protects you inside HTML. It does not protect you inside a <script> block, because the rules for breaking out of a JavaScript string are different from the rules for breaking out of HTML. This pattern has caused countless real-world breaches:
@* DANGEROUS: HTML encoding does not make this safe.
A value like "</script><script>evil()</script>"
can terminate the script block early. *@
<script>
var userName = '@Model.UserName';
</script>
The safe approach in ASP.NET Core is to never build JavaScript by string concatenation with user data. Instead, pass data through the DOM or serialize it with a JSON encoder that escapes HTML-significant characters:
@* SAFE: put the data in an HTML attribute (HTML-encoded),
read it from JavaScript *@
<div id="app" data-username="@Model.UserName"></div>
<script>
var userName = document.getElementById('app').dataset.username;
</script>
@* SAFE: System.Text.Json escapes < > & by default,
so "</script>" cannot break out *@
<script>
var settings = @Json.Serialize(Model.Settings);
</script>
Json.Serialize (backed by System.Text.Json with the default encoder) escapes < as \u003C, which is why it is safe inside a script block while raw string interpolation is not. If you configure a relaxed JavaScriptEncoder such as UnsafeRelaxedJsonEscaping globally for API responses, do not reuse that configuration for values embedded in HTML pages — the "Unsafe" in the name is there for a reason.
XSS Prevention in ASP.NET Core: The Five Layers
Effective XSS prevention in ASP.NET Core is layered. No single control is perfect, so you stack them: if one fails, the next limits the damage.
Layer 1: Context-Aware Output Encoding
You have seen HTML and JavaScript contexts above. When you build HTML outside Razor — in a tag helper, an emailed report, or a string builder — use the framework encoders directly rather than writing your own:
using System.Text.Encodings.Web;
public class ReportBuilder
{
private readonly HtmlEncoder _htmlEncoder;
private readonly UrlEncoder _urlEncoder;
public ReportBuilder(HtmlEncoder htmlEncoder, UrlEncoder urlEncoder)
{
// Both are registered in DI by default in ASP.NET Core
_htmlEncoder = htmlEncoder;
_urlEncoder = urlEncoder;
}
public string BuildRow(string userSuppliedName, string userSuppliedUrl)
{
var safeName = _htmlEncoder.Encode(userSuppliedName);
var safeUrl = _urlEncoder.Encode(userSuppliedUrl);
return $"<tr><td>{safeName}</td>" +
$"<td><a href=\"/profile?u={safeUrl}\">view</a></td></tr>";
}
}
One subtle trap with URLs: encoding is not enough if the attacker controls the scheme. A link built from @Model.Website is fully HTML-encoded and still dangerous if the value is javascript:alert(1), because no character in that payload needs encoding. Validate that user-supplied URLs start with http:// or https:// before rendering them as links:
public static bool IsSafeHttpUrl(string? url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
Layer 2: Sanitize When You Must Accept HTML
Sometimes the business requirement is genuinely "users can submit rich text" — a blog comment system with bold and links, a WYSIWYG editor. Encoding would destroy the formatting, so instead you sanitize: parse the HTML and keep only an allowlist of safe tags and attributes. Never write this parser yourself; use the well-maintained HtmlSanitizer NuGet package:
// dotnet add package HtmlSanitizer
using Ganss.Xss;
public class CommentService
{
private static readonly HtmlSanitizer Sanitizer = CreateSanitizer();
private static HtmlSanitizer CreateSanitizer()
{
var sanitizer = new HtmlSanitizer();
sanitizer.AllowedTags.Clear();
foreach (var tag in new[] { "b", "i", "em", "strong", "a", "p", "ul", "ol", "li" })
sanitizer.AllowedTags.Add(tag);
sanitizer.AllowedAttributes.Clear();
sanitizer.AllowedAttributes.Add("href");
sanitizer.AllowedSchemes.Clear();
sanitizer.AllowedSchemes.Add("https");
return sanitizer;
}
public string CleanUserHtml(string untrustedHtml) =>
Sanitizer.Sanitize(untrustedHtml);
}
Sanitize on input (before storing) and keep the sanitizer in the render path if older, unsanitized data might exist in your database. Only sanitized output should ever reach Html.Raw.
Layer 3: Content Security Policy (CSP)
A Content Security Policy is your safety net: even if an injection slips through, CSP can stop the injected script from executing. It is a response header that tells the browser which script sources are legitimate. In ASP.NET Core, add it via middleware:
// Program.cs
var app = builder.Build();
app.Use(async (context, next) =>
{
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"frame-ancestors 'none'");
await next();
});
The critical directive is script-src 'self': inline <script> blocks and javascript: URLs are blocked, and scripts only load from your own origin. This single header neutralizes the majority of real-world XSS payloads. The trade-off is that your own inline scripts stop working too, which is why mature applications move all JavaScript into .js files or use nonces. Roll CSP out with Content-Security-Policy-Report-Only first so you can find violations in your own pages before enforcing.
Layer 4: Cookie Hardening
The classic XSS payoff is stealing the session cookie via document.cookie. Take that prize off the table:
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true; // JavaScript cannot read it
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
});
ASP.NET Core Identity sets HttpOnly by default — verify no one on your team has turned it off to make some client-side feature work. If JavaScript needs a piece of user state, expose it through an API endpoint, not by weakening the auth cookie.
Layer 5: Validate Input as Defense in Depth
Input validation will not stop XSS on its own, but it shrinks the attack surface. Use data annotations to enforce that a phone number looks like a phone number and a username is alphanumeric — payloads simply have nowhere to live in tightly validated fields:
public class RegisterModel
{
[Required, StringLength(30)]
[RegularExpression("^[a-zA-Z0-9_]+$",
ErrorMessage = "Letters, numbers and underscore only.")]
public string UserName { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
}
Note that ASP.NET Core also rejects requests containing obvious HTML in bound values in some configurations, but you should never rely on request-level filtering as your primary control — it cannot understand output context.
Common Pitfalls That Cause XSS in ASP.NET Core
- Blindly trusting the database. Data that users wrote yesterday is still untrusted today. "It came from our database" is not a reason to use
Html.Raw. - Encoding in the wrong context. HTML-encoding a value and dropping it into a JavaScript string, an inline event handler (
onclick), or astyleattribute. Each context has its own escaping rules; avoid those sinks entirely. - DOM XSS in your front-end code. Razor can't save you from
element.innerHTML = location.search. PrefertextContentoverinnerHTMLin client-side code, and audit your JavaScript for DOM sinks. - Forgetting APIs feed browsers too. If your Web API returns user content that a SPA renders with
innerHTMLordangerouslySetInnerHTML, the vulnerability is real even though no Razor view exists. Sanitize stored HTML server-side and setX-Content-Type-Options: nosniff. - Testing only with
<script>tags. Real payloads use<img src=x onerror=...>, SVG handlers, and encoded variants. Test with a proper checklist (the OWASP XSS Filter Evasion Cheat Sheet) or a scanner such as OWASP ZAP against your staging environment.
Conclusion: XSS Prevention Checklist
Cross-site scripting prevention in ASP.NET Core is not one setting — it is a set of habits layered on top of a framework that already defaults to safe. Razor's automatic encoding handles the common case; your job is to protect the edges where encoding is bypassed or the context changes. Here are the key takeaways:
- Let Razor's
@encoding do its job, and treat everyHtml.Rawas a code-review red flag — it should only ever receive trusted or sanitized markup. - Never concatenate user data into
<script>blocks; pass data viadata-*attributes orJson.Serialize. - Accept rich HTML only through an allowlist sanitizer like the
HtmlSanitizerpackage — never a hand-rolled regex. - Deploy a Content Security Policy with
script-src 'self'as a safety net that stops injected scripts from executing. - Keep auth cookies
HttpOnlyandSecureso a successful injection cannot steal sessions. - Validate URLs for safe schemes, validate input shapes as defense in depth, and test with real payloads, not just
<script>alert(1)</script>.
Adopt these layers and an attacker has to defeat all of them at once — which is exactly the position you want them in. Start today by grepping your views for Html.Raw and adding a report-only CSP header; those two steps alone surface most latent XSS risk in a typical ASP.NET Core application.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment