Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix CSRF in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 31 KB
On this page

Steps

  1. Call builder.Services.AddAntiforgery(); set options.HeaderName (this card uses X-CSRF-TOKEN) if scripts send the token in a header.
  2. Call app.UseAntiforgery() after app.UseAuthentication() and app.UseAuthorization().
  3. 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.
  4. 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.
  5. Map state changes with MapPost, MapPut or MapPatch and keep MapGet handlers read-only; in a MapDelete handler, call await antiforgery.ValidateRequestAsync(context) yourself.
  6. Keep the auth cookie HttpOnly, Secure and SameSite=Lax (the AddCookie default) as defence in depth.
  7. 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.

Vulnerable, fixed and wrong

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

Vulnerablesrc/Account/impl/VulnerableDefence.csC#
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

Fixedsrc/Account/impl/FixedDefence.csC#
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

Fixedsrc/Account/impl/FixedDefence.csC#
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

Wrong fixsrc/Account/impl/WrongDefence.csC#
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 regression test

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.

Testtests/Account.Tests/ExploitTests.csC#
/// <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}");
    }
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix CSRF in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepcsharp.dotnet.security.mvc-missing-antiforgery.mvc-missing-antiforgeryNo 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)
CodeQLcs/web/missing-token-validationNot 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 analysisCA3147Mark verb handlers with ValidateAntiForgeryTokenNo 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
FrameworkASP.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.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform 2.4.0)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Account.Tests --locked-mode
Shell
# 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.xml

Each 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.

Download the project ZIP, 31 KB

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

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core (.NET 10): Antiforgery with Minimal APIs
  3. Prevent CSRF attacks in ASP.NET Core 11: automatic CSRF protection with Fetch Metadata
  4. Use cookie authentication without ASP.NET Core Identity
  5. Parameter binding in minimal APIs (form binding and antiforgery)
  6. Integration tests in ASP.NET Core
  7. CA3147: Mark verb handlers with ValidateAntiForgeryToken

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.