Fail-Closed Authorization with MediatR Pipeline Behaviors

Why a request with no access or permission rule is denied, not allowed.
Fail-Closed Authorization with MediatR Pipeline Behaviors

In a CQRS-style .NET service, every query and every command goes through a MediatR pipeline before it reaches its handler. That pipeline is a good place for shared concerns like logging, validation, caching, and authorization.

The harder question is not only "what are the rules?" It is also "what happens when a request does not declare any rule at all?" A common design checks the requests that have a rule and lets the rest through. That means one forgotten rule can ship an endpoint with no protection. Let's look at the opposite default: a pipeline where a request with no access or permission rule is denied, not allowed. Four pieces make that work: required rules, the right behavior order, exception handling that still denies, and a boot-time check.

The pipeline, and why order matters

Let's start with the pipeline itself. MediatR runs registered IPipelineBehavior implementations as nested wrappers around the handler. Each behavior does its work and then calls next() to run the next one inside it. The handler runs at the center. A realistic order looks like this:

Exception → Access → Permission → Cache → Validation → Handler

The order is not cosmetic. Two rules explain most of it:

  • Authorization runs before caching. If the cache behavior ran first, an unauthorized caller could receive a cached result that was computed for someone else.
  • Authorization runs before the handler. The handler is where side effects happen. An unauthorized command must never reach that point.

MediatR gets this order from registration order: the first behavior you register becomes the outermost wrapper.

services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ExceptionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(AccessBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PermissionBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CacheBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RuleValidationBehavior<,>));

This is also the weak spot of the design. The guarantee lives in a registration list, and lists are easy to copy, change, or drift across services. We will come back to that.

Fail closed: the rules

Now let's look at the rule itself. Authorization runs as a pipeline behavior. Each behavior looks at the request type for the rule it is responsible for, and it denies anything that does not carry one.

In this system, authorization is split into two behaviors, [Access] and [Permission]. The next section explains why. But both follow the same fail-closed shape. Here is the basic pattern for one of them:

public async Task<TResponse> Handle(
    TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
    var rule = typeof(TRequest).GetCustomAttribute<AccessAttribute>();

    // No attribute is not "no restrictions" — it is a denial.
    if (rule is null)
    {
        _logger.LogWarning("{Request} has no access attribute", typeof(TRequest).Name);
        return Denied<TResponse>();
    }

    if (rule.IsAnonymous)
        return await next();                  // explicitly public — opt in, not default

    if (!_caller.IsAuthenticated || !_caller.HasAccess(rule))
        return Denied<TResponse>();

    return await next();                       // reached only when the rule is satisfied
}

The first branch is the key. A request with no attribute is not treated as "no restrictions." It is denied. That one choice changes the failure mode. In a fail-open design, forgetting the attribute makes the endpoint public. Here, forgetting it makes the endpoint deny every request until someone declares what it needs.

Only the final next() lets the request continue. Every other path returns a denial without calling next(). So there is no path through this behavior that lets an unauthorized request reach the handler.

It also helps if the response type denies by default. If Success stays false unless something sets it to true, then even a bug that returns new TResponse() still fails closed.

One limit is worth stating clearly. A single behavior written this way fails closed on a missing attribute. But it cannot fail closed if it never runs at all. A behavior that is dropped from the pipeline cannot deny anything. The next section explains how to reduce that risk.

Two questions, not one

"Can this caller run this request?" is really two questions:

  • Which surface did the request come from? An admin operation should not be reachable from the public portal, even by a signed-in user. A machine client should not reach an endpoint meant for a person. This is the access or channel check.
  • Does the caller have the needed permission? This is the permission check.

Splitting these into two attributes and two behaviors keeps each rule small and easy to read at the top of a request class. It also gives you a useful backup. The permission behavior can check that the access attribute exists and deny if it does not.

That sounds like duplicate work, and it is. But it is duplicate work on purpose. It protects against the weak spot from the last section. If the behaviors are registered in the wrong order, or one is removed from a service's pipeline, the second check can still fail the request closed.

There is also a limit on what this pipeline-level check does. It answers whether the caller may run this operation at all. It does not answer which rows they may see. Row-level scope belongs in the handler, where the query is built. That part should fail closed too. If a scope cannot be resolved, the safe result is no rows, not all rows.

Exceptions have to fail closed too

Missing attributes are only half the story. The other half is what happens when authorization throws. A lookup might fail. A dependency might be down. A bug might appear in the middle of the check. If that exception is swallowed, or if execution continues anyway, a failure in the authorization path can become an authorization bypass.

That is why the exception behavior sits first. As the outermost wrapper, its try/catch surrounds authorization and everything inside it. An exception raised during authorization is caught there and turned into a failed response:

try
{
    return await next();
}
catch (Exception ex)
{
    _logger.LogError(ex, "Unhandled exception for {Request}", typeof(TRequest).Name);
    return Failed<TResponse>(ex);   // Success = false — not a passthrough, not a success
}

The catch does not call next() again. It does not try to continue. It returns a non-success response. So an exception anywhere inside that wrapper — including inside authorization — ends up as "the request failed," not "the request is allowed."

Catching the gap before it ships

Runtime denial for a missing attribute is the right behavior, but it is a late place to learn that a request shipped without a rule. By then, traffic has already reached it. So let's catch that gap earlier.

Because the rules live on the request type, they can be checked at startup. List every request type in the assembly and refuse to boot if one is missing either an access rule or a permission rule:

var missing = RequestTypesIn(assembly)
    .Where(t => t.GetCustomAttribute<AccessAttribute>() is null
             || !t.GetCustomAttributes<PermissionAttribute>().Any())
    .Select(t => t.Name)
    .ToList();

if (missing.Count > 0)
    throw new AuthorizationConfigurationException(
        "Requests missing an access or permission rule: " + string.Join(", ", missing));

Checking for both attributes matters. A request with a permission rule but no access rule is still a gap. So is the reverse.

But this startup check only catches a missing declaration. It does not prove that the declaration is correct. A request could still be opened to the wrong surface, or given a permission that is too broad. Those are meaning errors, not presence errors, so they still need review and runtime denial.

The same startup pass can also look for a subtler bug. A request might declare a scoped permission like "read your own records," but its handler might never filter by the caller. That is a data leak. A simple scan can catch the common shape of that mistake. But the scan is only a simple code check, not a proof. It can miss filters hidden behind helper methods or service calls, and it can flag safe code too. Use it as a guardrail, not as a full guarantee.

The cost

None of this is free. The cost is extra friction:

  • Every endpoint must opt in. A new request does not run until it declares both its access rule and its permission rule.
  • The guarantee is only as strong as its coverage. Default-deny only holds if every service registers the behaviors in the right order and runs the boot-time check.
  • Registration order is easy to duplicate. If many services build the same pipeline, the same order can drift in many places. A shared registration helper is usually worth it.

When the framework default is enough

For a small service with only a few endpoints, this can be more machinery than you need. ASP.NET Core's [Authorize] with policies may be enough, and a review can still catch a missing rule when there are only a handful of endpoints.

The pipeline approach earns its place when the number of endpoints grows and "someone will notice in review" stops being a reliable safety net. Then the default matters more than anyone's memory. Fail-open favors convenience. Fail-closed favors a different property: a request cannot run until someone declares what it needs. In a large service, that property is often worth the friction.


This pattern — the authorization behaviors, the deny-by-default result type, and the boot-time attribute check — is built out in full in my multi-tenant .NET reference architecture. [repo link]

Turkish version: Türkçe okuyun

Related Posts