Skip to content
Kumar Chandrachooda
.NET

The Antiforgery Check That Fails at Boot

FastEndpoints ships a CSRF middleware that is opt-in per endpoint, only inspects form posts, and refuses to start your app if you wired it up wrong. It earned a single clause in seventeen parts - here is the whole story. Part 3 of FastEndpoints — The Missing Chapters.

By Kumar Chandrachooda 03 Jun 2026 5 min read
A form post meets the token gate - no token, no handler

The parent series gave security two full parts - declarative policies and the JWT lifecycle - and still managed to reduce the library's CSRF protection to half a sentence. That is partly the feature's fault: AntiforgeryMiddleware is one small file in Src/Library/Middleware/. But small files with sharp opinions are exactly what this series is for, and this one has three: protection is opt-in per endpoint, only form-shaped requests are ever inspected, and misconfiguration is a startup crash, not a runtime surprise.

Why an API framework ships CSRF protection at all

Cross-site request forgery is a browser problem: a hostile page makes the victim's browser submit a request to your site, riding the cookies the browser attaches automatically. Token-in-header APIs are naturally immune - a cross-origin attacker can't set your Authorization header. But the moment an endpoint accepts a cookie-authenticated form post - a file upload behind cookie auth, a classic HTML form, anything multipart/form-data - you are back in CSRF territory, and FastEndpoints has first-class form binding (parent Part 4), so plenty of its endpoints qualify.

ASP.NET Core's answer is the IAntiforgery service and its request-token/cookie-token pair. MVC wires it into Razor automatically; Minimal APIs (before .NET 8's MapPost filters matured) left you to do it yourself. FastEndpoints' contribution is a thin middleware that connects IAntiforgery to its own endpoint metadata.

Three calls to turn it on

var bld = WebApplication.CreateBuilder(args);
bld.Services
   .AddAntiforgery()          // the standard ASP.NET Core service
   .AddFastEndpoints();

var app = bld.Build();
app.UseAntiforgeryFE()        // the FastEndpoints middleware
   .UseFastEndpoints();
app.Run();

Then, on exactly the endpoints that need it:

public class UploadStatementEndpoint : Endpoint<UploadStatementRequest>
{
    public override void Configure()
    {
        Post("/statements/upload");
        AllowFileUploads();
        EnableAntiforgery();   // opt in - everything else is untouched
    }
    // ...
}

EnableAntiforgery() just flips AntiforgeryEnabled on the endpoint's definition (through the same lock-checked setter path as every other Configure() call, so you can't flip it after startup). The interesting decisions are all in the middleware's Invoke.

The bypass ladder

Reading AntiforgeryMiddleware.cs, a request has to fall through four trapdoors before a token is ever checked:

  1. Safe verbs skip. GET, HEAD, OPTIONS, TRACE - and, a nice detail of how current this codebase stays, the new QUERY method - pass straight through. CSRF is about state changes; safe verbs shouldn't change state, and if yours do, no token scheme will save you.
  2. Your skip filter runs. UseAntiforgeryFE(skipRequestFilter: ctx => ctx.Request.Path.StartsWithSegments("/webhooks")) lets you exempt whole request classes - handy for signature-verified webhook receivers that would otherwise need fake tokens.
  3. Only form content types are inspected. An empty Content-Type passes. Otherwise the middleware validates only application/x-www-form-urlencoded, anything starting with multipart/form-data, or a content type you explicitly added via the additionalContentTypes parameter. A JSON POST never pays the antiforgery tax - which is correct, because a cross-origin attacker can't send application/json without a CORS preflight.
  4. The endpoint must have opted in. The middleware reads the EndpointDefinition from the route metadata - the same object the request handler itself rides in on - and checks AntiforgeryEnabled is true. No flag, no validation.

Only a form post, to an opted-in endpoint, that no filter exempted, gets handed to IAntiforgery.ValidateRequestAsync.

Failure speaks the house dialect

When validation throws AntiforgeryValidationException, the middleware does not let it bubble into a 500 or hand you ASP.NET's bare 400. It short-circuits with the library's own error machinery: a single ValidationFailure on the configured general-errors field, message "Anti-forgery token is invalid!", serialized through the same ResponseBuilder as every binding and validation error (parent Part 5). If you switched the app to RFC 7807 problem details, your CSRF failures are problem details too. Clients get one error grammar for the whole API - a small consistency that saves real client-side code.

Issuing tokens is your job, and it is three lines with the standard service:

public class AntiforgeryTokenEndpoint(IAntiforgery af) : EndpointWithoutRequest<TokenResponse>
{
    public override void Configure() => Get("/antiforgery/token");

    public override async Task HandleAsync(CancellationToken ct)
    {
        var tokens = af.GetAndStoreTokens(HttpContext);   // sets the cookie half
        await Send.OkAsync(new() { Token = tokens.RequestToken!, Field = tokens.FormFieldName });
    }
}

The SPA fetches that once, then includes the token as a form field (or the configured header) on each protected post. The cookie half travels automatically; the pair has to match.

The boot-time tripwire

My favourite part is not in the middleware at all - it is in UseFastEndpoints' startup pass. While binding endpoints, the library checks: does any endpoint have AntiforgeryEnabled? If so, both of these must be true - IAntiforgery is registered in the container, and UseAntiforgeryFE() was actually called (the middleware sets a static IsRegistered flag when added). Either missing, and startup throws:

InvalidOperationException: AntiForgery middleware setup is incorrect!

Consider what the failure mode would be without this check. EnableAntiforgery() sets a flag that only the middleware reads; forget UseAntiforgeryFE() and every request sails through as if protected. Security features that fail open are the worst kind of bug - present in code review, absent at runtime, invisible until the incident. FastEndpoints converts that silent hole into a crash the first time the app boots in CI. It is the same philosophy the discovery layer applies to duplicate routes and misconfigured endpoints: if the developer's intent cannot be honoured, refuse to start.

The static IsRegistered flag is also a tell worth noting for later: this library is comfortable with process-wide static state in ways that occasionally bite (parent Part 17 called it a sharp edge). Here it is harmless - middleware registration genuinely is per-process. Next week we meet the same pattern where it is much less harmless: the entire configuration system.

When to reach for it

My rule of thumb after reading the source: if an endpoint binds IFormFile or [FromForm] and the app uses cookie authentication, EnableAntiforgery() belongs in its Configure() - the cost is one middleware check on form posts only. If your API is pure bearer-token JSON, skip the whole package; the middleware would never validate anything anyway, and the bypass ladder tells you so honestly.

Small file, three opinions, one tripwire. Next part climbs from one middleware's static flag to the biggest static of them all: the Config object.