Antiforgery in Depth: CSRF Protection with Cookie Authentication, SPAs and Minimal APIs
ASP.NET Core's antiforgery middleware does not reject requests; it writes the result to a feature, and in minimal APIs only form binding enforces it, so cookie-authenticated JSON endpoints accept token-less requests with 200. With real output we cover the token pair internals, Angular's XSRF-TOKEN pattern, an endpoint filter that enforces validation, refreshing the token after login and deliberate use of DisableAntiforgery.
Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime).
Guess First
Bookstore's Angular app uses cookie authentication, and the order endpoint is a minimal API (Application Programming Interface) that takes JSON (JavaScript Object Notation). Someone on the team added .WithMetadata(new RequireAntiforgeryTokenAttribute()) to the endpoint, and app.UseAntiforgery() is in the pipeline. What happens when a request arrives without any antiforgery token: 400 or 200? Second question: what happens if the user, after logging in, uses the valid token received before login?
The answer to the first question is 200, and it is the most important finding of this post. We covered the basics of CSRF (cross-site request forgery), together with cookie flags, in the post "OWASP A07: Authentication Failures (2)" of the Web Security series, which covers the OWASP (Open Worldwide Application Security Project) Top 10. Here we look at how ASP.NET Core's antiforgery system works inside, where the automatic protection in minimal APIs ends, and how to set it up properly for an SPA (single-page application) built with Angular.
When Is CSRF a Real Risk?
For CSRF to exist, the browser must send the credential on its own. With cookie authentication it does: while the user is on another site, that site's form POSTs to Bookstore and the browser attaches the bookstore.auth cookie. A client that sends its access token in an Authorization: Bearer header has no such risk; another site cannot make the user's browser add that header. So the question is not "should my API use antiforgery" but "does my API authenticate with cookies". If the Angular app works with a cookie through a BFF (Backend for Frontend) instead of keeping tokens in the browser, which is the architecture recommended in the BFF post of the Authentication and Authorization series, CSRF is back on the table.
SameSite cookies solve most of it but not all. A SameSite=Lax cookie is not sent on a POST coming from another site, but "site" is not "origin". shop.bookstore.test and blog.bookstore.test count as the same site. An XSS (cross-site scripting) hole on the blog, or a subdomain handed over to someone else, disables SameSite entirely. On top of that, an endpoint that changes state on GET is always unprotected under a Lax cookie on top-level navigation. So every state-changing endpoint that relies on cookies needs a second check.
The Token Pair: How IAntiforgery Works
ASP.NET Core uses a cryptographic variant of double-submit. IAntiforgery.GetAndStoreTokens produces two tokens. The cookie token carries a random 128-bit security value and is written to an HttpOnly cookie. The request token carries the same security value plus the user's identity; it goes into the page, the form or a header. Validation opens both tokens with Data Protection, checks that the security values match and that the identity in the request token is the current user. The identity is a SHA-256 digest, from the SHA (Secure Hash Algorithm) family, computed from the sub, NameIdentifier or UPN (User Principal Name) claim, or from all the identity's claims if none of those exists. An attacker cannot read the cookie, and even a request token brought from the attacker's own session does not match the identity. Because both tokens are encrypted with Data Protection, the key ring problems from the series' Data Protection post come back here as 400s.
Automatic Protection in Minimal APIs and Its Limit
Since .NET 8, a minimal API endpoint that binds a form ([FromForm], IFormFile, IFormCollection) automatically gets antiforgery metadata. Let us try it with a small app to see its limits:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo("keys"));
builder.Services.AddAntiforgery();
var app = builder.Build();
if (args.Contains("--with-middleware")) app.UseAntiforgery();
app.MapPost("/form", ([FromForm] string title) => Results.Ok(title));
app.MapPost("/json-required", (Book book) => Results.Ok(book)).WithMetadata(new RequireAntiforgeryTokenAttribute());
app.MapPost("/json-feature", (Book book, HttpContext ctx) => Results.Ok(new { book, valid = ctx.Features.Get<IAntiforgeryValidationFeature>()?.IsValid })).WithMetadata(new RequireAntiforgeryTokenAttribute());
app.Run();
record Book(string Title);
== mode: none
HTTP/1.1 500 Internal Server Error
System.InvalidOperationException: Endpoint HTTP: POST /form contains anti-forgery metadata, but a middleware was not found that supports anti-forgery.
Configure your application startup by adding app.UseAntiforgery() in the application startup code. If there are calls to app.UseRouting() and app.UseEndpoints(...), the call to app.UseAntiforgery() must go between them. Calls to app.UseAntiforgery() must be placed after calls to app.UseAuthentication() and app.UseAuthorization().
== mode: --with-middleware
HTTP/1.1 400 Bad Request
{"title":"Dune"} 200
{"book":{"title":"Dune"},"valid":false} 200
There are three results. AddAntiforgery alone does not add the middleware; in .NET 10, if you forget UseAntiforgery(), form endpoints return 500 on every request. With the middleware in place, a form request without a token gets 400. But the JSON endpoints returned 200 even with RequireAntiforgeryTokenAttribute. The reason is that AntiforgeryMiddleware does not reject the request: it validates, writes the result to IAntiforgeryValidationFeature and moves on. What reads that result and turns it into a 400 is form binding. An endpoint reading a JSON body never looks at the feature; the valid: false on the third line shows this plainly. In MVC (Model-View-Controller) the [ValidateAntiForgeryToken] filter does this job; in a minimal API you must do it yourself.
The XSRF-TOKEN Pattern with Angular
If Angular's HttpClient finds a cookie named XSRF-TOKEN, it adds its value to state-changing requests as an X-XSRF-TOKEN header; you can change the names with withXsrfConfiguration. Angular does not add it to GET and HEAD requests or to requests whose URL (Uniform Resource Locator) is not on the page's origin, so that the token does not leak to another origin. On the server there are three parts: matching the header name to Angular's, writing the request token to a cookie JavaScript can read, and an endpoint filter that makes validation mandatory on JSON endpoints:
public static class XsrfCookie
{
public const string Name = "XSRF-TOKEN";
public static void Issue(HttpContext context, IAntiforgery antiforgery)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append(Name, tokens.RequestToken!, new CookieOptions
{
HttpOnly = false,
Secure = context.Request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/"
});
}
}
public sealed class ValidateAntiforgeryFilter(IAntiforgery antiforgery, ILogger<ValidateAntiforgeryFilter> logger)
: IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
try
{
await antiforgery.ValidateRequestAsync(context.HttpContext);
}
catch (AntiforgeryValidationException ex)
{
logger.LogWarning("Antiforgery validation failed for {Method} {Path}: {Reason}",
context.HttpContext.Request.Method, context.HttpContext.Request.Path, ex.Message);
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Invalid antiforgery token");
}
return await next(context);
}
}
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN";
options.Cookie.Name = "bookstore.af";
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapGet("/antiforgery/token", (HttpContext context, IAntiforgery antiforgery) =>
{
XsrfCookie.Issue(context, antiforgery);
return Results.NoContent();
});
RouteGroupBuilder api = app.MapGroup("/api")
.RequireAuthorization()
.AddEndpointFilter<ValidateAntiforgeryFilter>();
api.MapPost("/orders", (OrderRequest order, ClaimsPrincipal user) =>
Results.Ok(new { orderedBy = user.Identity!.Name, order.BookId }));
app.MapPost("/account/email", ([FromForm] string email, ClaimsPrincipal user) =>
Results.Ok(new { user = user.Identity!.Name, email }))
.RequireAuthorization();
The XSRF-TOKEN cookie is deliberately not HttpOnly: Angular has to read it. The attacker's site cannot read this cookie, because the same-origin policy gives no JavaScript access to another origin's cookies. The real security value lives in the HttpOnly bookstore.af cookie. Putting the filter on the route group guarantees that every new endpoint added to the group is protected; we do not leave it to per-endpoint memory. The filter does not run ValidateRequestAsync only for state-changing methods, it validates on GET too; so put it on a group that contains only POST, PUT, PATCH and DELETE endpoints, or add a method check.
I sent the requests Angular would make to the local app with curl. Since the only thing the attacker has is the cookies the browser sends automatically, the "cookies only" request is the smallest proof of concept of a cross-site request:
== token
Set-Cookie: bookstore.af=...; path=/; samesite=strict; httponly
Set-Cookie: XSRF-TOKEN=...; path=/; samesite=strict
== order with fresh token
{"orderedBy":"ada","bookId":7} 200
== order cookies only (cross-site attempt)
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid antiforgery token","status":400,"traceId":"00-f7b35e360b7fba4505826b83efb0fd66-5f55a5dca2d7d0a5-00"} 400
== form without token
400
== form with header token
{"user":"ada","email":"ada@bookstore.test"} 200
The form endpoint also accepted the token from the header, because with HeaderName set, antiforgery looks at the header first and then the form field. The 400 for the form request without a token came back with no body and left no trace at the default log levels; the cause only shows up at Debug level in the Microsoft.AspNetCore.Http.RequestDelegateFactory category. That is what makes the Warning log in our filter valuable.
Refreshing the Token after Login
The second opening question. The login endpoint is protected by antiforgery too (login CSRF is the attacker logging the victim into the attacker's own account), so the user first gets an anonymous token. If the user places an order with the same token after logging in:
app.MapPost("/login", async (LoginRequest request, HttpContext context, IAntiforgery antiforgery) =>
{
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, request.UserName)], CookieAuthenticationDefaults.AuthenticationScheme));
await context.SignInAsync(principal);
if (request.RefreshToken)
{
// the new token must be bound to the new identity
context.User = principal;
XsrfCookie.Issue(context, antiforgery);
}
return Results.Ok(new { signedIn = request.UserName });
}).AddEndpointFilter<ValidateAntiforgeryFilter>();
== login without refresh
{"signedIn":"ada"} 200
== order with pre-login token
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid antiforgery token","status":400,"traceId":"00-d590b625eff3f500d33dfb26850d388c-33f1f5cfef1fe6fa-00"} 400
warn: Bookstore.Web.ValidateAntiforgeryFilter[0]
Antiforgery validation failed for POST /api/orders: The provided antiforgery token was meant for a different claims-based user than the current user.
warn: Bookstore.Web.ValidateAntiforgeryFilter[0]
Antiforgery validation failed for POST /api/orders: The required antiforgery header value "X-XSRF-TOKEN" is not present.
The identity field in the anonymous token was empty; on the request after login the user is Ada. If the token is not refreshed, the user gets a 400 on the very first action after logging in. SignInAsync does not change HttpContext.User; the new cookie is only read on the next request. That is why we assign context.User = principal before generating the token; otherwise the new token is also bound to the anonymous identity. The same applies to logout and switching users: refresh the token wherever the identity changes.
Using DisableAntiforgery Deliberately
The payment provider's webhook posts a form, but it does not come from a browser and carries no cookies; it cannot send an antiforgery token either. DisableAntiforgery() removes the endpoint's automatic metadata:
app.MapPost("/webhooks/payments", ([FromForm] string paymentId) => Results.Accepted())
.DisableAntiforgery();
The webhook returned 202 without a token. This line does not remove a security check, it replaces it: writing DisableAntiforgery without verifying the webhook's signature (for instance a header signed with HMAC, Hash-based Message Authentication Code) opens the endpoint to everyone. The rule: DisableAntiforgery only on endpoints that do not authenticate with cookies and have their own verification mechanism. This line is easy to search for in code review, and every occurrence should raise a question.
.NET 11: Automatic Protection with Fetch Metadata
.NET 11 adds a CSRF middleware, on by default, to apps built with WebApplication.CreateBuilder. Instead of tokens it looks at the Sec-Fetch-Site and Origin headers, which the browser sends and JavaScript cannot alter: it lets same-origin requests through, denies cross-site and same-site requests and reads trusted origins from the CORS (Cross-Origin Resource Sharing) policy. Both questions from this post look different there; the verdict is still enforced only by endpoints that read forms. The details, the scenarios that can break during migration and when the token system is still needed are in the last post of the series.
Production Notes
Count antiforgery failures. The Warning log in the filter lets you catch a sudden rise after a deploy (usually a key ring problem or a new login flow that does not refresh the token); a steady low level is either requests from stale tabs or real attempts. We do not send the error message to the client, we only log it.
Do not regenerate the XSRF-TOKEN cookie on every request. Generate the token when the app starts and when the identity changes; a new token on every response does not make two open tabs invalidate each other's tokens, but it does mean needless Data Protection work and large Set-Cookie headers.
If you run on several subdomains, write cookies without Domain, that is, only for your own host. An XSRF-TOKEN written with Domain=.bookstore.test makes it easier for an XSS on a sibling subdomain to read and use the token.
Common Pitfalls
Believing a JSON endpoint is protected by RequireAntiforgeryTokenAttribute. The metadata makes the middleware validate, but nobody enforces the result; the 200 that came back with valid: false above is the proof.
Calling UseAntiforgery before UseAuthentication. Antiforgery validates the token against the user; before authentication runs the user is anonymous, and everyone who is logged in gets 400. The correct order: authentication, authorization, antiforgery.
Changing state with GET. In the source code AntiforgeryMiddleware validates only POST, PUT and PATCH requests; an endpoint like GET /orders/42/cancel bypasses every protection, and DELETE also falls outside the middleware's scope. Our filter validates without looking at the method, so it covers DELETE too.
Adding antiforgery to an API that uses bearer tokens "just in case". It seems harmless, but mobile clients and services cannot obtain a token, and at the first problem the team writes DisableAntiforgery everywhere.
A Small Experiment
★☆☆ Remove ValidateAntiforgeryFilter from the group and send the order request again with cookies only. Then write a small filter that reads IAntiforgeryValidationFeature instead, and find the cases where the two approaches behave differently.
★★☆ Call the login endpoint with RefreshToken = false, then call /antiforgery/token again as Angular would and send the order. Think about the difference between refreshing the token in the login response and refreshing it with a separate call in terms of race conditions.
★★★ Write the antiforgery cookie with the __Host- prefix (__Host-bookstore.af) and run over HTTPS (HTTP Secure). Observe the rules the browser enforces for this prefix and what happens over plain HTTP (Hypertext Transfer Protocol).
In this post we stopped requests that abuse the browser's automatic behavior. In the next post we go one level down to Kestrel: request limits for large bodies, slow clients and connections that try to keep the server busy.