Antiforgery
Decide when an API needs antiforgery tokens, wire AddAntiforgery and UseAntiforgery for form endpoints, and protect cookie-based SPAs.
builder.Services.AddAntiforgery();
app.UseAntiforgery(); // after UseAuthentication/UseAuthorization
app.MapPost("/orders/{id}/cancel", (int id, [FromForm] string reason) => ...);
webhooks.DisableAntiforgery();Cross-site request forgery works because browsers attach cookies automatically. A page on another site submits a form to https://shop.example.com/orders/7/cancel, the browser adds the user's authentication cookie, and the server cannot tell the request apart from a real one. Antiforgery adds a second value the attacker cannot read or guess: a cookie token plus a request token sent in a form field or header. They are a matched pair, and the request token is also bound to the signed-in user.
Does your API need it?
| Authentication | CSRF risk | What to do |
|---|---|---|
| Bearer token or API key in a header | None: browsers never add headers on their own | .DisableAntiforgery() on form endpoints |
| Cookie, HTML forms posting to the app | High | AddAntiforgery + UseAntiforgery, token in a hidden field |
| Cookie, SPA or BFF calling JSON endpoints | Lower but real | SameSite cookies plus a token in a header |
JSON endpoints get some protection for free: a cross-site fetch with Content-Type: application/json triggers a CORS preflight that your policy refuses. SameSite=Lax, the cookie authentication default, stops cross-site POSTs from carrying the cookie. Neither covers everything: SameSite treats evil.example.com and shop.example.com as the same site, a GET with side effects is still exposed, and an endpoint that also accepts form posts is a simple request.
How it works in minimal APIs
Register builder.Services.AddAntiforgery() and call app.UseAntiforgery() after authentication and authorization. Nothing adds the middleware for you: an endpoint that binds [FromForm] or IFormFile carries antiforgery metadata, and without the middleware every request to it fails with a 500 InvalidOperationException. The middleware validates requests to those endpoints and records the outcome in IAntiforgeryValidationFeature; form binding then rejects an invalid request with 400.
The token comes from IAntiforgery.GetAndStoreTokens(context), which sets the cookie (named .AspNetCore.Antiforgery.*, HttpOnly, SameSite=Strict) and returns the request token. The defaults are the form field __RequestVerificationToken and the header RequestVerificationToken. Tokens are protected with Data Protection keys, so every instance must share the key ring.
Production failure modes
- Marking a JSON endpoint and assuming it is protected.
RequireAntiforgeryTokenAttributeon an endpoint that binds JSON makes the middleware validate, but nothing rejects the request; check the feature in a filter. - Token issued before sign-in. The request token embeds the user's identity, so a token fetched anonymously fails with 400 after login. Issue a fresh token after signing in.
- Calling
ValidateRequestAsyncwithout a try/catch. It throwsAntiforgeryValidationException, which becomes a 500; useIsRequestValidAsyncor catch and return 400. - Keys not shared across replicas. A token issued by one pod fails on another, seen as random 400s after scaling out.
Example
When to use it
- Reject a forged POST /orders/7/cancel from a malicious page while the customer's cookie session is active.
- Protect a cookie-based BFF for a React storefront with an X-XSRF-TOKEN header checked by a route group filter.
- Turn antiforgery off on a signed payment-provider webhook that posts form data but never carries cookies.
More examples
Cookie-authenticated SPA: token in a header
This is the double-submit pattern frameworks like Angular expect: a readable <code>XSRF-TOKEN</code> cookie that the script echoes in a header. Another site cannot read your cookies, so it cannot produce the header. The <code>RequireAntiforgeryTokenAttribute</code> metadata makes the middleware validate JSON requests too; the filter is what turns an invalid result into a 400, because no form binding exists to do it.
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN");
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// The SPA calls this after sign-in and copies the cookie value into the X-XSRF-TOKEN header.
app.MapGet("/antiforgery/token", (IAntiforgery antiforgery, HttpContext context) =>
{
var tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append("XSRF-TOKEN", tokens.RequestToken!, new CookieOptions
{
HttpOnly = false, // script must read it
Secure = true,
SameSite = SameSiteMode.Strict
});
return TypedResults.NoContent();
}).RequireAuthorization();
var api = app.MapGroup("/api")
.RequireAuthorization()
.WithMetadata(new RequireAntiforgeryTokenAttribute())
.AddEndpointFilter(async (context, next) =>
{
// The middleware validated already; a JSON endpoint has to act on the result itself.
var validation = context.HttpContext.Features.Get<IAntiforgeryValidationFeature>();
if (validation is { IsValid: false })
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest,
title: "Missing or invalid antiforgery token");
}
return await next(context);
});
api.MapPost("/orders/{id:int}/cancel", (int id) => TypedResults.Ok(new { id, status = "cancelled" }));
app.Run();Issuing a fresh token after sign-in
A request token embeds the identity it was issued for, so a token fetched before login fails validation afterwards with a 400 whose log says the token was meant for a different claims-based user. <code>SignInAsync</code> only writes the cookie for the next request; <code>context.User</code> still holds the anonymous principal, so setting it before <code>GetAndStoreTokens</code> is what binds the new token to the user who just signed in.
using System.Security.Claims;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapPost("/account/login", async (LoginRequest login, HttpContext context, IAntiforgery antiforgery) =>
{
// Credential check omitted: verify the password hash before this point.
var principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, "c-1042"), new Claim(ClaimTypes.Name, login.Email)],
CookieAuthenticationDefaults.AuthenticationScheme));
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
// The old token belongs to the anonymous user. Generate the new one for the signed-in user.
context.User = principal;
var tokens = antiforgery.GetAndStoreTokens(context);
return TypedResults.Ok(new { requestToken = tokens.RequestToken });
}).DisableAntiforgery(); // JSON body, and there is no session to forge yet
app.Run();
record LoginRequest(string Email, string Password);Hardened cookie and token settings
The antiforgery cookie's <code>SecurePolicy</code> defaults to <code>None</code>, so it is sent over plain HTTP unless you change it. The <code>__Host-</code> prefix closes the gap SameSite leaves open: a compromised <code>blog.example.com</code> could otherwise set a cookie for <code>.example.com</code> and replace the token pair. The session cookie stays <code>Lax</code> so links from emails still arrive signed in.
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery(options =>
{
// __Host- prefix: browsers accept it only with Secure, Path=/ and no Domain,
// so a sibling subdomain cannot plant or overwrite the cookie.
options.Cookie.Name = "__Host-orders-af";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // default is None
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.Path = "/";
options.HeaderName = "X-XSRF-TOKEN";
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "__Host-orders-session";
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapGet("/", () => "ok");
app.Run();
Discussion