Cross-site request forgery (CSRF) and cross-site scripting (XSS) are the two client-side attacks most likely to reach an ASP.NET Core app, and they're often confused because both involve a malicious page in the victim's browser. They're different problems with different defenses: CSRF tricks the browser into sending a request the user didn't intend; XSS gets the attacker's script running in your origin. ASP.NET Core has strong built-in protections for both, but each has a sharp edge developers reach for when they're in a hurry. This post walks through a concrete example of XSS and CSRF against a Razor app, explains how each attack works, and lays out the exact defenses that close it.
How CSRF works and how ASP.NET Core stops it
CSRF exploits the fact that browsers automatically attach cookies to requests. If your app authenticates with a session cookie, an attacker's page can submit a form to your endpoint, and the browser dutifully includes the victim's cookie — so the request runs as the logged-in user without the attacker ever seeing the cookie.
The defense is the antiforgery token: a value the server issues, embedded in the form, that a cross-site attacker can't read or guess. ASP.NET Core generates and validates it for you. In .NET 8+, enable the middleware and validate on state-changing methods:
app.UseAntiforgery();
[ValidateAntiForgeryToken] // or [AutoValidateAntiforgeryToken] app-wide
public IActionResult Transfer(TransferModel m) { /* ... */ }
Razor forms emit the hidden token automatically via the form tag helper, so a standard <form asp-action="Transfer"> already includes it. Reinforce this with cookie SameSite=Strict (or Lax), which tells the browser not to send the cookie on cross-site requests in the first place:
builder.Services.ConfigureApplicationCookie(o => {
o.Cookie.SameSite = SameSiteMode.Strict;
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
o.Cookie.HttpOnly = true;
});
One nuance: pure token-authenticated APIs (a bearer token in the Authorization header, no cookie) aren't CSRF-susceptible the same way, because the browser doesn't auto-attach headers. But any cookie-based flow needs antiforgery — including the "we added a login cookie to our API later" case that quietly reintroduces the risk.
How XSS works and how Razor's default protects you
XSS happens when attacker-controlled data is rendered into a page as markup or script instead of as text. If a comment field containing <script>steal()</script> is written into the page verbatim, it executes in every viewer's browser with your app's privileges — reading cookies, making authenticated requests, defacing the page.
The good news: Razor HTML-encodes output by default. When you write @Model.UserComment, Razor converts <, >, &, and quotes into entities, so the script renders as inert text. The default is safe; the danger is when a developer opts out.
@* SAFE: auto-encoded, script becomes visible text *@
<p>@Model.UserComment</p>
@* DANGEROUS: raw output, script executes *@
<p>@Html.Raw(Model.UserComment)</p>
@Html.Raw, MarkupString in Blazor, and building HTML strings by hand all bypass encoding. Treat every one as a review flag. When you genuinely must render user-supplied HTML (a rich-text field, say), sanitize it with a maintained allow-list HTML sanitizer before it reaches the view — never trust it raw.
Context matters: encode for where the data lands
HTML-body encoding isn't enough when data goes into other contexts. Data placed in a JavaScript block, a URL, or an HTML attribute needs the encoder for that context:
| Sink | Correct encoding |
|---|---|
| HTML body | Razor default (HtmlEncoder) |
Inside a <script> / JS literal | JavaScriptEncoder |
| URL / query string | UrlEncoder |
| HTML attribute value | Razor default within quoted attributes |
The most dangerous anti-pattern is injecting server data straight into an inline script: var u = '@Model.Name';. A Name of ';steal();// breaks out of the string. Prefer passing data as HTML attributes or a JSON island read by JavaScript, and if you must inline it, use System.Text.Encodings.Web.JavaScriptEncoder.
Content-Security-Policy: the backstop when encoding slips
Even careful teams miss a sink. A Content-Security-Policy is defense-in-depth: it tells the browser which script sources are allowed, so an injected inline script won't run even if it lands in the page. ASP.NET Core doesn't set one by default — add it via middleware:
app.Use(async (ctx, next) => {
ctx.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self' 'nonce-" + nonce + "'; object-src 'none'";
await next();
});
Use a nonce-based policy and avoid 'unsafe-inline' in script-src — allowing inline scripts throws away most of CSP's XSS protection.
A CSRF and XSS checklist
-
UseAntiforgeryon;[ValidateAntiForgeryToken]on cookie-auth state changes - Cookies
SameSite=Strict/Lax,Secure,HttpOnly - Never
@Html.Raw/MarkupStringon untrusted data - User-supplied HTML sanitized with an allow-list library
- Context-correct encoding for JS, URL, and attribute sinks
- Nonce-based CSP, no
'unsafe-inline'scripts - Verified against the running app, since these are runtime behaviors
Because CSRF and XSS are exploitable behaviors of the running application, a dynamic scan that submits forms and injects payloads is the most direct way to confirm the defenses hold in production. If you're trying to learn how to test for XSS safely rather than run an ad hoc tutorial against a live site, this is the checklist to work from: reproduce the vulnerable-versus-safe snippet above locally, then point a DAST scan at a staging environment, never production.
How Safeguard Helps
Safeguard finds the CSRF and XSS gaps that slip past review and confirms whether they're actually exploitable. Its dynamic application security testing exercises your ASP.NET Core endpoints the way an attacker would — submitting requests without antiforgery tokens, injecting script payloads into inputs, and checking whether cookies carry SameSite and HttpOnly — so you learn which findings are real rather than theoretical. Static analysis flags the @Html.Raw calls and unencoded sinks in your Razor views, and Griffin AI traces whether untrusted data reaches them and explains the fix in context. For confirmed issues, the auto-fix workflow can propose adding the missing antiforgery attribute, encoder, or CSP header as a pull request. Teams comparing dynamic and static coverage often review the Safeguard vs Veracode page.
Create a free account at app.safeguard.sh/register and read the ASP.NET Core scanning docs at docs.safeguard.sh.