Web API Action Filters — Logging, Rate Limiting and Idempotency

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>());
Note: The audit filter’s 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.
Tip: ASP.NET Core 8+ includes built-in rate limiting via 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.
Warning: Action filter audit logs must never log sensitive data — request bodies that contain passwords, payment card numbers, SSNs, or other PII. Implement allowlisting (only log known non-sensitive argument names) rather than denylisting (exclude known sensitive names). A new argument added to a request might contain sensitive data that was not on the denylist. With allowlisting, new arguments are not logged until explicitly added to the safe list.

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.

🧠 Test Yourself

An idempotency filter uses a resource filter (not an action filter). Why is this the correct filter type for idempotency?