Pre-operation access checks failed across 75 trust-boundary incidents analysed in arXiv:2607.01711
Authentication validation keeps failing because many API handlers prove access before the security-sensitive operation, then let state, identity or interpretation change underneath that decision.
Authentication validation keeps failing in API handlers because the check is often placed before the operation it is meant to control. The system proves something near the boundary, such as a token signature, a session state, a route guard or a feature flag. Then it performs a different operation later, against a different object, under different assumptions. That gap is where the bug lives.
This is not the same argument as saying that developers forget authorisation. Some do. Many do not. The more interesting failure is that the application has an access check, the check is technically correct and the bypass still exists because the check is not semantically attached to the operation. The handler asks whether the caller is authenticated. It should be asking whether this caller may perform this action on this object in this state, with these side effects, at the moment the action is committed.
The recent arXiv paper "Trust Boundary Semantic Gaps: A Multi-dimensional Analysis and Mitigation for Security-by-Design" gives a useful vocabulary for this problem. Its central claim is simple: syntactic validation at a trust boundary is necessary but insufficient. A token can be authentic. A message can be well formed. A signed update can be correctly signed. None of those facts proves that the receiving domain's security properties have been satisfied. The paper analyses 75 public incidents from 2014 to 2025 and organises the failures across identity, spatial, temporal and interpretation dimensions.
That model fits a recurring API pattern almost too neatly. Authentication is treated as proof that the request is acceptable, when it is only proof that one part of the request passed one class of boundary check. The rest of the handler quietly assumes that the proof still applies.
The broken shape of the handler
A vulnerable API handler often has a familiar structure:
user = authenticate(request)
if not user:
return 401
resource = load_resource(request.path.id)
perform_operation(resource, request.body)
return 200The structure looks disciplined. Authentication happens first. The handler exits early when it fails. The operation is visually protected by the check above it. Code review tends to reward this layout because the security decision is easy to see.
The problem is that the visible check is not the same as the required decision. The handler has proved that the request has a caller. It has not proved that the caller may modify resource. It has not proved that resource still belongs to the tenant implied by the token. It has not proved that the request body cannot redirect the operation toward a different target. It has not proved that the action remains valid after intermediate state changes. It has not proved that downstream services will interpret the same fields in the same way.
This is why authentication bypasses keep appearing in codebases that already have middleware, route decorators and central identity providers. The edge check is real. It is just too early and too narrow.
A safer shape is less elegant and more repetitive:
user = authenticate(request)
if not user:
return 401
resource = load_resource_for_update(request.path.id)
if not can_perform(user, "update", resource, request.body):
return 403
perform_authorised_update(user, resource, request.body)
return 200Even this is incomplete unless perform_authorised_update preserves the same assumptions. The point is not that every handler needs more lines of code. The point is that authorisation must be bound to the object and the operation, not merely to the route.
Pre-operation checks fail because they are often written as a prologue. The security-sensitive behaviour is elsewhere.
Authentication is a boundary fact, not an operation fact
Authentication answers one question: did the caller present evidence that the system recognises as belonging to an identity. That is a boundary fact. It says something about the interface between the caller and the service.
Most API operations require a different class of fact. They need to know whether a particular state transition is permitted. That includes the actor, object, action, current state, requested state, tenant boundary, policy version and side effects. A signed token does not carry all of that. A session cookie does not either. Even when the token contains roles or scopes, those claims are usually snapshots of authority rather than proof about the object being touched now.
The Trust Boundary Semantic Gaps paper describes this distinction as a mismatch between mechanisms that validate artefacts and the semantic properties required by the receiving domain. In API terms, the request can satisfy the ingress contract while violating the business security contract. The handler accepts a valid request and then does an unsafe thing with it.
The failure is easy to miss because authentication feels foundational. Once a request is authenticated, the system often treats subsequent code as if it is running inside a trusted context. That mental model is wrong. The request is not trusted. Only one claim about the request has been validated.
This distinction matters when handlers compose multiple abstractions. A gateway verifies a JWT. A framework maps the route. A controller loads an object. An ORM follows a relationship. A service method triggers a workflow. A queue worker executes the delayed side effect. Each layer inherits a sense that the earlier layer already did the security work. Each layer may be right about its local contract and wrong about the end-to-end operation.
That is how a system with authentication everywhere still ships an unauthorised operation.
The temporal gap is where many bypasses hide
The most obvious timing flaw is time-of-check to time-of-use. A handler checks access to an object, then later uses the object after it has changed. In web applications this is rarely presented as a classical race condition. It is usually quieter: a user changes organisation, a resource changes owner, a workflow advances state, a token is revoked, a subscription expires or a background job runs after the authority that created it no longer exists.
Pre-operation checks are brittle because they assume that authority is stable between the check and the side effect. In real systems, authority is often a moving target.
Consider a request that schedules an export:
user = authenticate(request)
if can_export(user, request.path.project_id):
enqueue_export(request.path.project_id, request.body.destination)The export may execute seconds or minutes later. By then, the user may no longer have access. The project may have been transferred. The destination may resolve differently. The worker may load broader data than the controller checked. The check at enqueue time was not useless, but it did not authorise the eventual data transfer unless the job carried a durable, constrained authorisation context and the worker revalidated it at execution.
Temporal gaps also appear inside synchronous handlers. The route guard checks a role at the start. The handler then applies a body parameter that selects a different account, group or environment. The operation is still inside the same request, but the security meaning has shifted. The pre-operation proof no longer describes the action being performed.
The TBSG model's temporal dimension is useful here because it moves the discussion away from the narrow idea of racing two requests. The broader issue is that security claims have lifetimes. If code does not define those lifetimes, it invents them by accident.
Identity gaps are not solved by stronger login
Many teams respond to authentication bypasses by strengthening login: more MFA, shorter sessions, better token signing, stricter cookie flags. Those controls help against credential theft and session abuse. They do not fix identity confusion inside the application.
Identity confusion happens when the system validates one identity but uses another identity, identifier or authority source later. The token says the caller is user 123. The path contains organisation 456. The body contains owner 789. The database row has a tenant column. The downstream service uses an API key belonging to the platform rather than the user. The operation's effective authority is the composition of all of those identities, not the one printed in the access log.
This is why object-level authorisation is still missed in APIs that use mature identity providers. The identity provider can tell the service who the caller is. It cannot, by itself, tell the service whether invoice_id=abc belongs to the caller's tenant. That decision belongs near the data and the operation.
A handler that checks only role membership before loading an object by unscoped identifier has already separated identity from object authority:
require_role(user, "manager")
invoice = Invoice.get(request.path.invoice_id)
refund(invoice)If invoice identifiers are globally unique and the query is not tenant-scoped, the role check may authorise the class of action while failing to authorise the instance. The caller is a manager somewhere. The code performs a refund here.
The fix is not simply to add another if statement. The data access pattern needs to make the boundary hard to cross:
invoice = Invoice.get_for_tenant(
tenant_id=user.tenant_id,
invoice_id=request.path.invoice_id,
)
require_permission(user, "refund", invoice)
refund(invoice)That is still a pre-operation check, but it is attached to the object. Better designs go further by making privileged operations accept an authorisation context that cannot be silently substituted with raw identifiers. The goal is to make the wrong call awkward, not merely forbidden by convention.
Why central middleware cannot carry the whole policy
Central middleware is valuable. It removes duplicated token parsing, standardises failures and gives operators one place to instrument authentication. It is also a tempting place to put too much authority.
The middleware sees the request before the application has resolved its full meaning. It may know the route template and caller identity. It may not know which database row will be touched, which derived objects will be affected, which workflow transition will occur or which side effects will be triggered. Asking it to make the whole authorisation decision forces policy to operate on partial information.
This produces two bad outcomes. Either the middleware becomes broad and permissive because it cannot see enough detail, or it becomes tangled with application internals until it is no longer genuinely central. Both outcomes encourage developers to treat a green middleware decision as stronger than it is.
A better split is to let middleware authenticate and perform coarse admission control, then require operation-level authorisation at the point of use. The second check should not be optional decoration. It should be part of the operation's interface.
That means service methods should avoid signatures like this:
def delete_project(project_id):
project = Project.get(project_id)
project.delete()They should prefer designs that carry the actor and force the policy decision next to the state change:
def delete_project(actor, project_id):
project = Project.get_for_tenant(actor.tenant_id, project_id)
authorise(actor, "delete", project)
project.delete()In stronger designs, raw identifiers are not enough to perform privileged work. The caller must pass a capability, policy decision object or scoped repository that was constructed under the current actor's authority. The exact pattern depends on the stack. The architectural rule does not: privileged operations should not be callable without the security context they require.
The design review question that catches the bug
The useful review question is not "does this endpoint require authentication". That question catches only the blunt failures.
The better question is: what fact is being proved, and is that the same fact the operation needs.
For each handler, the answer should name the actor, object, action, state and side effect. If the check proves only that the actor exists, but the operation modifies a tenant object, the proof is incomplete. If the check proves access to the parent object, but the operation uses a child identifier from the body, the proof is incomplete. If the check happens before a queued job, but the side effect happens later, the proof may expire before it is used. If a downstream service interprets a field differently from the validating layer, the proof may not survive translation.
The 75-incident analysis in the Trust Boundary Semantic Gaps paper matters because it shows that these are not isolated programmer mistakes. They are recurring failures in how systems assign meaning to validation. The syntactic artefact passes. The semantic requirement does not.
For API handlers, that gives a practical checklist:
- Authenticate at the boundary, but do not treat authentication as authorisation.
- Scope data access by tenant, owner or capability before loading sensitive objects.
- Bind authorisation to the object and action that will actually be performed.
- Revalidate authority at delayed execution points such as workers and webhooks.
- Preserve caller context across service boundaries, or deliberately replace it with a constrained capability.
- Treat body-supplied identifiers as new authority questions, not as parameters inside an already-authorised request.
- Make privileged service methods impossible to call without an actor or capability.
None of this is novel magic. That is precisely the problem. The fixes are known, but frameworks still make the unsafe shape easy to write and the safe shape feel bespoke.
Pre-operation validation is an architectural smell
A pre-operation check is not automatically wrong. Every handler has to start somewhere. The smell appears when the check is asked to carry authority for facts that are resolved later.
This is the pattern across many authentication bypasses: security is front-loaded, while meaning is deferred. The route guard runs before object resolution. The token check runs before tenant scoping. The role check runs before workflow state is known. The signature check runs before the payload is interpreted. The queue admission check runs before the worker performs the side effect.
When validation and meaning are separated, attackers look for the seam. They change the object after the check. They supply a second identifier. They exploit a background worker. They find a downstream parser with a different interpretation. They do not need to defeat authentication. They need the system to overstate what authentication proved.
The uncomfortable part is that the vulnerable code often looks cleaner than the fixed code. A single early guard is tidy. Operation-bound authorisation is repetitive, context-heavy and harder to abstract. Security architecture keeps rediscovering that some repetition is not a failure of design. It is the price of proving the right fact at the point where the fact matters.
Newsletter
One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.