At a glance

  • The synthetic caller is authenticated as tenant-red.
  • The public tool arguments select tenant-blue and invoice inv-200.
  • The vulnerable handler discards trusted identity and returns the blue tenant's record.
  • The fix removes tenant selection from the tool schema, scopes the lookup to trusted context, verifies ownership, and passes four focused retests.

The valid-looking call

The request looked normal in every field I had spent the project measuring.

The host had an authenticated identity: tenant-red. The MCP client called a real tool named get_invoice. Its arguments matched the advertised schema. The caller supplied tenant_id: tenant-blue and invoice_id: inv-200. The server returned a valid invoice record with a total of 9800.

No traversal token appeared. No secret-shaped content left through an odd key. The tool description did not change. The method, schema, arguments, and response all looked legitimate.

The request still crossed a tenant boundary.

Synthetic reference, not customer work

I created this fixture specifically to test where MCP Detect's telemetry evidence stopped. Both tenants, both invoices, the server, and every identifier are fictional. The defect is intentional and lives only in the published reference implementation.

Figure 1. The protocol call is valid. The handler binds the lookup to the wrong source of authority.

Who can call it? What can it reach?

I kept returning to those two questions while building the project.

The first asks whether the server knows who is making the request and whether that principal is allowed to invoke the operation. The second follows the request through the handler to the file, API, database row, or tenant-scoped object that the operation can reach.

The current MCP authorization specification defines transport-level authorization for HTTP-based transports. It treats the MCP server as an OAuth resource server, requires audience-bound token validation, and encourages least-privilege scope selection. It also says stdio implementations should obtain credentials from the environment instead of using that HTTP flow. Those requirements (opens in a new tab) answer important parts of who can reach the server.

A valid access token does not prove object ownership inside get_invoice(). A scope that allows invoice reads does not automatically prove that tenant-red may read tenant-blue's invoice. The handler still has to bind the authenticated principal to the requested object.

This pattern maps directly to OWASP API1:2023 Broken Object Level Authorization (opens in a new tab) and CWE-639, Authorization Bypass Through User-Controlled Key (opens in a new tab). The user controls a key that selects another user's object, and the server never proves ownership before returning it.

The vulnerable handler accepts trusted identity and throws it away

The defect fits in four lines:

Intentionally vulnerable synthetic handler
def get_invoice(
    *, authenticated_tenant: str,
    tenant_id: str,
    invoice_id: str,
) -> dict:
    del authenticated_tenant
    return dict(INVOICES[(tenant_id, invoice_id)])

The function signature creates the appearance that authenticated identity matters. The first statement deletes it. The lookup key comes entirely from the caller-controlled tenant_id and invoice_id.

The public tool schema reinforces the defect:

Caller-controlled tenant selection
{
  "properties": {
    "tenant_id": {"type": "string"},
    "invoice_id": {"type": "string"}
  },
  "required": ["tenant_id", "invoice_id"]
}

For the synthetic reproduction, I call the function as tenant-red, request tenant-blue, and supply inv-200. The dictionary contains that exact key, so the function returns the blue tenant's record.

The impact follows whatever the tool can reach

In this fixture, the result is one fictional invoice. The security pattern is larger than invoices and larger than MCP. Any handler that accepts a caller-controlled object key can make the same mistake with a ticket, workspace, document, repository, customer record, or cloud resource.

MCP makes the boundary worth examining because a model can construct and repeat these calls at machine speed. The model does not need to exploit the server in a traditional sense. It only needs to use a field the server intentionally advertised. If the tool schema says tenant_id is valid input, the client has no protocol-level reason to treat that field as dangerous.

The reachable impact therefore depends on the handler's downstream authority. A read-only invoice tool can disclose records. A file tool can expose another workspace. A deployment tool can operate on the wrong account. The JSON-RPC envelope does not tell me how much authority sits behind the function.

This is why I avoided calling the fixture an authentication bypass. Authentication worked. The host supplied tenant-red. The failure occurred afterward, when the handler chose caller input over authenticated context. Calling it an auth bypass would erase the exact control that failed.

The same distinction changes the test plan. Token-validation tests belong at the transport boundary. Object-ownership tests belong at the handler and storage boundary. Passing the first set cannot stand in for the second. I wanted the reference to make both layers visible without pretending a four-test fixture covers a production identity system.

Why normal telemetry looked legitimate

The proxy can record that tenant-red initiated a get_invoice call. It can record the method, arguments, server identity, response, timestamps, and session labels. A SIEM can store the same fields and even alert when one tenant string differs from another.

That difference is not proof of a vulnerability. Real systems can allow delegated billing access, parent and child accounts, support impersonation, shared resources, or administrator scopes. Telemetry alone does not contain the business rule that tells me whether tenant-red should reach tenant-blue.

The inverse problem is worse. If a vulnerable handler ignores the public tenant argument and derives the wrong tenant from another internal source, the wire can look perfectly aligned while the resource lookup is still wrong.

Observability boundary

Wire telemetry proves what reached the handler. Source, configuration, identity propagation, and denied-path tests prove how the handler used it.

This is where my original detection-first framing reached its limit. I could add a cross-tenant mismatch rule, but that rule would produce an indicator that needs review. It could not prove ownership. More patterns would not solve the missing evidence.

I fixed the source of authority, not the suspicious string

The remediation removes tenant_id from the public tool schema. The handler receives tenant identity only through trusted host context, uses it to scope the lookup, verifies the returned record, and emits one non-enumerating denial for both absent and unauthorized objects.

Vulnerable
def get_invoice(
  *,
  authenticated_tenant,
  tenant_id,
  invoice_id,
):
  del authenticated_tenant
  return INVOICES[
    (tenant_id, invoice_id)
  ]
Fixed
def get_invoice(
  *,
  authenticated_tenant,
  invoice_id,
):
  record = INVOICES.get(
    (authenticated_tenant,
     invoice_id)
  )
  if record is None or \
     record["tenant_id"] != \
     authenticated_tenant:
    raise AuthorizationError(
      "invoice is not accessible"
    )
  return record
Figure 2. The fixed lookup derives tenant scope from trusted identity. The caller can select an invoice ID, but not a tenant.

Removing the field from the schema matters as much as checking it in the function. A public tenant_id invites clients and models to treat tenant selection as a legitimate capability. The fixed schema advertises only invoice_id and rejects additional properties.

The ownership check is intentionally redundant with the scoped dictionary key. If the storage implementation changes later, the explicit comparison still documents and tests the security invariant.

The error text does not say whether inv-200 exists for another tenant. The same denial covers missing and unauthorized objects, which avoids turning the tool into an invoice enumerator.

The fixed server also advertises version 1.0.1 instead of 1.0.0. That metadata change is useful evidence that a release occurred, but it cannot explain what changed inside the handler. The source diff and denied test carry that explanation.

I rejected a smaller patch

I could have left tenant_id in the public schema and added if tenant_id != authenticated_tenant. That would block this fixture's cross-tenant request, but it would preserve the wrong interface. Every caller would still send a tenant selector that the server already knows from trusted context.

Duplicated sources of authority drift. A later handler can forget the comparison, a batch endpoint can validate only the first item, or an internal client can assume the field grants delegation. Removing the selector reduces the number of states the authorization code must reason about.

I also avoided returning separate messages for missing and forbidden invoices. Different errors would help a caller enumerate valid invoice IDs across tenants. The generic AuthorizationError keeps both paths identical at this layer.

The fix had to deny the old path without breaking the right one

I wrote four focused tests. One reproduces the defect. Three define what the corrected boundary must do.

Vulnerable controlAccess succeeds

tenant-red reads tenant-blue invoice inv-200. This proves the fixture is exploitable.

Negative retestAccess denied

The fixed handler raises AuthorizationError for tenant-red requesting inv-200.

Positive controlAccess succeeds

tenant-red still reads its own inv-100 after remediation.

Schema retestSelector removed

tenant_id is absent and additional properties are rejected.

Figure 3. A denied cross-tenant test alone is incomplete. The same-tenant control and schema assertion prove the fix preserves intended behavior and removes the dangerous interface.

The first test is important because a negative retest against a nonfunctional fixture proves nothing. I make the vulnerable version return the wrong tenant's record, then run the fixed version against the same object ID.

The retest is bound to exact source

The reference directory includes an evidence manifest with SHA-256 hashes for the vulnerable server, fixed server, authorization tests, and control-evidence record. It also states the expected result: four tests, zero failures, zero errors.

Figure 4. Source hashes keep the result from drifting away from the implementation that produced it.

If any reviewed file changes, verify_manifest.py fails until the evidence is deliberately regenerated. That does not make the fixture a formal proof. It makes the evidence honest about which bytes were reviewed.

I ended by publishing the entire evidence path

The authorization fixture changed how I described MCP Detect. The project began with telemetry and rules. By the end, the stronger story connected normal model behavior, attack fixtures, evasions, frozen verdicts, source review, and a denied authorization retest.

I published the code, full synthetic corpus, Wazuh rules, stateful detector, compiler gates, reproduction tooling, reference review, and limitations under MIT. The repository runs its offline tests in CI and scans for secrets. Anyone can inspect the exact signals, rerun the measurements, or disagree with the boundaries using the same evidence.

That choice also keeps me honest. A public rule with a published evasion cannot hide behind a perfect recall table. A public synthetic defect cannot be mistaken for customer work. A checksum that anyone can verify is stronger than a screenshot of a passing command.

What the reference does not prove

Synthetic reference, not customer work

The server uses fictional invoices and a simplified environment-variable host context. It demonstrates one authorization pattern. It does not claim to solve a real deployment's OAuth, identity, delegation, tenancy, or storage architecture.

  • The environment variable models trusted context for a local stdio fixture. A remote server must bind identity to an authenticated request and validate its token audience.
  • The dictionary lookup is intentionally small. Real storage layers need tenant scoping in every query path, including caches, batch operations, and indirect references.
  • A non-enumerating application error still has to map correctly to the transport's authorization and error-handling behavior.
  • The four tests cover one tool and one object type. They do not establish complete authorization coverage.
  • Telemetry remains useful after the fix, but it supplements the preventive control. It does not replace it.

Run the denied retest

The authorization tests use the Python standard library. From the repository root:

Synthetic cross-tenant reproduction and retest
.venv/bin/python3 -m unittest discover \
  -s examples/reference-mcp-review/tests \
  -p 'test_*.py' -v

Then verify that the evidence manifest still matches the reviewed files:

Evidence manifest verification
.venv/bin/python3 \
  examples/reference-mcp-review/verify_manifest.py

What I would carry into the next MCP system

  1. Observe legitimate behavior first. A rule needs a denominator shaped by real model choices, including mistakes.
  2. Connect a threat to an observable signal. If the signal is not in the telemetry, adding a technique name does not create it.
  3. Publish evasions with detections. The failure boundary tells another engineer where to add a control.
  4. Bind measurements to exact evidence. Corpora, verdicts, rules, source, and retests should identify the versions that produced the result.
  5. Follow identity past the wire. When telemetry stops, inspect the handler, trace the downstream lookup, and prove the denied path.

MCP was already moving quickly when I started this project. That was exactly why I wanted to slow one boundary down, instrument it, attack it, and leave enough evidence for someone else to reproduce every claim.

Sources

  1. Model Context Protocol authorization, revision 2026-07-28 (opens in a new tab)
  2. MCP security policy and responsibilities (opens in a new tab)
  3. OWASP API1:2023 Broken Object Level Authorization (opens in a new tab)
  4. CWE-639: Authorization Bypass Through User-Controlled Key (opens in a new tab)
  5. Synthetic MCP authorization reference (opens in a new tab)
  6. Intentionally vulnerable handler (opens in a new tab)
  7. Fixed handler (opens in a new tab)
  8. Denied cross-tenant retest (opens in a new tab)