ASP.NET Core
Why [ApiController] plus cookie auth means no antiforgery at all, AutoValidateAntiforgeryToken, and the model-binding content-type gap.
The rule
Apply [AutoValidateAntiforgeryToken] globally, including to API controllers, whenever any part of the application authenticates with a cookie.
ASP.NET Core's antiforgery system is solid, and the Razor Pages and MVC form tag helpers inject the token automatically with no configuration. The gap is in coverage rather than in the mechanism:
- Razor Pages validate the token automatically.
- MVC views using the form tag helper get the token injected, but validation still requires
[ValidateAntiForgeryToken]or the global filter. - Web API controllers — anything marked
[ApiController]— perform no antiforgery validation at all, by default.
That last point is the whole guide. The default is correct for an API authenticated by a bearer token, and it is a vulnerability the moment cookie authentication is used, which is exactly what happens in a Blazor app, an MVC app with an API surface, or any SPA using cookie auth.
One more ASP.NET Core specific: there is no Origin or Referer check. Django and Rails have one; this does not.
The [ApiController] gap
// Cookie authentication is configured...
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
// ...and this controller is authenticated by that cookie -- an ambient
// credential -- while performing NO antiforgery validation, because
// [ApiController] does not do it and nothing else was added.
//
// Every action below is forgeable. Nothing in the code hints at it; the
// vulnerability is in what is absent.
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class AccountController : ControllerBase
{
[HttpPost("email")]
public async Task<IActionResult> ChangeEmail([FromBody] EmailDto dto)
{
var user = await _users.GetUserAsync(User);
await _users.SetEmailAsync(user, dto.Email);
return Ok();
}
[HttpDelete]
public async Task<IActionResult> Delete()
{
await _users.DeleteAsync(await _users.GetUserAsync(User));
return Ok();
}
}
// [FromBody] with application/json is a partial mitigation -- a form cannot
// emit that content type. It is not a complete one: see the model-binding
// section below.The model-binding gap
// A common belief: "this action takes JSON, and a form cannot send JSON, so
// it cannot be reached by CSRF."
//
// That holds only if the action accepts NOTHING ELSE. ASP.NET Core model
// binding is flexible by design, and the flexibility is the problem.
[ApiController]
[Route("api/[controller]")]
public class AccountController : ControllerBase
{
// [FromBody] does restrict this to the configured input formatters --
// JSON by default. A form POST arrives as form-urlencoded, no formatter
// matches, and the request fails. This one is genuinely hard to reach.
[HttpPost("email")]
public IActionResult ChangeEmail([FromBody] EmailDto dto) { ... }
// No [FromBody]. On a non-[ApiController] controller, or with a complex
// type on certain configurations, the binder will happily populate this
// from FORM FIELDS -- which a cross-site form can supply.
[HttpPost("settings")]
public IActionResult UpdateSettings(SettingsDto dto) { ... }
// Explicitly accepts form data. Reachable by any cross-site form.
[HttpPost("profile")]
public IActionResult UpdateProfile([FromForm] ProfileDto dto) { ... }
// Simple types bind from the query string, so this is reachable by a
// top-level GET navigation -- which carries a Lax cookie in every
// browser. Note the HttpPost attribute does not prevent binding from the
// query string once the route is reached.
[HttpPost("promote")]
public IActionResult Promote(int userId) { ... }
}
// Test all of it by replaying with a changed Content-Type. See the
// method-and-content-type-bypasses guide. The reliable fix is not to rely on
// the content type at all -- add the antiforgery filter.Views and JavaScript
@* The form tag helper injects __RequestVerificationToken automatically.
A hand-written <form> with a plain action attribute does NOT get it --
the tag helper only fires on asp-* attributes. *@
<form asp-action="ChangeEmail" method="post">
<input type="email" name="email" required />
<button type="submit">Update</button>
</form>
@* For a JavaScript client, render the token explicitly: *@
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
var tokens = Antiforgery.GetAndStoreTokens(Context);
}
<meta name="csrf-token" content="@tokens.RequestToken" />Checklist
AutoValidateAntiforgeryTokenAttributeregistered as a global filter, so[ApiController]classes are covered.grep -rn 'IgnoreAntiforgeryToken'— every hit justified, and none of them on a cookie-authenticated action.- Blazor Server and Blazor Web App projects reviewed specifically: they use cookie auth and are easy to leave uncovered.
- Auth cookie:
SameSite=Lax,SecurePolicy = Always,HttpOnly = true,__Host-prefixed name. - Antiforgery cookie:
SameSite=Strict(the default) and__Host-prefixed. [HttpPost]/[HttpPut]etc. declared explicitly; no action reachable by an unintended verb.- No state-changing action binds from the query string — ASP.NET Core exempts
GET,HEAD,OPTIONS, andTRACEfrom validation. - An
OriginorSec-Fetch-Sitecheck added as middleware. ASP.NET Core provides none. See Origin, Referer, and Fetch Metadata. - Content type enforced on JSON endpoints rather than relied upon.
SignInManagerregenerates the session identity on login — the default does.
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Cookies are scoped by domain, not origin: who can set one, who receives one, and what __Host-, Secure, Path, and Partitioned actually change.
Smuggling a JSON document through enctype="text/plain", content-type confusion in body parsers, and GraphQL over GET and form encodings.
The layering order — SameSite=Lax, __Host- prefix, Fetch Metadata rejection, then a token — plus re-authentication for high-value operations.