Skip to content

Custom Authorization, Resource and Action Filters

In the previous lesson we learned what filters are and the order they run in. Now we build real custom filters, the kind you will write in professional ASP.NET Core Web API projects. For each filter you will see why it is needed, the code, how to register it, and the result.

FilterTypeProblem it solves
ApiKeyAttributeAuthorizationOnly clients with a valid API key may call the API
CacheResourceFilterResourceReturn a stored response without running the action again
LogExecutionTimeFilterActionLog how long every action takes
ValidateIdAttributeActionReject invalid ids before the action runs
TrimStringsFilterActionClean up incoming text arguments automatically

All filter interfaces live in the Microsoft.AspNetCore.Mvc.Filters namespace.

1. Authorization filter: API key check

Scenario: your Web API is called by a partner company's server. There is no user login; the partner sends a secret API key in a header, X-Api-Key. Every request without the right key must be rejected before anything else runs, which is exactly what authorization filters are for.

The API key in appsettings.json

{
  "ApiSettings": {
    "ApiKey": "mmc-7f3c9a21-partner-key"
  }
}

In production, keep secrets out of appsettings.json: use environment variables, User Secrets during development, or Azure Key Vault / AWS Secrets Manager.

The filter

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class ApiKeyAttribute : Attribute, IAuthorizationFilter
{
    private const string HeaderName = "X-Api-Key";

    public void OnAuthorization(AuthorizationFilterContext context)
    {
        // Respect [AllowAnonymous] on an action, e.g. a public health-check endpoint
        if (context.ActionDescriptor.EndpointMetadata.OfType<IAllowAnonymous>().Any())
            return;

        // 1. Is the header present?
        if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var providedKey))
        {
            context.Result = new UnauthorizedObjectResult(new { message = "API key is missing" });
            return;
        }

        // 2. Read the valid key from configuration (attributes can't use constructor injection)
        var config = context.HttpContext.RequestServices.GetRequiredService<IConfiguration>();
        string validKey = config["ApiSettings:ApiKey"] ?? "";

        // 3. Compare in constant time, so attackers can't guess the key from response timing
        bool isValid = validKey.Length > 0 && CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(providedKey.ToString()),
            Encoding.UTF8.GetBytes(validKey));

        if (!isValid)
        {
            context.Result = new UnauthorizedObjectResult(new { message = "API key is invalid" });
        }
    }
}

How it works

  • OnAuthorization runs first, before model binding and before any other filter.
  • Setting context.Result short-circuits the request: the action never runs, and the 401 response is returned.
  • If the method finishes without setting context.Result, the request continues normally.
  • An attribute is created by .NET, not by the DI container, so we get IConfiguration from RequestServices. (In lesson 3 we see how to use constructor injection with [ServiceFilter] and [TypeFilter].)

Using it

[ApiController]
[Route("api/products")]
[ApiKey]                                   // every action in this controller needs the key
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(int id) => Ok(new { id, name = "Laptop" });

    [HttpGet("health")]
    [AllowAnonymous]                       // except this one
    public IActionResult Health() => Ok("Healthy");
}
Request without a key
GET /api/products/5

HTTP/1.1 401 Unauthorized
{ "message": "API key is missing" }
Request with the right key
GET /api/products/5
X-Api-Key: mmc-7f3c9a21-partner-key

HTTP/1.1 200 OK
{ "id": 5, "name": "Laptop" }

For user logins (JWT, cookies), use ASP.NET Core's built-in [Authorize] with roles and policies instead of writing your own filter. See JWT Token Authentication. Custom authorization filters are a good fit for simple machine-to-machine checks like API keys or IP allow-lists.

2. Resource filter: caching a response

Scenario: the product catalog API is called thousands of times a minute, and the data changes only every few minutes. We want to return a stored response without running model binding, the action or the database query. A resource filter is perfect, because it runs before model binding and again at the very end of the pipeline.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Caching.Memory;

public class CacheResourceFilter : IResourceFilter
{
    private readonly IMemoryCache _cache;

    public CacheResourceFilter(IMemoryCache cache) => _cache = cache;

    // BEFORE: if we have a cached value, return it and skip everything else
    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        if (!HttpMethods.IsGet(context.HttpContext.Request.Method))
            return;

        if (_cache.TryGetValue(GetKey(context.HttpContext), out object? cachedValue))
        {
            context.Result = new OkObjectResult(cachedValue);   // short-circuit
        }
    }

    // AFTER: store a successful result for the next 60 seconds
    public void OnResourceExecuted(ResourceExecutedContext context)
    {
        string key = GetKey(context.HttpContext);

        if (context.Result is OkObjectResult ok && !_cache.TryGetValue(key, out _))
        {
            _cache.Set(key, ok.Value, TimeSpan.FromSeconds(60));
        }
    }

    private static string GetKey(HttpContext http) =>
        "resource-cache:" + http.Request.Path + http.Request.QueryString;
}

Registering and using it

This filter needs IMemoryCache from dependency injection, so we register it as a service and apply it with [ServiceFilter]:

// Program.cs
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<CacheResourceFilter>();

// Controller
[HttpGet]
[ServiceFilter(typeof(CacheResourceFilter))]
public async Task<IActionResult> GetAll()
{
    Console.WriteLine("Reading products from the database...");
    return Ok(await _repository.GetAllAsync());
}
Console output for three calls within 60 seconds
Reading products from the database...

Only the first request reached the action. The next two were answered from the cache by the resource filter.

This example teaches how resource filters work. For production caching of whole responses, ASP.NET Core (.NET 7+) has built-in Output Caching ([OutputCache]) with expiration, tags and invalidation. See Caching in ASP.NET Core.

3. Action filters

Action filters are the most commonly written custom filters. They run right before and right after the action method, after model binding, so they can see and even change the action arguments (context.ActionArguments) and the result.

3a. Logging the execution time of every action (async, global)

using System.Diagnostics;
using Microsoft.AspNetCore.Mvc.Filters;

public class LogExecutionTimeFilter : IAsyncActionFilter
{
    private readonly ILogger<LogExecutionTimeFilter> _logger;

    public LogExecutionTimeFilter(ILogger<LogExecutionTimeFilter> logger) => _logger = logger;

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var watch = Stopwatch.StartNew();
        string action = context.ActionDescriptor.DisplayName ?? "unknown action";

        ActionExecutedContext executed = await next();   // the action runs here

        watch.Stop();

        if (executed.Exception != null && !executed.ExceptionHandled)
            _logger.LogWarning("{Action} FAILED after {Ms} ms", action, watch.ElapsedMilliseconds);
        else
            _logger.LogInformation("{Action} took {Ms} ms", action, watch.ElapsedMilliseconds);
    }
}

Register it globally so it runs for every action:

builder.Services.AddControllers(options =>
{
    options.Filters.Add<LogExecutionTimeFilter>();   // ILogger is injected automatically
});
Log output
info: LogExecutionTimeFilter[0]
      ShopApi.Controllers.ProductsController.Get (ShopApi) took 38 ms
info: LogExecutionTimeFilter[0]
      ShopApi.Controllers.OrdersController.Create (ShopApi) took 212 ms

options.Filters.Add<T>() creates the filter through dependency injection, so its constructor parameters (like ILogger) are filled in automatically. You don't need to register the filter class itself.

3b. Validating an argument with a parameterised attribute

Scenario: many actions take an id that must be a positive number. Instead of repeating if (id <= 0) everywhere, we write an attribute. Deriving from ActionFilterAttribute gives us a ready-made attribute that implements the action filter interfaces; we override only what we need.

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

public class ValidateIdAttribute : ActionFilterAttribute
{
    private readonly string _parameterName;

    public ValidateIdAttribute(string parameterName = "id") => _parameterName = parameterName;

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.ActionArguments.TryGetValue(_parameterName, out object? value)
            && value is int id && id <= 0)
        {
            context.Result = new BadRequestObjectResult(new
            {
                message = $"'{_parameterName}' must be greater than 0. You sent {id}."
            });
        }
    }
}
[HttpGet("{orderId}")]
[ValidateId("orderId")]
public IActionResult GetOrder(int orderId) => Ok(_orders.Find(orderId));
GET /api/orders/-3
HTTP/1.1 400 Bad Request
{ "message": "'orderId' must be greater than 0. You sent -3." }

3c. Changing arguments before the action runs

Scenario: users type " Rahul " with extra spaces in search boxes and forms. This filter trims every string argument, and every string property of model objects, before the action receives them:

public class TrimStringsFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        foreach (string key in context.ActionArguments.Keys.ToList())
        {
            object? value = context.ActionArguments[key];

            if (value is string text)
            {
                context.ActionArguments[key] = text.Trim();   // replace the argument
            }
            else if (value != null && value.GetType().IsClass)
            {
                // trim string properties of a model, e.g. CustomerDto.Name
                foreach (var prop in value.GetType().GetProperties()
                             .Where(p => p.PropertyType == typeof(string) && p.CanRead && p.CanWrite))
                {
                    if (prop.GetValue(value) is string s)
                        prop.SetValue(value, s.Trim());
                }
            }
        }
    }

    public void OnActionExecuted(ActionExecutedContext context) { }   // nothing to do after
}
builder.Services.AddControllers(options => options.Filters.Add<TrimStringsFilter>());

[HttpGet("search")]
public IActionResult Search(string name) => Ok($"Searching for [{name}]");
GET /api/customers/search?name=%20%20Rahul%20%20
Searching for [Rahul]

Which base class or interface should I use?

You needUse
An attribute with simple parameters, no DIDerive from ActionFilterAttribute (or Attribute + the filter interface)
Services from DI (logger, DbContext, cache)Implement the interface in a normal class; apply with [ServiceFilter], [TypeFilter] or options.Filters.Add<T>()
To await somethingThe async interface (IAsyncActionFilter, IAsyncAuthorizationFilter...)
To run for every actionRegister globally in AddControllers(options => ...)

Summary

  • Authorization filters run first; set context.Result (e.g. 401) to block the request.
  • Resource filters run before model binding and at the very end; ideal for short-circuiting with cached responses.
  • Action filters run around the action; they can read and change ActionArguments and the result.
  • Use the async interface when you need await, and call await next() to run the action.
  • Filters that need DI are applied with [ServiceFilter], [TypeFilter] or Filters.Add<T>().

Next: custom Exception and Result filters, DI in filters, and filter order.