August 24, 2026
Authorization Is Not a Middleware Problem
Why roles, tokens, and endpoint checks are not enough to protect business flows

By Andrews Ferreira
16 min read
Your API Is Not Your System ended on a question left open on purpose: four writers can reach the same transition, and no two of them enforce the same conditions, so which conditions should they enforce? This piece answers it, and the answer keeps eroding until it stops living in any single handler.
The authorization failures that cost the most don't bypass authentication. They respect it.
The token is authentic and unexpired. The signature verifies. The principal exists. The role is correct. The scope covers the operation. The gateway policy permits the route. The handler's permission check returns true. Every control does exactly what it was built to do, and the system still performs an action the business never authorized.
The controls aren't missing. Each one is answering a narrower question than the one that mattered.
What Middleware Actually Knows
Middleware has exactly one source of evidence: the request.
From a request it can establish whether the credential is authentic, who the principal claims to be, which roles and scopes that principal carries, which route was addressed, and coarse properties like client, origin, and rate. Those facts are worth enforcing early and cheaply, and a system without them isn't worth discussing.
What middleware can't establish is anything requiring a dereference into the system's own state. Whether the object named in the path belongs to the caller. Whether the caller's tenant still holds an active relationship with the counterparty implied by that object. What state the object is in. What's already happened to it. How much of its value has already been consumed. Who initiated the operation the caller is now approving.
This is structural, not a maturity gap. A gateway that queried the ledger to answer those questions would pick up a dependency on the domain schema, a caching problem it has no good way to solve, and a deployment coupling that defeats the reason gateways exist in the first place. At that point it's stopped being a gateway and become a domain service with a worse operational story.
Which leaves a precise gap. Middleware validates a claim about the caller. Authorization is a claim about the effect.
Two consequences follow.
A token carries evidence about authority established earlier. It was accurate when it was minted, but cryptographic validity doesn't prove that every business fact underlying that authority is still true now: a partnership can terminate, a delegated mandate can be revoked, ownership of the object can change hands, the transaction can reach a state where the requested operation no longer applies. Commercial relationships can change independently of token lifetime.
And a role is a lossy projection of authority. RBAC assigns permissions to principals. It has no term for the object, so refund:create means "may attempt refunds," never "may refund this one." Finer-grained roles don't recover that missing term, because the term was never a property of the principal to begin with.
Authorization Has More Than One Dimension
If the decision is about the effect, it depends on everything the effect touches, and those dependencies aren't independent of each other. The interactions are where systems fail.
A role is meaningful only inside a tenant. Tenant isolation is a partition property: no query should cross that boundary. Ownership is a relation inside the partition. Systems routinely enforce the first and infer the second, so an internal operator inside the correct tenant ends up acting on a sub-account they don't manage, and every boundary control reports success.
Ownership doesn't imply relationship. Relationships are intervals, not booleans. A platform, a sub-merchant, a partner integration, a delegated support mandate: all of them start and end. Most schemas model the relationship as a foreign key and the termination as a status column that the authorization path never reads.
Relationship doesn't imply state validity. An object can be the actor's, inside the right tenant, under a live contract, and still sit in a lifecycle state where the requested action has no meaning, or an unintended one.
State validity doesn't imply invariant preservation. The object can be in a permitted state and the action can still push an accumulated total past a bound, or create a second effect where one was allowed.
And none of it implies separation of duties, because that's not a predicate over the actor at all. It's a predicate over the pair (actor, object history): initiator(operation) ≠ approver(operation). RBAC evaluates the left side of that comparison and has no access to the right side. Granting one principal both refund:create and refund:approve isn't a misconfiguration at the role layer. It's a question the role layer was never built to express.
Identity and role get evaluated in framework code that a hundred engineers have read. Ownership, tenancy, relationship, state, history, and invariants get evaluated, if at all, inside a request handler that one engineer wrote under a deadline. That asymmetry, more than any lack of care, is what produces the class of bugs below.
The Role Is Valid. The Refund Is Not.
Card acquiring exposes the problem cleanly because value moves in stages: authorize, capture, settle, refund. Each stage changes what the next stage may legitimately do.
Take an operations user with a genuine refund:create permission, an unexpired token, and a correct tenant claim. Every request below passes token validation, RBAC, scope enforcement, and route policy.
A refund against another merchant's transaction on the same platform. The permission is real. The object isn't the actor's. The system already knows this user can refund transactions; what it never confirmed is that this particular transaction sits inside this user's own economic boundary. Nothing in the token contradicts the request, because the token describes the actor, and the violation lives in the relation between actor and object.
A refund of 100 against a capture of 80. Authorization succeeded. An invariant didn't hold. This isn't input validation: 100 is a perfectly well-formed amount on its own. It only becomes wrong once it's checked against a second fact the request never carried, a captured total of 80. The decision can't be derived from the request alone.
Three refunds of 40 against a capture of 100. Each request checks its own amount against the capture and passes: 40 ≤ 100, three times over. The first two are legitimate. The aggregate moves from 0 to 40, then from 40 to 80, both inside the bound. The third pushes the aggregate to 120, past what was ever captured. A check scoped to a single request has no way to see that. The boundary only exists in the history the check never consulted.
A refund against an authorized but uncaptured transaction. The correct operation here is a void, which reverses a hold. A refund emits a credit instead. Skip the state machine and the system produces an outflow with no corresponding inflow. If both a void and a refund are accepted, it produces two.
A refund on a transaction already reversed through a dispute. The funds went back once already. The state moved to a terminal value and then back out through a flow built for a different purpose. The refund handler reads a state it believes is refundable.
A refund created and approved by the same operator. Two permissions existed, so the control was assumed to exist too. Nothing in the codebase actually compares the approver to the initiator.
A refund issued by a partner whose contract terminated last month, using a token minted while the contract was live and refreshed by a service that only checks whether the credential itself is still valid.
A replay of a successful refund under a new idempotency key. Idempotency was built as a reliability feature, and its scope is the key, not the effect.
None of this is a sign of carelessness on the engineering side. A refund handler that checks permission, loads the transaction, validates state, checks the amount, and writes the refund reads as correct, because it is correct, given what's visible from inside that one function. The gap only shows up once a second writer enters the picture: a partner callback, a backoffice tool, a retry worker, a scheduled reconciliation job, a legacy integration nobody's touched in two years. Each of those paths exists for a real reason: a service boundary that let a team deploy independently, a cache that kept p99 latency reasonable, an integration that let a partner move faster than a synchronous review cycle would allow. The invariant wasn't necessarily forgotten. It may have been distributed across components that individually can't see the whole condition.
In every case above, technically permitted and business-authorized diverge, and no control sitting in the request path is positioned to notice.
State Changes the Decision
Take the third example seriously, because it holds the sharpest version of the argument.
Two refunds of 60 against a capture of 100, submitted concurrently. Each request reads a refunded total of zero. Each computes 60 ≤ 100. Each commits. The invariant is violated by 20, and every authorization check in the system returned the correct answer for the state it observed at the time.
A check against mutable state can become a prediction rather than a guarantee. Another writer is free to invalidate it before the write lands.
This reframes a large class of findings normally filed as concurrency bugs. If the property being protected is "this actor may not extract more than X," a check that can be raced isn't a weaker control. It's a control that doesn't exist under adversarial timing, and adversarial timing is just a request the attacker sends twice.
The general principle underneath this example: when authorization depends on state that can change between the check and the write, enforcement has to share the same consistency boundary that protects the resulting state transition. Not every authorization decision needs that. Most of the dimensions from the earlier section, ownership, tenant, an active relationship, are stable enough across a single request that reading them once and proceeding is fine. Cumulative bounds, and anything else that depends on what other writers might be doing at the same instant, aren't.
The objection here is real and worth stating plainly: sharing that consistency boundary constrains the design. The decision has to happen where the state lives, which limits how far policy can be externalized and how aggressively it can be cached.
That constraint is the finding, not a reason to avoid it. None of the available answers are free, and each is one way of drawing that consistency boundary, trading something different for correctness. A database constraint over a derived total is clean when the invariant maps to something relational, and awkward when it doesn't. A conditional write against a version the caller read protects against stale writes, at the cost of pushing retry logic back onto the caller. An append-only ledger with a derived balance trades storage and read complexity for never having to trust a stored total. A single-writer boundary with optimistic concurrency is the easiest to reason about and the first thing to feel during a throughput or availability incident. What's off the table entirely is a read, a comparison in application memory, and a write, on the assumption that nothing happened in between.
The same actor, action, and object can produce different correct answers at different points in the lifecycle. Authorization in a transactional domain isn't a stateless yes or no.
The Failure Doesn't Stay in the Ledger
None of the examples above need an attacker in the conventional sense. A legitimate account, used in a legitimate sequence, can leave the business in a state nobody authorized. What happens next rarely stays inside the transaction that caused it.
The direct financial exposure can be small: a refund that settles a little above the captured amount, a dispute reversal and a manual refund landing on the same transaction, a balance drifting away from what the ledger and the processor agree on by a specific, boring number. None of it looks like theft while it's happening. The signal rarely looks like a security alert. It looks like a reconciliation report with a number that doesn't tie out, surfaced days or weeks later, once settlement has already run. Even when the direct financial exposure is small, the operational cost often isn't.
That's where the cost really starts. Someone has to reconstruct the sequence of writes across every system that could have touched the transaction: the public API, the backoffice tool, the retry worker, the partner callback, the batch job that reprocessed something overnight. Someone has to check whether the same condition shows up in other transactions that followed the same path and simply weren't flagged. A temporary control usually goes in, a manual review step, a rate limit, a feature flag, while the real fix works its way through review. The defect itself might be a single comparison. Cleaning up after it usually isn't a single person's afternoon.
The customer doesn't see any of this. They see a refund that landed twice, or didn't land at all, or a balance that's wrong for reasons support can't explain on the first call. Whether the token was valid, the role was correct, and the endpoint was properly gated isn't information that helps anyone understand why their account looks the way it does. That gap, between what the system can prove about its own controls and what the customer actually experienced, is one of the clearest ways to see that authorization is ultimately about the legitimacy of an effect, not the correctness of a check.
There's a product tension underneath most of this worth naming directly instead of leaving implicit. Not every business rule belongs inside one centralized security policy, and security shouldn't want it there. Product usually owns the lifecycle a transaction moves through. Engineering owns the mechanics of the state transition. Finance understands which totals actually need to hold. Operations understands the exceptional flows: the manual overrides, the goodwill credits, the cases that don't fit the state machine cleanly. None of that requires security to own the business logic. It just requires asking one specific question of it, over and over: which of these assumptions stops holding once someone deliberately violates the sequence it was written to expect? A violated invariant is a path into the system, whether or not anyone intended it that way.
The investigation is usually where the whole thing compounds. Pull the access log for one of these transactions afterward and it looks unremarkable: authenticated session, valid role, permitted endpoint, a 200 response, no exploit payload, nothing a WAF would ever have flagged. Every individual entry is legitimate. The evidence that something went wrong only shows up once the sequence is reconstructed against the business state at each point in time, which is exactly the context a request-scoped log was never built to hold.
Business Invariants Are Security Controls
A business rule describes what the system normally does. An invariant is a property that has to hold in every reachable state, including states reached through retries, concurrent writers, partial failures, compensating flows, and the paths nobody drew on the diagram.
The distinction matters because it changes where the enforcement lives. A rule can sit inside a service method. An invariant has to live somewhere every writer is forced to pass through.
A payment domain tends to carry invariants like these:
- The sum of refunds against a transaction never exceeds the captured amount, across every source, including partner APIs and internal tooling.
- Capture follows a successful authorization and never exceeds the authorized amount.
- The initiator of an operation is never its approver.
- No object is reachable from outside its tenant at the query layer, not just by convention.
- A terminal state is left only through an explicit compensating flow that records why.
- One idempotency key produces at most one financial effect.
- Delegated authority stays inside its granted scope and its time window.
Idempotency deserves a note here, because it's regularly confused with authorization. Idempotency prevents a duplicate effect from a repeated request. It doesn't decide whether the first effect was permitted. But the scope of the key is itself an authorization boundary: a key namespace shared across tenants lets one caller's key collide with another's, which is either an information leak or a way to bind a second actor's request to the first actor's effect. Idempotency isn't an authorization control. Its scoping is.
These properties are security controls because breaking them produces value. Someone who can push a refund total past a capture total has found a way to withdraw funds from the platform using a legitimate account, a legitimate permission, and a legitimate endpoint. There's no exploit to write. There's only a sequence.
Why the Scanner Sees Nothing
Automated tooling generally has no oracle for this class of defect. It can only find what someone has already encoded as wrong, and the thing that's wrong here lives in a commercial agreement and a domain model, not in the shape of the code.
SAST reasons about structure and data flow. It can flag a route with no authorization decorator. It has no way to know that the merchant_id in the token has to equal the merchant_id on the transaction row, because nothing in the source says so, and that equality isn't a taint property.
DAST and API scanners can do something genuinely useful here: given two identities and an object identifier, they'll detect broken object-level authorization. That covers the first example above and none of the others. Detecting the cumulative refund case means knowing the capture amount, the set of prior refunds, and the fact that a bound exists at all. That's a specification, and the scanner was never given one.
SCA answers a different question and shouldn't really be in this conversation.
Manual review is stronger, and still bounded by what fits in one file at a time. A reviewer looking at the refund handler sees a permission check, a state read, an amount comparison, and concludes it's correct, because in isolation, it is. The defect lives in the relationship between that handler, the partner integration reaching the same aggregate, the retry path, and the dispute flow that moves the state backward. Reviewers see functions. This class of failure lives between them.
The tooling isn't necessarily the failure here. Often the missing artifact is the specification nobody wrote down.
Designing Authorization Into the Flow
Two practices carry most of the weight.
Authorization decision tables. For each actor class, action, and object type, write out the predicates the decision actually requires: ownership, tenant, relationship and its validity window, the permitted state set, cumulative constraints, separation of duties, elevated-risk conditions. For the refund case, that table looks something close to this:
That's the shift the table represents: actor → permission → endpoint becoming something closer to actor + object + relationship + state + history + invariant → effect. Most authorization code still checks the left side of that and calls it done. The table is worth building because it's simultaneously the specification the scanner never had, the artifact a reviewer can check code against, and the source of the negative test cases. It also makes absence visible: a column that's empty for every row is a dimension nobody implemented.
Structural enforcement over conditional enforcement. Make ownership a parameter of the query instead of a comparison after the fetch, so a forgotten check returns nothing instead of someone else's object. Make the tenant a non-optional predicate at the persistence layer. Express bounds as constraints the storage engine enforces rather than conditions the application evaluates. Each of these turns a check that can be skipped into one that can't.
Around those, a few things aren't optional in a value-moving system.
Threat model around actors, objects, actions, states, and invariants instead of components. The question worth asking, the one infrastructure threat modeling almost never asks out loud: what can a perfectly authenticated, correctly permissioned user make the business do that it was never supposed to let them do?
Write abuse cases with the same specificity as user stories. "As an operations user, I refund a transaction belonging to a different merchant" is directly testable. "Improve authorization" isn't.
Test the illegal edges of the state machine, not just the legal ones. Test concurrently for anything with a cumulative bound. Test replay with the same key and with a different key. Test separation of duties with one identity performing both steps.
Log the decision inputs, not just the outcome. A log line saying a request returned 200 can't answer whether the refund was authorized, as the investigation example above shows, that line is legitimate on its own. A log line carrying actor, object, tenant, relationship identifier, observed state, and which predicates were evaluated can. None of this means logging everything everywhere, or exposing sensitive fields. It means preserving enough decision context to explain, later, why an effect was considered legitimate. Without it, an investigation months later can usually reconstruct which API call happened. It can't reconstruct whether the effect it produced was legitimate.
On layering, the useful distinction isn't centralized versus distributed. It's which facts a decision actually needs. Request-scoped facts (authentication assurance, coarse role gates, tenant scoping, rate and risk signals) belong at the edge, where a central policy engine works well. It improves consistency and makes decisions auditable, right up until the decision needs domain state the engine doesn't own. Decisions that dereference domain state belong next to that state, inside the transaction that writes the effect, because a policy service that needs the object's state either gets a stale copy or turns into a slow proxy for the database. Centralize the policy vocabulary and the common predicates. Don't centralize evaluations that depend on state you'd have to ship somewhere else just to check.
"We Already Have This Covered"
"We already use RBAC." RBAC decides which class of actions a principal may attempt, and it does that well. It has no term for the object, so it can't express most of the actual decision. ReBAC systems add that term and are a real improvement, at which point the question becomes whether the relationship data they hold is fresh, and whether they can express bounds over accumulated values. Usually they can't, and shouldn't try to.
"The API gateway handles authorization." The gateway enforces what it can see, which is the token and the route. Where teams have pushed ownership resolution into the gateway, they've moved domain logic into an infrastructure component with its own deployment cycle, and the check quietly drifts from the model it was meant to protect.
"Our service checks permissions." Usually true. The real questions are which predicates the check covers beyond role, against which snapshot of state it was evaluated, and whether it sits inside the transaction that writes the effect. In practice, the first two are usually partial, and the third is rare.
"This is business logic, not security." Correct, and that's the argument, not a rebuttal of it. It belongs in the domain, owned by the engineers who own the model. Calling it security isn't a bid to own the code. It's a statement that a violated invariant here is reachable by an attacker holding a legitimate account, which changes how it gets tested, reviewed, and monitored.
"Penetration testing should find this." Given the domain model, the state machine, and enough time, testers do find instances. They'll find one refund path with a missing ownership check. They won't usually produce the invariant that would have covered the other four paths, because that requires knowing what the business intended, and an engagement is scoped in days, not in the lifetime of the product.
"Centralized authorization solves this." Centralization solves inconsistent vocabulary, scattered policy, and unauditable decisions. Those are worth solving on their own terms. It doesn't solve state freshness, and it doesn't solve transactional coupling, because a decision made in another process is a decision made against a copy.
The Boundary Lives in the Domain
Authentication answers who's asking. Coarse authorization, role, scope, route, answers what class of operation they're allowed to attempt. Domain authorization goes further: whether this actor may produce this specific effect, against this specific object, under the state that exists right now. And wherever a shared invariant is involved, there's a question underneath all of it: whether the system will still be valid after the write commits.
That last question can only be answered where the state and the write live together. Authorization isn't a function attached to an endpoint. It's a property of the business flow, and the flow is where it has to be enforced.
The middleware decides whether a request may enter. The domain decides whether the effect should exist.