Action filters for Web APIs address cross-cutting concerns that are specifically scoped to controller actions: audit logging with action context, idempotency enforcement, and per-endpoint rate limiting. Unlike middleware, action filters have access to the full controller context — the action descriptor, route values, model binding results, the action result before it is serialised, and any exceptions thrown in the action body. This richer context makes action filters the right tool for API-specific cross-cutting concerns.
API Audit Logging Filter
// ── Captures full action context for audit/observability ───────────────────
public class ApiAuditFilter(
IAuditService auditService,
ILogger<ApiAuditFilter> logger) : IAsyncActionFilter, IOrderedFilter
{
public int Order => int.MinValue + 10; // run very early
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
var correlationId = context.HttpContext.Items["CorrelationId"]?.ToString();
var userId = context.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
var actionName = context.ActionDescriptor.DisplayName;
var httpMethod = context.HttpContext.Request.Method;
var path = context.HttpContext.Request.Path.Value;
// Safely capture non-sensitive argument summary
var argSummary = context.ActionArguments
.Where(a => !IsSensitiveKey(a.Key))
.Select(a => $"{a.Key}={TruncateValue(a.Value)}")
.ToArray();
var sw = Stopwatch.StartNew();
var executed = await next();
sw.Stop();
var statusCode = executed.Result is ObjectResult r
? r.StatusCode ?? 200
: context.HttpContext.Response.StatusCode;
await auditService.LogAsync(new AuditEntry
{
CorrelationId = correlationId,
UserId = userId,
Action = actionName,
Method = httpMethod,
Path = path,
Arguments = string.Join(", ", argSummary),
StatusCode = statusCode,
ElapsedMs = sw.ElapsedMilliseconds,
Exception = executed.Exception?.GetType().Name,
Timestamp = DateTime.UtcNow,
});
if (executed.Exception is not null)
logger.LogError(executed.Exception,
"[{CorrelationId}] Action {Action} failed after {Ms}ms",
correlationId, actionName, sw.ElapsedMilliseconds);
}
private static bool IsSensitiveKey(string key) =>
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
key.Contains("token", StringComparison.OrdinalIgnoreCase) ||
key.Contains("secret", StringComparison.OrdinalIgnoreCase);
private static string TruncateValue(object? value) =>
value?.ToString()?[..Math.Min(50, value.ToString()?.Length ?? 0)] ?? "null";
}
// ── Register globally ─────────────────────────────────────────────────────
builder.Services.AddScoped<ApiAuditFilter>();
builder.Services.AddControllers(opts => opts.Filters.AddService<ApiAuditFilter>());
Order = int.MinValue + 10 ensures it runs as the outermost action filter — before other action filters on the way in, and after them on the way out. This means it captures the final status code and any exceptions thrown by inner filters or the action itself. If the order were default (0), an inner filter could short-circuit and the outer audit filter’s OnActionExecuted would run but with different context.app.UseRateLimiting() with configurable policies (fixed window, sliding window, token bucket, concurrency). For most new projects, the built-in middleware is the right choice. For legacy code or cases requiring per-user fine-grained control that the built-in middleware does not cover, a custom action filter with IMemoryCache remains a valid approach. The built-in middleware is more efficient (uses a dedicated rate limiting framework) and integrates with the ASP.NET Core health check system.Idempotency Filter
// ── Returns cached response for repeated requests with same idempotency key ─
public class IdempotencyFilter(IDistributedCache cache) : IAsyncResourceFilter
{
private const string HeaderKey = "X-Idempotency-Key";
public async Task OnResourceExecutionAsync(
ResourceExecutingContext ctx, ResourceExecutionDelegate next)
{
if (ctx.HttpContext.Request.Method is not ("POST" or "PUT" or "PATCH"))
{ await next(); return; }
if (!ctx.HttpContext.Request.Headers.TryGetValue(HeaderKey, out var key))
{ await next(); return; }
var cacheKey = $"idempotency:{key}";
var cachedJson = await cache.GetStringAsync(cacheKey);
if (cachedJson is not null)
{
ctx.Result = new ContentResult
{
Content = cachedJson,
ContentType = "application/json",
StatusCode = 200,
};
ctx.HttpContext.Response.Headers["X-Idempotent-Replayed"] = "true";
return;
}
var executed = await next();
// Cache the response for 5 minutes
if (executed.Result is ObjectResult { StatusCode: 200 or 201 } result)
{
var json = JsonSerializer.Serialize(result.Value,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
await cache.SetStringAsync(cacheKey, json,
new DistributedCacheEntryOptions
{ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
}
}
}
// ── Apply per-endpoint ─────────────────────────────────────────────────────
[HttpPost("orders")]
[ServiceFilter(typeof(IdempotencyFilter))]
public async Task<ActionResult<OrderDto>> CreateOrder(CreateOrderRequest request, CancellationToken ct)
=> CreatedAtAction(nameof(GetOrder), new { id = (await _svc.CreateAsync(request, ct)).Id }, null);
Common Mistakes
Mistake 1 — Logging action arguments without sanitisation (PII in logs)
❌ Wrong — logging full request body that may contain passwords or payment data.
✅ Correct — log only allowlisted, non-sensitive fields; truncate long values.
Mistake 2 — Using cache-based idempotency without distributed cache in multi-server deployment
❌ Wrong — IMemoryCache idempotency; Server A caches the result; Server B has no cache; duplicate request processes again.
✅ Correct — use IDistributedCache (Redis) for idempotency in multi-instance deployments.