Call AddAntiforgery() and, after UseAuthentication() and UseAuthorization(), UseAntiforgery(). Bind form posts with [FromForm] so the token is checked, render it with IAntiforgery.GetAndStoreTokens(), and never add .DisableAntiforgery() to a cookie-authenticated endpoint. Change state only in MapPost endpoints; GET endpoints are never checked.
- Call builder.Services.AddAntiforgery(); set options.HeaderName (this card uses X-CSRF-TOKEN) if scripts send the token in a header.
- Call app.UseAntiforgery() after app.UseAuthentication() and app.UseAuthorization().
- Bind posted forms with [FromForm], IFormFile or IFormCollection so the middleware checks them, and remove .DisableAntiforgery() from endpoints that browsers reach with the auth cookie.
- Render the token with IAntiforgery.GetAndStoreTokens(context): put RequestToken in a hidden field named FormFieldName (__RequestVerificationToken) and in a meta tag for scripts. MVC and Razor Pages form tag helpers add the field for you.
- Map state changes with MapPost, MapPut or MapPatch and keep MapGet handlers read-only; in a MapDelete handler, call await antiforgery.ValidateRequestAsync(context) yourself.
- Keep the auth cookie HttpOnly, Secure and SameSite=Lax (the AddCookie default) as defence in depth.
- Add regression tests that send the auth cookie with the headers a browser sends for a cross-site form, a sibling subdomain, another user's token and a cross-site link.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public void MapEmailRoutes(RouteGroupBuilder account)
{
account.MapPost("/account/email", ([FromForm] string email, ClaimsPrincipal user, AccountStore accounts) =>
ChangeEmail(email, user, accounts));
// The link in the "confirm your new address" email sends GET /account/email?email=...
account.MapGet("/account/email", (string email, ClaimsPrincipal user, AccountStore accounts) =>
ChangeEmail(email, user, accounts));
}
private static IResult ChangeEmail(string email, ClaimsPrincipal user, AccountStore accounts)
{
if (!Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+$"))
{
return Results.BadRequest("Enter a valid email address");
}
accounts.ChangeEmail(user.Identity!.Name!, email);
return Results.Redirect("/account");
}With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).
The fix
public void AddServices(IServiceCollection services) =>
// Forms send the token as __RequestVerificationToken; scripts send it in this header.
services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN");
public void UseMiddleware(WebApplication app, string trustedOrigin) =>
// After UseAuthentication and UseAuthorization. A POST, PUT or PATCH to an endpoint
// that binds form data ([FromForm]) now needs a valid token, or it gets a 400.
app.UseAntiforgery();
public void ConfigureEndpoints(RouteGroupBuilder endpoints)
{
// Nothing to switch on: form-binding endpoints require the token by default.
}With IMPL=fixed, 12 of 12 tests pass.
The fix: change state only on POST
public void MapEmailRoutes(RouteGroupBuilder account) =>
// POST only, so a link cannot reach it. [FromForm] is what makes the
// antiforgery middleware check the token for this endpoint.
account.MapPost("/account/email", ([FromForm] string email, ClaimsPrincipal user, AccountStore accounts) =>
{
if (!Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+$"))
{
return Results.BadRequest("Enter a valid email address");
}
accounts.ChangeEmail(user.Identity!.Name!, email);
return Results.Redirect("/account");
});With IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
public void AddServices(IServiceCollection services)
{
// No AddAntiforgery: the Referer check below replaces it.
}
public void UseMiddleware(WebApplication app, string trustedOrigin)
{
var host = new Uri(trustedOrigin).Host; // "portal.example.test"
app.Use(async (context, next) =>
{
var method = context.Request.Method;
var referer = context.Request.Headers.Referer.ToString();
// Some browsers and privacy tools strip the Referer, so allow requests without one.
if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method)
|| referer.Length == 0 || referer.Contains(host, StringComparison.OrdinalIgnoreCase))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status403Forbidden;
});
}
public void ConfigureEndpoints(RouteGroupBuilder endpoints) =>
endpoints.DisableAntiforgery();With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>
/// Exploit: each forged request carries Alice's auth cookie. Browsers withhold a SameSite=Lax
/// cookie from cross-site POSTs, but not from sibling subdomains or top-level GET
/// navigations, and older browsers ignore SameSite, so the server must refuse anyway.
/// </summary>
public sealed class ExploitTests(AccountAppFactory factory) : BrowserTests(factory)
{
private static Task<Response> Forge(Browser browser, Dictionary<string, string> headers, Dictionary<string, string>? fields = null) =>
browser.Post("/account/email", headers, With(fields ?? new Dictionary<string, string>(), "email", "mallory@evil.example"));
[Fact]
public async Task CrossSiteFormPostWithoutATokenIsRefused()
{
var alice = await SignIn("alice");
AssertRefused(await Forge(alice, FormFrom(Evil)));
}
[Fact]
public async Task ForeignOriginIsRefusedWhenTheBrowserSendsNoFetchMetadata()
{
var alice = await SignIn("alice");
AssertRefused(await Forge(alice, new Dictionary<string, string> { ["Origin"] = Evil, ["Referer"] = $"{Evil}/" }));
}
[Fact]
public async Task SiblingSubdomainIsRefusedAlthoughSameSiteCookiesReachIt()
{
var alice = await SignIn("alice");
AssertRefused(await Forge(alice, FormFrom(Sibling, "same-site")));
}
[Fact]
public async Task TokenFromTheAttackersOwnSessionIsRefused()
{
var bob = await SignIn("bob");
var bobsFields = HiddenFields(await bob.AccountPage(SameOriginPage));
var alice = await SignIn("alice");
AssertRefused(await Forge(alice, FormFrom(Evil), bobsFields));
}
[Fact]
public async Task RefererThatMerelyContainsTheHostNameIsRefused()
{
var alice = await SignIn("alice");
AssertRefused(await Forge(alice, FormFrom(Lookalike)));
}
[Fact]
public async Task PageThatSuppressesTheRefererIsRefused()
{
// <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
var alice = await SignIn("alice");
var headers = new Dictionary<string, string>
{
["Origin"] = "null", ["Sec-Fetch-Site"] = "cross-site", ["Sec-Fetch-Mode"] = "navigate", ["Sec-Fetch-Dest"] = "document",
};
AssertRefused(await Forge(alice, headers));
}
[Fact]
public async Task CrossSiteGetNavigationCannotChangeTheEmail()
{
// Lax cookies are sent on top-level GET navigations, so only the method protects this.
var alice = await SignIn("alice");
var res = await alice.Get("/account/email?email=mallory%40evil.example", LinkFrom(Evil));
Assert.True(Accounts.EmailOf("alice") == "alice@example.test", $"a GET changed Alice's email (HTTP {(int)res.Status})");
Assert.True((int)res.Status >= 400, $"expected an error status, got {(int)res.Status}");
}
}- UseAntiforgery checks only endpoints that bind form data, and only for POST, PUT and PATCH. A DELETE with a form body, or a POST handler that calls Request.ReadFormAsync() itself, is not checked: both returned 200 without a token on ASP.NET Core 10.0.12.
- .DisableAntiforgery() is what developers reach for when a form endpoint returns 400, or throws "contains anti-forgery metadata, but a middleware was not found that supports anti-forgery" because UseAntiforgery is missing. It removes the check; Microsoft documents it, and [IgnoreAntiforgeryToken] in MVC, only for endpoints that browsers cannot reach or that do not use cookie authentication.
- MVC controllers are not protected by default: add AutoValidateAntiforgeryTokenAttribute as a global filter with AddControllersWithViews (AddControllers alone has no antiforgery support), or [ValidateAntiForgeryToken] per action. Razor Pages validate POST handlers automatically.
- Tokens are bound to the signed-in user, so render a fresh token after sign-in. A token issued to another user fails; in this card's tests Bob's token in Alice's browser fails with 'The antiforgery cookie token and request token do not match.'
- Antiforgery tokens are protected with ASP.NET Core Data Protection; on more than one server, share the key ring or tokens issued by one instance fail on another.
- A missing or invalid token on a form endpoint returns 400 (BadHttpRequestException). In Development the exception page shows the AntiforgeryValidationException; in Production it is logged.
- The antiforgery cookie defaults to SameSite=Strict and the AddCookie auth cookie to SameSite=Lax. Lax cookies still travel with top-level GET navigations, so a MapGet that changes state is reachable from any site.
- ASP.NET Core 11 (a release candidate on 2026-10-10) adds automatic CSRF protection that checks Sec-Fetch-Site and Origin, on by default; it lets requests with neither header through, and when UseAntiforgery also runs, the token result takes precedence.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/csharp-aspnetcore does this for you).
- Against your own app, replay a forged post with a signed-in user's .AspNetCore.Cookies cookie and no token, for example curl -i -b '.AspNetCore.Cookies=...' -H 'Origin: https://evil.example' --data 'email=mallory@evil.example' https://your.app/account/email, and expect 400 with no change; repeat as a GET and expect 405.
- No scanner checked here covers minimal API endpoints, so grep for DisableAntiforgery, IgnoreAntiforgeryToken, ReadFormAsync and MapDelete handlers that read forms, and keep the regression tests.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | csharp.dotnet.security.mvc-missing-antiforgery.mvc-missing-antiforgery | No findings on this card: the rule matches MVC controller actions marked [HttpPost], [HttpPut], [HttpPatch] or [HttpDelete] that return IActionResult without [ValidateAntiForgeryToken]. Minimal API endpoints and .DisableAntiforgery() are outside its patterns.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | cs/web/missing-token-validation | Not expected to report anything on this card: the query checks [HttpPost] actions on ASP.NET MVC and ASP.NET Core controllers for ValidateAntiForgeryToken, a global AutoValidateAntiforgeryTokenAttribute or [RequireAntiforgeryToken] with UseAntiforgery. Minimal API endpoints are not modelled.Checked: github/codeql main at b5b8165; not executed |
| .NET code analysis | CA3147Mark verb handlers with ValidateAntiForgeryToken | No CSRF-related warnings for any implementation. CA3147 is off by default and applies to ASP.NET MVC (System.Web.Mvc) controller actions, not to ASP.NET Core minimal APIs.Checked: .NET SDK 10.0.107 with -p:AnalysisMode=All -p:AnalysisModeSecurity=All |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12) |
| Framework | ASP.NET Core 10 minimal API with cookie authentication and antiforgery |
| Microsoft.AspNetCore.App (shared framework: antiforgery, cookie authentication) | 10.0.12 at run time |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.1 (Microsoft Testing Platform 2.4.0) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/csharp-aspnetcorefolder.
dotnet restore tests/Account.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Account.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Account.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Account.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs csrf/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- aspNetCoreRuntime 10.0.12
- mvcTesting 10.0.12
- xunitV3 4.0.1
- microsoftTestingPlatform 2.4.0
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 7/12 fail (all 7 exploit tests); wrong 3/12 fail (3 exploit tests), offline after install
- Dependency freshness
- Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) is the current 10.0.x patch. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09), which is a bug-fix release under 14 days old. dotnet list package --vulnerable found nothing. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet
- Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core (.NET 10): Antiforgery with Minimal APIs
- Prevent CSRF attacks in ASP.NET Core 11: automatic CSRF protection with Fetch Metadata
- Use cookie authentication without ASP.NET Core Identity
- Parameter binding in minimal APIs (form binding and antiforgery)
- Integration tests in ASP.NET Core
- CA3147: Mark verb handlers with ValidateAntiForgeryToken
Attack patterns: