Skip to main content
Synthetic reference — not customer work

Authorization case 01 · source review + negative retest

The tool call looked normal. The authorization was not.

A fictional invoice MCP server trusted a tenant identifier supplied in tool input. An authenticated caller for tenant-red could select tenant-blue and read its invoice without malformed input, an unusual tool name, or suspicious telemetry.

Same request. Different trust decision.
authenticated contexttenant-redtool argumentinvoice_id: inv-200
Before · vulnerable tenant_id: tenant-blue 200 · invoice returned Caller input selects the ownership scope.
After · fixed tenant := trusted context Denied · AuthorizationError Server identity selects the ownership scope.
FixtureNorthwindPay / invoice MCP
DefectCross-tenant object read
EvidenceSource + executable test
RetestDenied as intended
01 · Architecture

The boundary was identity, not transport.

The host adapter had already established tenant-red. The MCP tool then accepted a second tenant identifier from the model-controlled argument object. That duplicated identity on opposite sides of the trust boundary.

Failed invariant: an object lookup must be scoped by the authenticated principal, never by a principal or tenant supplied in tool arguments.

02 · Vulnerable handler

The authenticated tenant was present and explicitly discarded.

vulnerable_server.pylines 15–18
def get_invoice(*, authenticated_tenant: str,
                tenant_id: str, invoice_id: str) -> dict:
    del authenticated_tenant
    return dict(INVOICES[(tenant_id, invoice_id)])

The public tool schema also required tenant_id. The handler therefore treated untrusted tool input as the authorization scope and used the authenticated identity for nothing.

03 · Telemetry

Why normal monitoring looked legitimate.

tool.callget_invoice({tenant_id: "tenant-blue", invoice_id: "inv-200"})success

The call used a declared tool, schema-valid strings, an existing invoice ID, and a normal successful response. There was no injection string, traversal sequence, unexpected destination, or tool-definition drift for an indicator to flag.

What telemetry could say: the call happened and succeeded. What it could not establish: whether tenant-red owned the returned object. That proof required reading where identity entered the handler and how the storage predicate was constructed.

04 · Reproduction

One unauthorized read, isolated to a synthetic fixture.

  1. 01

    Authenticate the wrapper as tenant-red.

  2. 02

    Call get_invoice with tenant_id="tenant-blue" and invoice_id="inv-200".

  3. 03

    Observe a record whose stored owner is tenant-blue and total is 9800.

Demonstrated impactAn authenticated tenant can read another tenant’s invoice when it knows or guesses the object identifier.

This demonstrates broken object-level authorization in the fixture. It does not claim production exposure, customer data access, persistence, write access, or a flaw in MCP itself.

05 · Exact fix

Remove tenant choice from the tool contract.

fixed_server.pysource-level remediation
- def get_invoice(*, authenticated_tenant, tenant_id, invoice_id):
-     return dict(INVOICES[(tenant_id, invoice_id)])
+ 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 to this tenant")
+     return dict(record)
  • Delete tenant_id from the public tool schema and reject additional properties.
  • Bind tenant identity from the trusted host or authenticated request context.
  • Include that trusted identity in the storage lookup and verify ownership before returning the record.
  • Fail closed with a typed authorization error when the scoped record does not exist.
06 · Retest

The negative case is now the evidence.

TestExpectedObserved
Red requests Blue’s inv-200DeniedAuthorizationError
Red requests Red’s inv-100Returnedtenant-red record
Tool schema exposes tenant_idAbsentRemoved
Additional properties acceptedNoFalse

Executed against the repository fixture with four passing assertions. The retest proves this defect is closed in the fixed synthetic handler; it is not a general certification of an application or deployment.

Synthetic reference — not customer work

Have a boundary where identity, tool input, and data access meet?

Bring the narrowest useful slice. We’ll discuss what evidence would actually prove the control.

Discuss a boundary with Rasheed