Every authorization framework I've used has the same fundamental problem: it's optional.
You add middleware, decorate a controller, or call a service method. If you forget, the endpoint works fine. It just works without checking permissions. The code compiles, the tests pass (because your tests probably don't assert on authorization either), and the vulnerability ships to production.
This isn't a tooling problem. It's a type system problem. If your language lets you write business logic without first proving that authorization happened, someone eventually will.
The Middleware Pattern and Why It Fails
The standard approach in most web frameworks looks something like this:
[Authorize(Policy = "CanCreateOrder")]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
// Business logic here
}
This works until it doesn't. The failure modes are predictable:
- A developer copies a controller method and forgets the attribute
- Someone adds a new endpoint and doesn't know which policy to apply
- The attribute is present but references a policy that doesn't exist (runtime error, not compile-time)
- Authorization logic lives in one place (the attribute) while the business context it needs lives in another (the request body)
Code review catches some of these. But code review is a human process applied to a problem that should be mechanical. If the compiler can enforce it, the compiler should enforce it.
Two Interfaces, One Transition
The approach we use at Oproto is built on a simple idea: split the request pipeline into two phases with different type signatures, and make the transition between them require an authorization call.
Phase 1 is the pre-authorization phase. It exposes methods for validation and declaring authorization requirements. It does not expose methods for executing business logic.
Phase 2 is the post-authorization phase. It exposes methods for executing business logic. You can only get a Phase 2 reference by calling an authorization method on a Phase 1 reference.
In C#, this looks like two interfaces:
// Phase 1: Before authorization
public interface IPreAuthRunner<TState>
{
IPreAuthRunner<TState> Validate(IValidator validator, object request);
IPreAuthRunner<TState> WithAuthRequirements(Action<AuthBuilder> builder);
IRequestRunner<TState> Authorize(); // Returns Phase 2
IRequestRunner<TState> AuthorizeAnonymous(); // Returns Phase 2
IRequestRunner<TState> AuthorizeIam(); // Returns Phase 2
}
// Phase 2: After authorization
public interface IRequestRunner<TState>
{
IRequestRunner<TState> ThenWithResultAsync(Func<Context<TState>, Task<Result<TState>>> action);
Task<Result<TState>> GetStateResult();
Task<Result> GetResult();
}
The key detail: ThenWithResultAsync only exists on IRequestRunner<TState>. It does not exist on IPreAuthRunner<TState>. There is no way to execute business logic without first calling Authorize(), AuthorizeAnonymous(), or AuthorizeIam().
The Pipeline Starts at the Entry Point
The context that powers this pipeline doesn't originate inside the service method. It's created at the Lambda function entry point, before the service is ever called:
[LambdaFunction]
[RestApi(LambdaHttpMethod.Post, "/orders")]
public async Task<IHttpResult> CreateOrder(
[FromServices] LambdaRequestBuilder requestBuilder,
[FromServices] IOrderService orderService,
[FromBody] CreateOrderRequest request,
APIGatewayProxyRequest proxyRequest,
ILambdaContext lambdaContext)
{
return await requestBuilder
.FromEvent(proxyRequest, lambdaContext)
.UseOAuthAuthentication()
.HandleHttpAsync(request, orderService.CreateOrderAsync);
}
requestBuilder.FromEvent() extracts the identity, headers, and request metadata from the API Gateway event and populates the ILambdaRequestContext. By the time CreateOrderAsync is called, the context is already established. The service method's only path to execution is through HandleHttpAsync, which sets up the context that contextAccessor.Context.Run() depends on.
This means verifying that every endpoint uses the pattern is straightforward: look at the Lambda functions. Every one should follow the same requestBuilder.FromEvent().HandleHttpAsync() shape. If a function calls a service method directly without going through the request builder, it stands out immediately.
What This Looks Like in Practice
A typical service method:
public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest req)
{
return await contextAccessor.Context.Run<Order>()
.Validate(new CreateOrderValidator(), req)
.WithAuthRequirements(auth => auth
.ForAction("CreateOrder")
.OnResource("Order", req.CompanyId)
.InScope(req.CompanyId))
.Authorize()
.ThenWithResultAsync(async ctx =>
{
var order = new Order { /* ... */ };
await repository.SaveAsync(order);
return Result.Ok(order);
})
.GetStateResult();
}
If a developer tries to skip authorization:
// This does not compile
return await contextAccessor.Context.Run<Order>()
.Validate(new CreateOrderValidator(), req)
.ThenWithResultAsync(async ctx => ...) // Compiler error: method not found
.GetStateResult();
The compiler rejects it. Not a warning, not a runtime check. The code cannot be written.
Anonymous Endpoints Are Explicit
Public endpoints that genuinely don't need authorization still have to declare that intent:
return await contextAccessor.Context.Run<HealthStatus>()
.AuthorizeAnonymous()
.ThenWithResultAsync(async ctx => await CheckHealth())
.GetStateResult();
AuthorizeAnonymous() is the explicit opt-out. It transitions to Phase 2 without checking permissions, but it requires a conscious decision. There's no way to accidentally end up in Phase 2.
This matters for code review. When you see AuthorizeAnonymous(), you know the developer made a deliberate choice. When you see Authorize() with requirements, you can review whether the requirements are correct. What you never see is a method that silently skips authorization because someone forgot to add it.
Authorization Context Travels With the Request
One advantage of declaring authorization requirements inline (rather than in an attribute or middleware) is that the requirements can reference the request data directly:
.WithAuthRequirements(auth => auth
.ForAction("UpdateUser")
.OnResource("User", req.UserId)
.InScope(req.CompanyId))
The action, resource, and scope are all derived from the request. This eliminates the disconnect between "what permission do I need" and "what am I operating on" that plagues attribute-based systems.
It also means the authorization requirements are visible in the same method as the business logic. You don't have to look at a controller attribute, then a policy definition, then a handler to understand what's being checked. It's all in one place.
Three Authorization Models, One Pipeline
Our platform has three API surfaces with different authorization needs:
Public API uses capability-based authorization. The user's permissions are scoped to a tenant, company, or location:
.WithAuthRequirements(auth => auth
.ForAction("ViewUser")
.OnResource("User", req.UserId)
.InScope(req.CompanyId))
.Authorize()
Control Plane API uses simple permission checks for platform administrators:
.WithAuthRequirements(auth => auth
.ForAction("SuspendTenant")
.OnResource("Tenant", req.TenantId))
.Authorize()
Internal API uses IAM authentication for service-to-service calls:
.AuthorizeIam()
All three use the same pipeline. All three require an explicit authorization call to reach Phase 2. The pipeline decides which authorization backend to call based on how the requirements are declared: if InScope() is present, it uses the capability-based authorization service; if not, it uses simple Verified Permissions.
What This Doesn't Solve
This pattern enforces that authorization happens. It does not enforce that the authorization requirements are correct. A developer can still write:
.WithAuthRequirements(auth => auth
.ForAction("ReadOrder") // Wrong action for a delete operation
.OnResource("Order", req.Id))
.Authorize()
The compiler can't tell you that ReadOrder is the wrong permission for a delete endpoint. That's still a code review concern. But the surface area for review is much smaller: you're checking whether the requirements are correct, not whether they exist at all.
Why Not Attributes or Middleware
We considered and rejected several alternatives before landing on this design.
Attributes ([Authorize(Policy = "...")]) separate the authorization declaration from the business logic. They work for simple cases but break down when the authorization requirements depend on the request body (which tenant, which resource, what scope). They also can't enforce that every method has one.
Middleware has the same problem. It runs before the request reaches your code, which means it either has to parse the request body itself (duplicating work) or it can only check coarse-grained permissions that don't depend on the request content.
Base class methods (calling this.Authorize() at the top of every method) are enforceable by convention but not by the compiler. You can forget the call and the code still compiles.
The two-phase interface approach is the only one we found that makes the compiler do the enforcement. The tradeoff is that every service method has to use the pipeline. You can't just write a plain async method and call the repository directly. That's a feature, not a bug, but it does add ceremony to simple operations.
The Broader Point
Authorization bugs are some of the most dangerous vulnerabilities in multi-tenant systems. A missing permission check doesn't throw an error or return bad data. It returns the right data to the wrong person. These bugs are silent, hard to detect in testing, and potentially catastrophic in regulated industries.
If your authorization model depends on developers remembering to add it, you have a process problem disguised as a security architecture. The type system is right there. Use it.