Mert Özen Further With Every Line
Security ASP.NET Core Security — #08

ASP.NET Core Security in .NET 11: Automatic CSRF Protection with Fetch Metadata, Device Bound Session Credentials and TLS Channel Binding

Mert Özen Nov 8, 2026 10 min 2 views
ASP.NET Core Security in .NET 11: Automatic CSRF Protection with Fetch Metadata, Device Bound Session Credentials and TLS Channel Binding

.NET 11 adds CSRF protection, on by default for apps built with WebApplication.CreateBuilder, that checks the Sec-Fetch-Site and Origin headers; yet only form-reading endpoints enforce the verdict and DisableAntiforgery turns it off too. We cover the decision table, origins trusted through CORS, a middleware applying the same rules on .NET 10, experimental DBSC, TLS channel binding, unified authorization metadata and a migration checklist.

Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime). The .NET 11 (ASP.NET Core 11) features are described before the GA (general availability) release on 10 November 2026, based on the 11.0.0-rc.3 packages on NuGet and the .NET 11 documentation on Microsoft Learn; no .NET 11 code is given because it was not compiled.

Guess First

Bookstore's admin panel is a separate app at https://admin.bookstore.test, and from the browsers of users authenticated with cookies it posts forms to the API (Application Programming Interface) at https://shop.bookstore.test. On .NET 10 we do not call UseAntiforgery, and the form endpoints carry DisableAntiforgery. If you move the app to .NET 11 and deploy it without changing a single line, will the panel's forms keep working? And the fetch calls in the same panel that send JSON (JavaScript Object Notation)?

This is the last post of the series, and its subject is what .NET 11 brings to ASP.NET Core security. The biggest change is CSRF (cross-site request forgery) protection that is on by default in every app built with WebApplication.CreateBuilder and that looks at the browser's Fetch Metadata headers instead of tokens. Alongside it come experimental Device Bound Session Credentials (DBSC) support, unified authorization metadata across MVC (Model-View-Controller), SignalR and Blazor, and TLS (Transport Layer Security) channel binding. Because this was written two days before the .NET 11 GA, I recommend comparing every detail here with the GA release notes.

Automatic CSRF Protection: The Decision Table

In the antiforgery post we saw the token pair and how the browser's automatic sending of cookies gets abused. Modern browsers add a Sec-Fetch-Site header to every request, and this header (like Origin) is one of the "forbidden headers" JavaScript cannot change. The .NET 11 middleware decides based on these two headers. The rules from the .NET 11 version of the "Prevent CSRF attacks" page on Microsoft Learn, evaluated in order, first match wins:

#ConditionVerdict
1Method is GET, HEAD, OPTIONS or TRACEAllowed
2Sec-Fetch-Site: same-origin or noneAllowed
3Origin present, the endpoint's CORS (Cross-Origin Resource Sharing) policy trusts it and calls AllowCredentials()Allowed
4Sec-Fetch-Site: same-site or cross-siteDenied
5No Sec-Fetch-Site but Origin presentAllowed only if Origin equals the request's scheme://host[:port]
6Neither presentAllowed (treated as a non-browser client)

The fourth rule closes the gap SameSite cookies leave open: a request from blog.bookstore.test is same-site and is denied. The sixth rule exists so as not to break non-browser clients such as curl, mobile apps and services; those clients do not send cookies on their own anyway. The fifth rule is a fallback for older browsers that do not support Fetch Metadata.

The middleware runs after authentication and authorization and gets its verdict from the ICsrfProtection interface; the default implementation is registered with TryAddSingleton, so you can replace it with your own rule. To exclude an endpoint you use DisableAntiforgery() as in .NET 10, or [IgnoreAntiforgeryToken] in MVC. To turn it off entirely there is the DisableCsrfProtection configuration key (ASPNETCORE_DisableCsrfProtection=true); but the documentation warns that in that case you must call app.UseAntiforgery() so that endpoints relying on token-based antiforgery do not throw.

Trusted Origins: Through CORS

The answer to the first half of the opening question is surprising: the forms keep working. DisableAntiforgery() turns off not only the token system but the new CSRF middleware as well, so the endpoints the panel posts to stay unprotected on .NET 11 too. The right migration is to remove the opt-out and explicitly trust the panel's origin; otherwise these same-site requests hit the fourth rule. There is no separate CSRF allowlist; the middleware uses the CORS policy resolved for the endpoint: first [EnableCors("name")] or .RequireCors("name"), then the default policy. To trust an origin, adding it to a policy is not enough; AllowCredentials() is required too. AllowAnyOrigin does not count as a trust signal, and [DisableCors] does not turn off CSRF protection. A predicate written with SetIsOriginAllowed, on the other hand, is trusted; if you write wildcard subdomain rules, make sure every origin that can match is under your control.

Deferred Validation: Who Gets the 400?

Here the finding from the series' antiforgery post shows up again. The .NET 11 middleware does not reject the request itself either: it writes the verdict to IAntiforgeryValidationFeature and moves on. According to the documentation, what turns the verdict into a 400 is MVC actions protected by antiforgery, minimal API endpoints that bind a form, Blazor SSR (server-side rendering) and code that reads the form directly. An endpoint reading a JSON body runs normally even when the verdict is Denied. So the answer to the second half of the opening question is "it works" too: the admin panel's JSON fetch calls go through on .NET 11, but a cookie-authenticated JSON API does not become protected just by moving to .NET 11. Both flows work, and both are unprotected. The ValidateAntiforgeryFilter from the antiforgery post, or a layer that enforces the verdict like the one below, is still needed.

The relationship with the token system is clearly defined: when app.UseAntiforgery() is called, the token middleware clears the CSRF verdict and writes its own result. A valid token rescues a request denied by Fetch Metadata; a missing or invalid token fails a request that was allowed. The documentation says to keep the token system if you must support browsers that do not send Fetch Metadata, if you use IAntiforgeryAdditionalDataProvider, or if compliance requires an independent token layer.

Applying the Same Rules on .NET 10

.NET 10 is an LTS (long-term support) release and many teams will stay on it for a while. Because the decision table is simple, you can apply the same rules on .NET 10 with a small middleware. This is not .NET 11's code; it is our own implementation following the documented rules. We added one difference on purpose: it does not leave the verdict to form readers, it enforces it with 403 on every state-changing request, so JSON APIs are protected too.

// for .NET 10: our own middleware mirroring .NET 11's documented decision table
public sealed class FetchMetadataCsrfMiddleware(
    RequestDelegate next, ICorsPolicyProvider corsPolicies, ILogger<FetchMetadataCsrfMiddleware> logger)
{
    public async Task InvokeAsync(HttpContext context)
    {
        string? verdict = await EvaluateAsync(context);
        if (verdict is null)
        {
            await next(context);
            return;
        }

        logger.LogWarning("Cross-site request blocked: {Method} {Path} ({Reason})",
            context.Request.Method, context.Request.Path, verdict);
        context.Response.StatusCode = StatusCodes.Status403Forbidden;
    }

    private async Task<string?> EvaluateAsync(HttpContext context)
    {
        string method = context.Request.Method;
        if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method) || HttpMethods.IsTrace(method))
        {
            return null;
        }

        if (context.GetEndpoint()?.Metadata.GetMetadata<IAntiforgeryMetadata>() is { RequiresValidation: false })
        {
            return null;
        }

        string? site = context.Request.Headers["Sec-Fetch-Site"];
        string? origin = context.Request.Headers.Origin;
        if (site is "same-origin" or "none")
        {
            return null;
        }

        if (origin is not null && await IsTrustedAsync(context, origin))
        {
            return null;
        }

        if (site is "same-site" or "cross-site")
        {
            return $"Sec-Fetch-Site: {site}";
        }

        if (origin is not null)
        {
            string self = $"{context.Request.Scheme}://{context.Request.Host}";
            return string.Equals(origin, self, StringComparison.OrdinalIgnoreCase) ? null : $"Origin: {origin}";
        }

        return null;
    }

    private async Task<bool> IsTrustedAsync(HttpContext context, string origin)
    {
        CorsPolicy? policy = await corsPolicies.GetPolicyAsync(context, policyName: null);
        return policy is { SupportsCredentials: true, AllowAnyOrigin: false } && policy.IsOriginAllowed(origin);
    }
}
builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
    .WithOrigins("https://admin.bookstore.test")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()));

var app = builder.Build();

app.UseCors();
app.UseMiddleware<FetchMetadataCsrfMiddleware>();

app.MapPost("/orders", (OrderRequest order) => Results.Ok(new { order.BookId }));
app.MapPost("/webhooks/payments", () => Results.Accepted()).DisableAntiforgery();

I tried each rule against the local app by imitating with curl the headers a browser would send:

same-origin                                                200
cross-site, Origin https://evil.example                    403
same-site, Origin https://blog.bookstore.test              403
same-site, Origin https://admin.bookstore.test (CORS)      200
no Sec-Fetch-Site, Origin = self                           200
no Sec-Fetch-Site, Origin https://evil.example             403
no Sec-Fetch-Site, no Origin (curl, server-to-server)      200
webhook, cross-site (DisableAntiforgery)                   202

warn: Bookstore.Web.FetchMetadataCsrfMiddleware[0]
      Cross-site request blocked: POST /orders (Sec-Fetch-Site: same-site)

This simple implementation looks only at the default CORS policy; if you also want to resolve endpoint-specific RequireCors policies, you must pass the policy name from the endpoint metadata to ICorsPolicyProvider. When you move to .NET 11 you can remove this middleware and fall back to the framework's; but do not forget to keep a filter that enforces the verdict to protect JSON endpoints.

Device Bound Session Credentials

Malware that steals a cookie can act as the user for the cookie's lifetime. The W3C (World Wide Web Consortium) draft DBSC specification binds session refresh to a private key the browser keeps on the device: the app issues a short-lived session cookie, and the browser must present a proof signed with that key to refresh it. A copied cookie can be used until it expires, but an attacker without the key cannot extend the session.

.NET 11 brings an experimental server implementation in the Microsoft.AspNetCore.Authentication.DeviceBoundSessions package. The package sits on top of an existing cookie scheme and manages the registration and refresh endpoints, a path-scoped refresh cookie and the short-lived session cookie; the ShortLivedCookieExpiration option sets the short lifetime. Setup amounts to adding AddDeviceBoundSession(...) to the AddCookie(...) chain. The package sits on NuGet at version 0.11.0-rc.1 and, according to the documentation, will remain prerelease throughout .NET 11 until the specification matures. The browser side is experimental too; Chrome's DBSC page describes the current status. So today DBSC is an "extra layer": in browsers without support the session works like a normal cookie, so do not put it into production without measuring the effect of the short cookie lifetime on user experience. The rest of session management is in the cookie authentication post of the Authentication and Authorization series.

Authorization Metadata, TLS Channel Binding and More

In the first post of the series we said that IAuthorizationRequirementData attributes are read on .NET 10 only by the middleware, that is in minimal APIs and in actions on endpoint routing. .NET 11 has a new overload, AuthorizationPolicy.CombineAsync(IAuthorizationPolicyProvider, IEnumerable<object>), and MVC filters, SignalR hub methods and Blazor's AuthorizeView and AuthorizeRouteView components use the same combining code. An attribute implementing both IAuthorizeData and IAuthorizationRequirementData (like our RequirePermissionAttribute) contributes to the decision once. The legacy MVC path with EnableEndpointRouting = false does not change.

Negotiate authentication, which uses Kerberos or NTLM (NT LAN (local area network) Manager) in Windows environments, now uses the TLS endpoint channel binding token on HTTPS (HTTP Secure) connections in Kestrel; no configuration is needed. Channel binding ties authentication to that TLS connection and makes relay attacks harder. If you need the same token in your own protocol, there is a new TryGetChannelBindingBytes method on ITlsConnectionFeature; on HTTP.sys the behavior is controlled by HttpAuthenticationHardeningLevel (Legacy, the default Medium, Strict).

There are two smaller fixes we ran into during the series. The RequestHeadersTimeout from the Kestrel post also applies to HTTP/2 and HTTP/3 trailer headers in .NET 11. In the rate limiting post we saw that Retry-After was wrong; here is the .NET 10 behavior:

using FixedWindowRateLimiter limiter = new(new FixedWindowRateLimiterOptions
{
    PermitLimit = 1, Window = TimeSpan.FromSeconds(10), QueueLimit = 0
});
Stopwatch clock = Stopwatch.StartNew();
using RateLimitLease first = limiter.AttemptAcquire();

foreach (int second in new[] { 1, 4, 7 })
{
    await Task.Delay(TimeSpan.FromSeconds(second) - clock.Elapsed);
    using RateLimitLease lease = limiter.AttemptAcquire();
    lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter);
    Console.WriteLine($"t={clock.Elapsed.TotalSeconds:F1}s acquired={lease.IsAcquired} RetryAfter={retryAfter.TotalSeconds:F1}s, next window in {10 - clock.Elapsed.TotalSeconds:F1}s");
}
t=1.0s acquired=False RetryAfter=10.0s, next window in 9.0s
t=4.0s acquired=False RetryAfter=10.0s, next window in 6.0s
t=7.0s acquired=False RetryAfter=10.0s, next window in 3.0s

.NET 10 says wait 10 seconds wherever you are in the window. According to the .NET 11 release notes, FixedWindowRateLimiter now reports the next window boundary; apps that carry this metadata into Retry-After in OnRejected will return the correct value without code changes. The same release also fixes partial token refills in TokenBucketRateLimiter and makes the CreateChained limiter forward idle duration from its inner limiters.

Production Notes: Migration Checklist

The steps this post and the documentation recommend when moving from .NET 10 to 11:

  • Find every flow that posts forms from another origin (an admin panel, a payment provider's return, an old portal). Add the origin you trust to a CORS policy with WithOrigins(...) and AllowCredentials().
  • Apply DisableAntiforgery() and [IgnoreAntiforgeryToken] only to endpoints that do not use cookies and have their own signature verification (webhooks, bearer-token APIs).
  • Keep app.UseAntiforgery() if you support browsers that do not send Fetch Metadata or want an independent token layer.
  • If your Blazor SSR apps ran without calling UseAntiforgery(), check that their forms no longer emit tokens; the documentation lists this as a breaking change.
  • While testing, set the Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware category to Debug and watch for CsrfValidationFailed events; requests sent by integration tests through HttpClient do not carry these headers, so they fall into the sixth rule and do not reveal the problem.
  • Do not leave endpoints that change state on GET; the first rule always lets them through.

Duende's September 2026 post warns that IdentityServer's iframe-based front-channel logout notifications might be dropped by this protection. By the decision table, the GET requests iframes make pass the first rule; the real risk is in protocol flows that make cross-site POSTs. Test the identity provider integration end to end with the GA release in a real browser.

Common Pitfalls

Believing a cookie-authenticated JSON API is protected after moving to .NET 11. The verdict is enforced only by form readers; the filter from the antiforgery post is still needed.

Trying to verify the CSRF verdict with integration tests. The WebApplicationFactory client sends no Sec-Fetch-Site or Origin, so the request counts as a "non-browser client" and passes. Add these headers by hand in the test.

Trusting an origin with AllowAnyOrigin or without AllowCredentials. Neither is a trust signal for CSRF; the panel keeps getting 400s and the team leans toward switching the protection off entirely.

Designing around DBSC as if it were mandatory today. The package and browser support are experimental; in unsupported browsers the session falls back to classic cookie behavior.

A Small Experiment

★☆☆ Make FetchMetadataCsrfMiddleware specific to a route group so it runs only for endpoints under /api. Then write an integration test for every row of the decision table, adding the headers by hand in the test.

★★☆ Extend the middleware to resolve endpoint-specific CORS policies too: read IEnableCorsAttribute from the endpoint metadata and pass the policy name to ICorsPolicyProvider.

★★★ Once .NET 11 GA is installed, move Bookstore to net11.0, remove your own middleware and run the same curl matrix against the framework's protection. Note which rows give different results, especially what happens on the JSON endpoint.

This is where we complete the series. From the internals of authorization to Data Protection, from rate limiting to headers, from antiforgery to Kestrel limits and observability, we have seen where the framework's security tools make which decision and how to tune them for production. Who authenticates the user and which permissions they get is the subject of the Authentication and Authorization series, and the browser-side detail of CSP (Content Security Policy) and Trusted Types is in the Frontend Security series.