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 one 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 one MCP boundary