DEV Community

Paolo Costanzo
Paolo Costanzo

Posted on • Originally published at paolocostanzo.github.io

The MCP spec's own state-handle fix is bypassable

Note. This is an application-authorization finding, not a vulnerability in the MCP protocol. The lab runs entirely in-process: no sockets, no DNS, no cloud account, no real target, fixture tokens rather than a real OAuth flow. The category is the one the specification names itself.

TL;DR

Revision 2026-07-28 removes MCP protocol sessions. In the same release, the Security Best Practices document adds a section called State Handle Hijacking with a normative requirement — “MCP servers MUST NOT treat possession of a state handle as authentication” — and a recommendation for how to comply: bind the handle server-side, “for example by keying stored state as <user_id>:<handle>.

The spec ships the requirement without a test. I wrote the test. The recommended key format fails it: string concatenation is ambiguous, and with a structured principal id an attacker moves the delimiter one segment and lands on someone else's record — with the ownership check running and passing. 13 of 13 tests, evidence manifest with source hashes.

What actually changed

No initialize handshake, no Mcp-Session-Id. Version and capabilities travel per-request in _meta, and each request is handled on its own.

That makes the protocol stateless. It does not make authentication, business state, operation deduplication or event correlation stateless. Those did not disappear — they changed owners, and the new owner is your application code.

The lab

Client and MCP handler talk through a controlled fetch. Fixture bearer tokens map to distinct principals via a verified sub claim. Handles are always crypto.randomUUID(). The only variable is how the record is keyed:

const KEY_STRATEGIES = {
  vulnerable: (owner, handle) => handle,
  naive:      (owner, handle) => `${owner}:${handle}`,
  hardened:   (owner, handle) => JSON.stringify([owner, handle])
};
Enter fullscreen mode Exit fullscreen mode

Same tokens, same tools, same UUID generator, same inputs. Only the key changes — so nothing can be blamed on a weak or guessable identifier, which is the favourite alibi whenever an IDOR turns up.

vulnerable fails as expected: a second authenticated principal presents the handle returned to the first, reads their private note and completes their job. Knowledge of the handle became a bearer credential.

Why ${owner}:${handle} also fails

Concatenation does not preserve the boundary between its parts:

("a",   "b:c")  →  "a:b:c"
("a:b", "c")    →  "a:b:c"
Enter fullscreen mode Exit fullscreen mode

The handle arrives as an ordinary tool argument, so the caller controls that half of the string completely. If the principal id can contain the delimiter, the boundary is movable:

victim   `tenant-a:svc` + handle `H`      → key "tenant-a:svc:H"
attacker `tenant-a`     + handle `svc:H`  → key "tenant-a:svc:H"   ← same key
Enter fullscreen mode Exit fullscreen mode

The attacker is authenticated as a genuinely different principal. Ownership is checked on every call. The check simply answers the wrong question.

Structured principal ids are not a textbook curiosity: multi-tenant issuers emit them, and so does the canonical iss + sub form you would naturally build behind an API Gateway JWT authorizer.

The assertion that pins it down:

const smuggled = `svc:${created.handle}`;
const attempt  = await attacker.callTool({
  name: "read_scan",
  arguments: { handle: smuggled }
});

// naive: the keys collapse, the record leaks
assert.equal(toolJson(naiveRun.attempt).owner, "tenant-a:svc");
assert.equal(toolJson(naiveRun.attempt).note,  "svc-private");

// hardened: ["tenant-a:svc", H] and ["tenant-a", "svc:H"] stay distinct
assert.equal(hardenedRun.attempt.isError, true);
assert.equal(toolText(hardenedRun.attempt), "scan not found");
Enter fullscreen mode Exit fullscreen mode

Identical bytes on the wire in both variants. Only the server-side key differs.

The fix

Stop joining the pair, start encoding it: JSON.stringify([owner, handle]), a length-prefixed key, or two separate attributes in a store that keeps them distinct by construction. DynamoDB partition key + sort key gets this right for free, because the boundary is enforced by the datastore rather than by your template literal.

None of these is slower, longer or harder than concatenation.

To be fair to the spec: it writes “for example”, so it illustrates rather than mandates. But an example inside a security best-practices document does not get read as an illustration — it gets copied, and it reaches production with the same delimiter in the same place, looking compliant.

The part that is specific to agentic systems

On exposure, the spec says the attacker “obtains or guesses” the handle. In a classic web application that verb is a real barrier.

In an agentic system the right verb is a third one the spec does not name: collect.

tool result
  └─ opaque handle
       ├─ model context / transcript
       ├─ checkpoint / trace
       ├─ retry queue
       └─ subagent or workflow handoff
                ↓
       replay by another principal
                ↓
       handle-only lookup → cross-principal access
Enter fullscreen mode Exit fullscreen mode

None of those components was designed as a security boundary, and all of them can read a string back. The point is not that every orchestrator leaks these values. It is that if the backend authorizes on the handle alone, every component able to read and replay it enters the security boundary — including the ones you added last week for debugging.

Reducing propagation helps. Verifying ownership at the destination is not negotiable.

Scope

One implementation, one pinned SDK version, fixture tokens rather than a real OAuth flow, in-process storage rather than a datastore, no LLM and no AWS account. The delimiter ambiguity is a property of the key format, not of any particular SDK. Runtime, commit and source hashes live in the evidence manifest rather than in prose, so the two cannot drift apart.


Full write-up in Italian and English, reproducible lab, normalized TAP output and primary sources:
👉 https://paolocostanzo.github.io/mcp-2026-07-28-stateless-security/


Disclaimer: this post was written with AI assistance, based on primary sources (MCP Specification 2026-07-28, MCP Security Best Practices, TypeScript SDK v2 migration guide, AWS API Gateway and DynamoDB documentation). Editorial framing is mine.

Top comments (0)