Add a custom action filter in ASP.NET Core

Action filters allow you to look at requests right before they are routed to an action method (and responses right after they are returned from the action method).

The simplest way to add your own action filter in ASP.NET Core is to subclass ActionFilterAttribute and then override the appropriate methods depending on if you want to look at the request, result, or both.

Here’s an example that overrides OnActionExecuting() so it can look at the request:

using Microsoft.AspNetCore.Mvc.Filters;

public class RequestLogger : ActionFilterAttribute
{
	public override void OnActionExecuting(ActionExecutingContext context)
	{
		Console.WriteLine($"Request {context.HttpContext.Request.Method} {context.HttpContext.Request.Path} routed to {context.Controller.GetType().Name}");

		base.OnActionExecuting(context);
	}
}
Code language: C# (cs)

Read about how to unit test action filters.

Then apply the action filter to specific action methods, controllers, or apply it to all controllers. This is adding it to a specific action method:

[ApiController]
[Route("[controller]")]
public class HealthStatusController : ControllerBase
{
	[HttpGet()]
	[RequestLogger()]
	public IActionResult Get()
	{
		return Ok();
	}
}
Code language: C# (cs)

When a request comes in, it goes through this RequestLogger action filter and outputs this to the console:

Request GET /healthstatus/ routed to HealthStatusControllerCode language: plaintext (plaintext)

In this article, I’ll show how to apply action filters to the three different levels (action, controller, and global). I’ll explain how the framework creates action filter instances by default (and how to use type-activation registration instead when you need thread safety or dependency injection support). At the end, I’ll show multiple examples of custom action filters.

Apply an action filter to the different levels: action, controller, and global

You can apply action filters to one or more specific action methods:

[HttpGet()]
[RequestLogger()]
public IActionResult Get()
Code language: C# (cs)

You can add the action filter to the controller to apply it to all actions in the controller:

[ApiController]
[Route("[controller]")]
[RequestLogger()]
public class HealthStatusController : ControllerBase
{	
	[HttpGet()]
	public IActionResult Get()
	{
		return Ok();
	}

	[HttpPost("SetResponse/{status}")]
	public IActionResult SetResponse(HealthStatus status)
	{
		return Ok();
	}
}
Code language: C# (cs)

Finally, you can apply it globally by adding it to the initialization code:

var builder = WebApplication.CreateBuilder(args);

// rest of adding services

builder.Services.AddControllers(options => options.Filters.Add(new RequestLogger()));

var app = builder.Build();

//rest of init code
Code language: C# (cs)

Note: Before .NET 6, this was done in Startup.ConfigureServices.

Adding it globally makes it apply to all action methods in all controllers. Note: The only reason to use a global action filter instead of a middleware function is if you need the information provided by the action context (such as which controller it’s going to use).

How the framework creates action filter instances

Normally when you add services in ASP.NET Core, you have to register it and specify if it’s a singleton, transient, or scoped. With action filters, you just add the action filter attribute (i.e. [SomeActionFilter]) or add the global filter using new().

When you use this default registration approach, the framework creates a single instance per registration. This results in using the same instance for multiple requests, which can lead to problems if you’re not aware of this behavior.

To illustrate this point, consider the following action filter class that logs its instance id:

public class RequestLogger : ActionFilterAttribute
{
	public readonly string Id = Guid.NewGuid().ToString();
	public override void OnActionExecuting(ActionExecutingContext context)
	{
		Console.WriteLine($"Id={Id} Request {context.HttpContext.Request.Method} {context.HttpContext.Request.Path} routed to {context.Controller.GetType().Name}");

		base.OnActionExecuting(context);
	}
}
Code language: C# (cs)

Now apply this to multiple action methods:

[HttpGet()]
[RequestLogger()]
public IActionResult Get()
{
	return Ok();
}

[HttpPost("SetResponse/{status}")]
[RequestLogger()]
public ActionResult SetResponse(HealthStatus status)
{
	return Ok();
}
Code language: C# (cs)

Now send multiple GET requests:

Id=ea27a176-6a1f-4a25-bd26-6c5b865e2844 Request GET /healthstatus/ routed to HealthStatusController
Id=ea27a176-6a1f-4a25-bd26-6c5b865e2844 Request GET /healthstatus/ routed to HealthStatusControllerCode language: plaintext (plaintext)

Notice the id is the same. This is because a single RequestLogger action filter instance is being used for the Get() action method.

Now send multiple POST requests:

Id=7be936e7-d147-42af-9316-834cbfb9adb3 Request POST /healthstatus/setresponse/healthy routed to HealthStatusController
Id=7be936e7-d147-42af-9316-834cbfb9adb3 Request POST /healthstatus/setresponse/healthy routed to HealthStatusControllerCode language: plaintext (plaintext)

Notice the id is the same for two POST requests, but it’s different from id shown for the GET requests. This is because one instance is created per registration ([RequestLogger] was registered on the GET and POST methods, hence two instances).

Since multiple requests use the same instance, it’s not thread-safe. This is only a problem if your action filter has instance fields / shared data. To solve this problem, you can use type-activation registration instead (shown next).

Use type-activation registration for thread-safety and dependency injection

Using type-activation solves two problems with action filters:

  • It creates a new instance per request, so your action filters can have instance fields without it being thread unsafe.
  • It allows you to inject dependencies into the action filter.

To do type-activation registration, first add the action filter as a service in the initialization code:

var builder = WebApplication.CreateBuilder(args);

// rest of adding services

builder.Services.AddScoped<RequestLogger>();

var app = builder.Build();

//rest of init code
Code language: C# (cs)

Before .NET 6, do this in Startup.ConfigureServices().

Then, instead of applying the action filter directly, use the [ServiceFilter] attribute and the action filter type:

[HttpGet()]
[ServiceFilter(typeof(RequestLogger))]
public IActionResult Get()
{
	return Ok();
}
Code language: C# (cs)

Note: If you’re registering the action filter globally, pass in the type of the action filter instead of using new(), like this: services.AddControllers(options => options.Filters.Add(typeof(RequestLogger)));

Now when GET requests are sent, you can see the id’s are different (because there are multiple instances of the action filter):

Id=233a93b7-99e9-43c1-adfc-4299ff9ac47c Request GET /healthstatus/ routed to HealthStatusController
Id=cbb02112-651c-475e-84e3-de8775387ceb Request GET /healthstatus/ routed to HealthStatusControllerCode language: plaintext (plaintext)

Override OnResultExecuted if you want to check HttpContext.Response

When an action method is executed, it returns a result object (such as BadRequestResult). The framework has to execute this result in order to populate the HttpContext.Response. This is done after OnActionExecuted. That’s why if you try to check HttpContext.Response in OnActionExecuted, it won’t have the correct values.

To check the populated HttpContext.Response, you can override OnResultExecuted (or OnResultExecutionAsync).

Here’s an example that shows the difference between OnActionExecuted and OnResultExecuted:

public override void OnActionExecuted(ActionExecutedContext context)
{
	Console.WriteLine($"Action executed. Response.StatusCode={context.HttpContext.Response.StatusCode}");
	base.OnActionExecuted(context);
}
public override void OnResultExecuted(ResultExecutedContext context)
{
	Console.WriteLine($"Result executed. Response.StatusCode={context.HttpContext.Response.StatusCode}"); 
	base.OnResultExecuted(context);
}
Code language: C# (cs)

This outputs the following:

Action executed. Response.StatusCode=200
Result executed. Response.StatusCode=400Code language: plaintext (plaintext)

Notice that the status code in OnActionExecuted is 200. This is because the BadRequestResult hasn’t executed yet. Then in OnResultExecuted the status code is 400.

Example – Require a custom header in the request

Let’s say you want to require requests to have a custom header specific to the action method.

To enforce this with an action filter, you can override OnActionExecuting(), check for the request header, and set context.Result:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

public class RequireCustomHeader : ActionFilterAttribute
{
	private readonly string RequiredHeader;
	public RequireCustomHeader(string requiredHeader)
	{
		RequiredHeader = requiredHeader;
	}
	public override void OnActionExecuting(ActionExecutingContext context)
	{
		if (!context.HttpContext.Request.Headers.ContainsKey(RequiredHeader))
		{
			context.Result = new ContentResult()
			{
				StatusCode = (int)System.Net.HttpStatusCode.BadRequest,
				Content = $"Missing required header - {RequiredHeader}"
			};
		}
	}
}
Code language: C# (cs)

Note: Setting context.Result short-circuits the request (skips remaining action filters and doesn’t route it to the action method).

Apply this to an action method, passing in the name of the required request header:

[ApiController]
[Route("[controller]")]
public class HealthStatusController : ControllerBase
{
	
	[HttpGet()]
	[RequireCustomHeader("HealthApiKey")]
	public IActionResult Get()
	{
		return Ok();
	}
}
Code language: C# (cs)

When a request is sent without the HealthApiKey header, it returns:

Status: 400 - Bad Request
Body: Missing required header - HealthApiKeyCode language: plaintext (plaintext)

Example – Add a response header

Let’s say you want to add a response header containing debug info to help when troubleshooting your web API.

To do that with an action filter, override OnActionExecuted() and add the custom response header:

public class AddDebugInfoToResponse : ActionFilterAttribute
{
	public override void OnActionExecuted(ActionExecutedContext context)
	{
		context.HttpContext.Response.Headers.Add("DebugInfo", context.ActionDescriptor.DisplayName);

		base.OnActionExecuted(context);
	}
}
Code language: C# (cs)

Apply this action filter:

[ApiController]
[Route("[controller]")]
public class HealthStatusController : ControllerBase
{
	[HttpGet()]
	[AddDebugInfoToResponse()]
	public IActionResult Get()
	{
		return Ok();
	}
}
Code language: C# (cs)

When a request is sent, it returns a response with the following headers:

Content-Length=0
Date=Tue, 26 Oct 2021 20:31:55 GMT
DebugInfo=WebApi.Controllers.HealthStatusController.Get (WebApi)
Server=Kestrel
Code language: plaintext (plaintext)

Example – Track how long the action took

Let’s say you want to return the action method’s elapsed time in a response header for tracking purposes.

The simplest way to do that with an action filter is is to override OnActionExecutionAsync(), use a stopwatch, and await the action method:

public class LogStats : ActionFilterAttribute
{
	public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
	{
		var stopwatch = Stopwatch.StartNew();

		var actionExecutedContext = await next();

		stopwatch.Stop();

		actionExecutedContext.HttpContext.Response.Headers.Add("Stats", stopwatch.Elapsed.ToString());
	}
}
Code language: C# (cs)

Apply the action filter:

[ApiController]
[Route("[controller]")]
public class HealthStatusController : ControllerBase
{
	[HttpGet()]
	[LogStats()]
	public IActionResult Get()
	{
		return Ok();
	}
}
Code language: C# (cs)

When a request is sent, it returns a header with the elapsed time:

Content-Length=0
Date=Tue, 26 Oct 2021 20:45:33 GMT
Server=Kestrel
Stats=00:00:00.0000249
Code language: plaintext (plaintext)

Leave a Comment