If you've built ASP.NET Core apps that render forms, you've dealt with antiforgery tokens. You've added @Html.AntiForgeryToken() to Razor views, called app.UseAntiforgery() in the pipeline, decorated controllers with [ValidateAntiForgeryToken], and debugged the inevitable 400 Bad Request that arrives when a token expires mid-session or a JavaScript client forgets to include one. The token-based antiforgery system works, but it creates friction — especially in minimal APIs and SPAs where there's no server-rendered form to embed a token into.

.NET 11 Preview 6 ships a fundamentally different approach. A new automatic CSRF protection middleware inspects the Sec-Fetch-Site header that modern browsers attach to every request and uses it to determine whether a request is same-origin or cross-origin. No tokens, no state, no Data Protection dependency. It's enabled by default in apps built with WebApplication.CreateBuilder, and for most applications it requires zero code changes.

This isn't a replacement for the existing token-based system — the two coexist. But for many apps, the new middleware eliminates the need for antiforgery tokens entirely.

What Fetch Metadata gives you

The Sec-Fetch-Site header is a forbidden request header, meaning JavaScript running in the browser cannot set or override it. The browser itself stamps the value on every request, and there's no way for a malicious page to forge it. When a form at https://attacker.example.com submits a POST to your API at https://yourapp.example.com, the browser sets Sec-Fetch-Site: cross-site — and there's nothing the attacker can do about that.

This is the key insight: the browser already knows the origin of every request. The CSRF middleware simply reads what the browser is telling it, rather than relying on a synchronised token pair between server and client.

// NOTE

Sec-Fetch-Site is supported by all current versions of Chromium-based browsers, Firefox, and Safari. For older browsers that predate the spec, the middleware falls back to comparing the Origin header against the request's scheme and host.

The rule chain

The middleware evaluates a short chain of rules for every request. The first match wins:

  1. Safe methods pass through. GET, HEAD, OPTIONS, and TRACE are allowed unconditionally. This follows RFC 9110 — these methods shouldn't change state, so CSRF protection is irrelevant.

  2. Sec-Fetch-Site: same-origin or none is allowed. This covers normal in-app navigation, fetch calls, and requests initiated directly by the user (typing a URL, using a bookmark). The vast majority of legitimate browser traffic exits here.

  3. A trusted CORS origin is allowed. If the request carries an Origin header and the endpoint's resolved CORS policy trusts that origin, the request passes. The middleware reuses CORS configuration you've already set up — it doesn't introduce its own trust list.

  4. Any other Sec-Fetch-Site value is denied. When the header is cross-site or same-site and the origin isn't trusted via CORS, the request is marked invalid.

  5. No Sec-Fetch-Site, but Origin is present. The middleware compares the Origin to scheme://host[:port] built from the request. Match means allowed; mismatch means denied. This is the fallback for legacy browsers.

  6. No Sec-Fetch-Site and no Origin. Allowed. Browsers always send at least one of these on a write request, so a request with neither is almost certainly a non-browser client — curl, Postman, a mobile app, server-to-server. CSRF is a browser-only attack vector; these clients pass through.

Deferred validation

The middleware doesn't short-circuit the pipeline. Instead, it records its verdict — allowed or denied — on the request's IAntiforgeryValidationFeature. The request continues flowing through the pipeline, and a denied verdict only becomes an HTTP 400 Bad Request when a component that processes form data observes it.

These components enforce the verdict:

This deferred model means that a JSON API endpoint that binds its body from JSON is not rejected on a cross-origin request, even if the verdict is invalid. CSRF is a form-and-cookie attack — endpoints that never consume browser-submitted forms don't need the protection, and they don't get it imposed on them.

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// This endpoint is automatically protected — form binding triggers verdict enforcement
app.MapPost("/orders", ([FromForm] CreateOrderRequest request) =>
    Results.Created($"/orders/{request.Id}", request));

// This endpoint is NOT affected — JSON binding doesn't enforce the CSRF verdict
app.MapPost("/api/orders", (CreateOrderRequest request) =>
    Results.Created($"/api/orders/{request.Id}", request));

app.Run();

A same-origin browser POST to /orders works normally. A cross-origin form submission from https://attacker.example.com is rejected with 400 before the handler body runs. A curl request without Sec-Fetch-Site or Origin passes through. The /api/orders endpoint is unaffected regardless of origin.

Working with cross-origin clients

The most common scenario that requires action is a browser-based client on a different origin submitting forms — for example, an app at https://app.contoso.com posting to an API at https://api.contoso.com. These requests are denied by default because Sec-Fetch-Site is same-site or cross-site.

The middleware doesn't have its own trust list. It reuses whatever CORS policy you've already configured:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
        policy.WithOrigins("https://app.contoso.com")
              .AllowAnyHeader()
              .AllowAnyMethod());
});

var app = builder.Build();

app.UseCors();

app.MapPost("/orders", ([FromForm] CreateOrderRequest request) =>
    Results.Created($"/orders/{request.Id}", request));

app.Run();

For granular control, apply a named CORS policy to specific endpoints:

Example.cs
app.MapPost("/orders", ([FromForm] CreateOrderRequest request) =>
    Results.Created($"/orders/{request.Id}", request))
    .RequireCors("partners");

// WARNING

AllowAnyOrigin() is intentionally not honoured as a CSRF trust signal. Allowing any origin to read a resource is a different concern from allowing any origin to mutate state on the user's behalf. Apps that need a public-read CORS policy with CSRF-protected writes should list trusted write origins explicitly with WithOrigins, or opt those endpoints out if they don't rely on cookie-based authentication.

Opting endpoints out

Endpoints that aren't browser-reachable, or that authenticate via bearer tokens or API keys rather than cookies, don't need CSRF protection. Opt them out individually rather than disabling the middleware globally.

For minimal APIs, call DisableAntiforgery():

Example.cs
app.MapPost("/api/webhooks/stripe", (StripeEvent payload) => Results.Accepted())
    .DisableAntiforgery();

For MVC controllers, apply [IgnoreAntiforgeryToken]:

Controllers/WebhookController.cs
[ApiController]
[Route("api/[controller]")]
[IgnoreAntiforgeryToken]
public class WebhookController : ControllerBase
{
    [HttpPost]
    public IActionResult Post([FromBody] StripeEvent payload) => Accepted();
}

Both approaches add IAntiforgeryMetadata { RequiresValidation = false } to the endpoint metadata. The CSRF middleware checks this and skips validation entirely.

// TIP

DisableAntiforgery() and [IgnoreAntiforgeryToken] opt out of both the new CSRF middleware and the legacy token-based middleware. You don't need separate opt-outs.

Customising the validation logic

The decision logic lives behind a single-method interface:

Example.cs
namespace Microsoft.AspNetCore.Antiforgery;

public interface ICsrfProtection
{
    ValueTask<CsrfProtectionResult> ValidateAsync(HttpContext context);
}

The framework registers its default implementation with TryAddSingleton, so an explicit registration overrides it:

Security/AllowlistCsrfProtection.cs
public sealed class AllowlistCsrfProtection : ICsrfProtection
{
    private static readonly HashSet<string> SafeMethods =
        new(StringComparer.OrdinalIgnoreCase) { "GET", "HEAD", "OPTIONS", "TRACE" };

    private static readonly HashSet<string> TrustedOrigins =
        new(StringComparer.OrdinalIgnoreCase)
        {
            "https://app.contoso.com",
            "https://admin.contoso.com",
        };

    public ValueTask<CsrfProtectionResult> ValidateAsync(HttpContext context)
    {
        if (SafeMethods.Contains(context.Request.Method))
            return ValueTask.FromResult(CsrfProtectionResult.Allowed());

        var origin = context.Request.Headers.Origin.ToString();
        var allowed = !string.IsNullOrEmpty(origin) && TrustedOrigins.Contains(origin);

        return ValueTask.FromResult(
            allowed ? CsrfProtectionResult.Allowed() : CsrfProtectionResult.Denied());
    }
}
Program.cs
builder.Services.AddSingleton<ICsrfProtection, AllowlistCsrfProtection>();

The middleware still honours DisableAntiforgery() and [IgnoreAntiforgeryToken] regardless of which implementation is registered — opt-out is handled by the middleware before ValidateAsync is called.

How it interacts with token-based antiforgery

The two systems share the same plumbing — both record their verdict on IAntiforgeryValidationFeature — but they can coexist:

Aspect Token-based antiforgery Automatic CSRF middleware
Available since ASP.NET Core 2.0 .NET 11
Activation Opt-in via app.UseAntiforgery() Auto-injected by WebApplication.CreateBuilder
What it validates Synchronised token pair (form field + cookie) Sec-Fetch-Site / Origin headers
Requires Data Protection for token encryption Nothing — no tokens, no state
Opt-out DisableAntiforgery() / [IgnoreAntiforgeryToken] Same

When both middlewares are active, the token-based middleware runs after the CSRF middleware and replaces its verdict with the result of token validation. Token validation is authoritative:

This means apps that already use app.UseAntiforgery() see the same end-to-end behaviour they had before. Apps that don't use tokens fall back to the CSRF middleware's verdict.

// IMPORTANT

If your app relies on antiforgery validation but you disable the CSRF middleware globally and don't call app.UseAntiforgery(), endpoints that require validation have no middleware backing them. Requests to those endpoints will throw an exception.

Blazor SSR implications

Blazor static server-side rendering endpoints participate in the deferred model. The Razor Components endpoint trusts the verdict recorded on IAntiforgeryValidationFeature and returns 400 Bad Request for a form post only when that verdict is invalid.

This is a behaviour change for Blazor SSR apps that previously removed app.UseAntiforgery(). In .NET 11, those apps are now protected by the CSRF middleware rather than being left unprotected, and they stop emitting antiforgery tokens. If your Blazor SSR app was deliberately running without antiforgery, the new middleware may start rejecting requests you previously allowed.

Disabling globally

If you need to disable the middleware entirely — during a migration window, for example — use the DisableCsrfProtection configuration key:

appsettings.json
{
  "DisableCsrfProtection": true
}

Or as an environment variable:

ASPNETCORE_DisableCsrfProtection=true

// WARNING

Prefer per-endpoint opt-outs over global disabling. The CSRF middleware also satisfies the antiforgery requirement for endpoints that need validation, so disabling it globally may leave those endpoints unprotected if app.UseAntiforgery() isn't called.

Debugging rejected requests

When a cross-origin form post returns 400 with no body, enable debug logging for the middleware:

appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware": "Debug"
    }
  }
}

You'll see entries like:

dbug: Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware[1]
      Cross-origin CSRF protection marked request POST /orders from origin 'https://attacker.example.com' as invalid.

To simulate a cross-origin request locally:

terminal
curl -i -X POST https://localhost:5001/orders \
     -H "Origin: https://attacker.example.com" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d "id=1&product=widget"

Common pitfalls

Assuming AllowAnyOrigin() opts out of CSRF. It doesn't. The middleware intentionally ignores wildcard CORS policies for write requests. If you need a public-read API that also accepts cross-origin form posts, list the trusted origins explicitly.

Forgetting that JSON endpoints are unaffected. The middleware records a verdict, but nothing enforces it unless a component binds form data. If you switch an endpoint from [FromForm] to [FromBody], it silently loses CSRF protection. That's fine if it's a bearer-authenticated API, but dangerous if it still relies on cookies.

Disabling globally without calling app.UseAntiforgery(). If you disable the CSRF middleware and also don't register the token-based middleware, endpoints that require antiforgery validation will throw at runtime. One of the two middlewares must be present.

Not accounting for Blazor SSR changes. Apps that previously ran Blazor SSR without app.UseAntiforgery() were unprotected. In .NET 11, the automatic middleware protects them by default, which may reject requests that previously worked — particularly cross-origin form posts.

Using CORS policies that don't match. The CSRF middleware resolves the CORS policy per-endpoint in the same order as the CORS middleware: [EnableCors("name")] or .RequireCors("name") first, then the default policy. If your CORS policy isn't attached to the endpoint that needs it, the middleware won't see it.

Summary