Here are what I believe to be bits of the Microsoft docs you link to on how to handle this. They say that "using local storage to store the antiforgery token on the client and sending the token as a request header is a recommended approach." They also go on to say that the approach is to use middleware to generate an antiforgery token and send it in the response as a cookie. In short they are saying if you have an API put the antiforgery token in a cookie.
As you say with just AddControllers you can't use the [ValidateAntiForgeryToken]. As LarryX says the thing to do is create your own filter.
In case it helps anyone I have created a demo app that uses some custom middleware to check for the antiforgery token if the request is not a GET.
Note that the CORS code is just there so that I could make a post from another domain to test the code works (I tested with https://localhost:44302).
Standard Program.cs (nothing interesting here)
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace SpaAntiforgery
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
Startup.cs
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace SpaAntiforgery
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddCors();
services.AddControllers();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN");
}
public void Configure(IApplicationBuilder app, IAntiforgery antiforgery)
{
//CORS code that is needed if you want another domain to access your API
app.UseCors(
options => options.WithOrigins("https://localhost:44302")
.AllowAnyMethod()
.AllowCredentials()
.WithHeaders("x-csrf-token", "content-type"));
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
//this bit is straight form the Microsoft docs. See the link reference at the start of my answer
app.Use(next => context =>
{
string path = context.Request.Path.Value;
if (
string.Equals(path, "/", StringComparison.OrdinalIgnoreCase) ||
string.Equals(path, "/index.html", StringComparison.OrdinalIgnoreCase))
{
// The request token can be sent as a JavaScript-readable cookie,
var tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append("XSRF-TOKEN", tokens.RequestToken,
new CookieOptions() { HttpOnly = false });
}
return next(context);
});
//this is my custom middleware that will test for the antiforgery token if the request is not a GET
app.EnsureAntiforgeryTokenPresentOnPosts();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapFallbackToController("Index", "Home");
});
}
}
}
Here is the custommiddleware code that is needed for app.EnsureAntiforgeryTokenPresentOnPosts();
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System;
using System.Threading.Tasks;
namespace SpaAntiforgery
{
public class AppEnsureAntiforgeryTokenPresentOnPostsMiddleware
{
private readonly RequestDelegate _next;
private readonly IAntiforgery _antiforgery;
public AppEnsureAntiforgeryTokenPresentOnPostsMiddleware(RequestDelegate next, IAntiforgery antiforgery)
{
_next = next;
_antiforgery = antiforgery;
}
public async Task Invoke(HttpContext httpContext)
{
var notAGetRerquest = !string.Equals("GET", httpContext.Request.Method, StringComparison.OrdinalIgnoreCase);
if (notAGetRerquest)
{
// This will throw if the token is invalid.
await _antiforgery.ValidateRequestAsync(httpContext);
}
await _next(httpContext);
}
}
public static class AppEnsureAntiforgeryTokenPresentOnPostsExtension
{
public static IApplicationBuilder EnsureAntiforgeryTokenPresentOnPosts(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<AppEnsureAntiforgeryTokenPresentOnPostsMiddleware>();
}
}
}
HomeController.cs
The idea is to make a get to this endpoint so that your client code can retrieve the antiforgery token.
using Microsoft.AspNetCore.Mvc;
namespace SpaAntiforgery.Controllers
{
[Route("[controller]")]
[ApiController]
public class HomeController: ControllerBase
{
public IActionResult Index()
{
return Ok();
}
}
}
I also included a controller to test out a post.
using Microsoft.AspNetCore.Mvc;
namespace SpaAntiforgery.Controllers
{
[Route("[controller]")]
[ApiController]
public class TestAntiforgeryController : ControllerBase
{
[HttpPost]
public IActionResult Index()
{
return Ok();
}
}
}
Sending a post request to /testantiforgery using something like Postman results in an error because the post does not include the antiforgery token. This is what we want.
In order to test that a successful post can be made I created another website with the following code. Note the getCookie method comes straight from the Microsoft docs that I linked to at the start of my answer.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<button id="MyButton">
Test
</button>
<script>
const getCookie = cookieName => {
var name = cookieName + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(";");
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == " ") {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
};
const getCsrfToken = () => {
return getCookie("CSRF-TOKEN");
};
const getHeadersIncludingCsrfToken = () => {
const defaultHeaders = {
Accept: "application/json",
"Content-Type": "application/json"
};
return { ...defaultHeaders, "X-CSRF-TOKEN": getCsrfToken()};
};
const sendRequest = async (url, settings, done) => {
const baseUrl = "https://localhost:44333";
const response = await fetch(baseUrl + url, settings);
if (response.status !== 200) {
console.log("there was an api error");
return;
}
done();
};
const sendGet = async (url, done) => {
const settings = {
method: "GET"
};
await sendRequest(url, settings, done);
};
const sendPost = async (url, done) => {
const settings = {
method: "POST",
headers: getHeadersIncludingCsrfToken()
};
settings.credentials = "include";
await sendRequest(url, settings, done);
};
const sendAPost = () => {
sendPost("/testantiforgery", () => console.log("post succeeded!"));
}
const onTest = () => {
//sending a get to / means the antiforgery cookie is sent back
sendGet("/", sendAPost);
};
const MyButton = document.getElementById("MyButton");
MyButton.addEventListener("click", onTest);
</script>
</body>
</html>
As you can see from the javascript code, after clicking the button, the code sends a GET, this is just to retreive the antiforgery token. The GET is followed by a post. The CSRF-TOKEN is retreived from the cookies and included in the request headers. Note if trying this code out for yourself you will need to set your own baseUrl in the javascript code and also set your own url in the UseCors method in the Configure of Startup.