How to Add a custom action filter in ASP.NET Core

By FoxLearn 3/12/2025 3:41:14 AM   19
Action filters in ASP.NET Core provide a way to inspect HTTP requests before they reach the action method and responses before they are sent back to the client.

By customizing these filters, we can handle various tasks, such as logging, authorization, or modifying the response.

The simplest approach to creating a custom action filter is to inherit from ActionFilterAttribute and override the appropriate methods based on whether you want to inspect the request, result, or both.

Log User IP Address in Requests

In this example, we'll create an action filter to log the IP address of the user making the request. We'll override OnActionExecuting() to access the request's IP address and log it before the action method is invoked.

using Microsoft.AspNetCore.Mvc.Filters;

public class LogUserIp : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var ipAddress = context.HttpContext.Connection.RemoteIpAddress?.ToString();
        Console.WriteLine($"Request from IP: {ipAddress} routed to {context.Controller.GetType().Name}");

        base.OnActionExecuting(context);
    }
}

In this case, the LogUserIp action filter is capturing the user's IP address from context.HttpContext.Connection.RemoteIpAddress and logging it to the console.

Applying the Action Filter to Specific Action Methods

You can apply this action filter to a specific action method, or even an entire controller.

[ApiController]
[Route("[controller]")]
public class UserController : ControllerBase
{
    [HttpGet()]
    [LogUserIp()]
    public IActionResult GetUserInfo()
    {
        return Ok();
    }
}

When a request is made to the GetUserInfo() action, the LogUserIp action filter logs the user’s IP address to the console:

Request from IP: 192.168.1.1 routed to UserController

Apply the Action Filter Globally

You can also register the action filter globally, making it apply to all action methods across your entire application. This is done in the service registration step of the application initialization:

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddControllers(options => options.Filters.Add(new LogUserIp()));

var app = builder.Build();

// Application setup

This setup will ensure that the LogUserIp action filter logs the user's IP address for every request handled by the application.

How the Framework Creates Action Filter Instances

By default, ASP.NET Core creates a single instance of the action filter per registration, meaning that the same instance is shared across multiple requests. If your action filter maintains state (like logging an ID or user data), this shared instance can cause issues in a multi-threaded environment.

For example, consider this action filter that logs its instance ID:

public class LogUserIp : ActionFilterAttribute
{
    public readonly string Id = Guid.NewGuid().ToString();

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        var ipAddress = context.HttpContext.Connection.RemoteIpAddress?.ToString();
        Console.WriteLine($"Id={Id} Request from IP: {ipAddress} routed to {context.Controller.GetType().Name}");

        base.OnActionExecuting(context);
    }
}

If you apply this to multiple action methods, you’ll see the same instance ID for multiple requests:

[HttpGet()]
[LogUserIp()]
public IActionResult GetUserInfo()
{
    return Ok();
}

[HttpPost()]
[LogUserIp()]
public IActionResult PostUserInfo([FromBody] User user)
{
    return Ok();
}

Multiple requests will show the same Id:

Id=9f4b3787-0216-4f7b-9a2e-739779d101f0 Request from IP: 192.168.1.1 routed to UserController
Id=9f4b3787-0216-4f7b-9a2e-739779d101f0 Request from IP: 192.168.1.2 routed to UserController

This behavior could lead to issues if your action filter stores state in instance variables. To avoid this, it's recommended to use type-activation registration for better thread safety.

Use Type-Activation Registration for Thread Safety and Dependency Injection

Type-activation ensures that a new instance of the action filter is created per request, and it allows for proper dependency injection (DI). This can be done by registering the action filter as a service and using the [ServiceFilter] attribute to apply it:

var builder = WebApplication.CreateBuilder(args);

// Register the action filter as a scoped service
builder.Services.AddScoped<LogUserIp>();

var app = builder.Build();

Now, instead of applying the action filter directly, use the [ServiceFilter] attribute in your controller:

[HttpGet()]
[ServiceFilter(typeof(LogUserIp))]
public IActionResult GetUserInfo()
{
    return Ok();
}

This ensures that a new instance of LogUserIp is created for each request, solving the thread-safety issue.

Require a Custom Header in the Request

Sometimes you may want to enforce a specific header for your requests, such as requiring an API key. Here’s an action filter that checks for the presence of a custom header:

public class RequireApiKeyHeader : ActionFilterAttribute
{
    private readonly string RequiredHeader;

    public RequireApiKeyHeader(string requiredHeader)
    {
        RequiredHeader = requiredHeader;
    }

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.HttpContext.Request.Headers.ContainsKey(RequiredHeader))
        {
            context.Result = new ContentResult()
            {
                StatusCode = 400,
                Content = $"Missing required header: {RequiredHeader}"
            };
        }
    }
}

You can apply this action filter to your controller or action method:

[ApiController]
[Route("[controller]")]
public class UserController : ControllerBase
{
    [HttpGet()]
    [RequireApiKeyHeader("ApiKey")]
    public IActionResult GetUserInfo()
    {
        return Ok();
    }
}

When a request is sent without the ApiKey header, the response will indicate a 400 Bad Request:

Status: 400 - Bad Request
Body: Missing required header: ApiKey

Add a Custom Response Header

Another common scenario is adding custom headers to your responses for debugging or tracking purposes. You can use an action filter to add a custom response header like this:

public class AddCustomHeader : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext context)
    {
        context.HttpContext.Response.Headers.Add("X-Debug-Info", "Custom Header Value");

        base.OnActionExecuted(context);
    }
}

Apply it to your action:

[ApiController]
[Route("[controller]")]
public class UserController : ControllerBase
{
    [HttpGet()]
    [AddCustomHeader()]
    public IActionResult GetUserInfo()
    {
        return Ok();
    }
}

The response headers will include the X-Debug-Info header:

Content-Length=0
X-Debug-Info=Custom Header Value

Track Action Duration

If you want to track how long an action method takes to execute, you can use a Stopwatch to measure the time in your action filter:

public class TrackActionDuration : 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("X-Action-Duration", stopwatch.Elapsed.ToString());
    }
}

This action filter will add a X-Action-Duration header to the response, showing how long the action method took to execute:

[ApiController]
[Route("[controller]")]
public class UserController : ControllerBase
{
    [HttpGet()]
    [TrackActionDuration()]
    public IActionResult GetUserInfo()
    {
        return Ok();
    }
}

The response will include:

X-Action-Duration=00:00:00.0037