Antiforgery

Decide when an API needs antiforgery tokens, wire AddAntiforgery and UseAntiforgery for form endpoints, and protect cookie-based SPAs.

Syntaxbuilder.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?

AuthenticationCSRF riskWhat to do
Bearer token or API key in a headerNone: browsers never add headers on their own.DisableAntiforgery() on form endpoints
Cookie, HTML forms posting to the appHighAddAntiforgery + UseAntiforgery, token in a hidden field
Cookie, SPA or BFF calling JSON endpointsLower but realSameSite 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. RequireAntiforgeryTokenAttribute on 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 ValidateRequestAsync without a try/catch. It throws AntiforgeryValidationException, which becomes a 500; use IsRequestValidAsync or 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · csharp
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.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.