- MCP 2026-07-28 removes protocol sessions,
initializeandMcp-Session-Id. Version and capabilities travel in_meta, one request at a time (changelog). - This makes the protocol stateless. It does not make authentication, business state, idempotency, auditing or tool datastores stateless. State was not abolished: it was outsourced.
- The spec already says it, and says it normatively: “MUST NOT treat possession of a state handle as authentication”, with a recommendation to key state as
<user_id>:<handle>(State Handle Hijacking). What it does not provide is a test. This lab is that test. - Three ways to key the same record, with identical tokens, tools and UUIDs:
handlealone, concatenated<owner>:<handle>— the form the spec shows as an illustration — and the(owner, handle)pair as a tuple. The first one falls. So does the second, when the principal contains the delimiter. - 13 out of 13 tests. This is an application authorization flaw — the IDOR/BOLA family, which the spec calls State Handle Hijacking — not a vulnerability in the MCP protocol.
[00] Result first
A handle is the reference a server returns so an operation can be found later: think of the number printed on a receipt. That number identifies which record to retrieve; it should not decide who has the right to open it. Obvious, put that way. Then you open production code and find that the receipt opens the till.
The revision makes MCP stateless at the protocol layer, and with the session goes the habit of leaning on it. In exchange, the spec leaves an explicit normative requirement about application handles. We wanted to know whether that requirement holds up when implemented literally.
Four authenticated principals, a fictitious in-memory job, three key strategies. No network, no AWS account, no real target. The handle is always a UUID v4: only how the record is indexed changes.
With handle-only lookup, Mallory reads Alice's note and modifies her job. With the concatenated <owner>:<handle> key — the form the spec offers as an example — a structured principal still gets through by moving the delimiter one segment. With the pair encoded as a tuple, both attacks end on scan not found.
A UUID makes a handle hard to guess; it does not authorize whoever presents it. Identifier entropy and ownership checks solve two different problems, and the second is not solved by lengthening the first. The suite verifying this distinction — and showing that even the binding can be written badly — passed 13 out of 13 tests.
We are not claiming a vulnerability in MCP: the weakness lives in the implementation, and it is exactly the weakness the spec describes and forbids. The contribution here is twofold — an executable test for a requirement that had none, and a crack in the shape of the key the spec itself suggests. If that is enough for you, stop here: from [01] onwards we go into sources, specification, code and transcripts.
[01] Scope and method
The starting point was a post summarizing the revision as MCP's shift from session-based to stateless. The post was used as a trigger, not as a source — the difference between those two is more or less the whole job. Claims were checked against the specification, changelog, deprecation registry, security considerations, security best practices and the TypeScript SDK v2 guide.
MUST, SHOULD or behavior explicitly stated by the specification.
Output produced by the lab, with version, command and transcript.
Reasoned architectural consequence, not guaranteed by the protocol.
Condition narrowing the validity of the result or preventing generalization.
The research question, scope, threat model and criteria are mine, along with the interpretation of the results and responsibility for every conclusion. The rest: a first AI pass verified the sources, implemented the suite and held the QA bar. A second one, with a clean context — in my workflow its job is precisely to dismantle whatever I have just finished writing — went back to the Security Best Practices and observed that the “hardened” variant, the one I had just declared hardened, could be bypassed by moving a delimiter one segment. That is where T13 came from. The most interesting part of this article was found by the review, not by the author: which is why the review exists.
The technical boundary: what is tested here is the binding of an application handle created by the tool, not the normative requestState field in Multi-Round Tool Requests (MRTR). MCP is a vendor-neutral project under the Linux Foundation's Agentic AI Foundation, and this lab has nothing to say about the protocol itself: it has something to say about how it gets implemented.
[02] What actually changed
Before saying anything about what became more or less secure, it helps to line up what actually changed. Spoiler: almost everything that disappears from the protocol reappears, wearing a different hat, in your application code.
| Area | 2025-11-25 | 2026-07-28 | Security implication |
|---|---|---|---|
| Session | initialize handshake; optional Mcp-Session-Id on Streamable HTTP |
Independent requests | Context must be reconstructed and verified per request. |
| Negotiation | Initial handshake | Per-request _meta; server/discover optional for the client |
Version and capabilities are not identity and do not authorize the caller. |
| Multi-round state | Server-to-client channel | input_required with inputRequests and/or requestState |
State returned by the client must be treated as untrusted input; integrity, binding and expiry are implementation responsibilities. |
| Broken stream | SSE resume/redelivery | New request and new ID | Ambiguous retries may require application-level idempotency to prevent duplicate effects. |
| Roots, Sampling, Logging | Active | Deprecated and present in the revision | Deprecation is not removal; support and compatibility depend on the revision and SDK. |
On the authorization side the revision tightens up: iss validation on authorization responses, binding credentials to the authorization server that issued them, application_type during DCR. All of it while remaining optional (specification). A protocol can raise the bar as high as it likes: if the bar is optional, someone will still walk underneath it.
[03] Stateless does not mean state-free
The revision removes protocol session state. At least four other forms of state remain: identity and authorization, application process data, operation deduplication and event correlation. They were not eliminated: they changed owners, and the new owner is you.
token + request + _meta
new context per request
business state and policy
Why an agentic system never needs to guess a handle
On exposure, the spec says one thing: the attacker obtains or guesses the handle. In a classic web app that verb is a real barrier: without enumeration or interception you go nowhere. In an agentic system the right verb is a third one the spec does not name: collect. A value returned by a tool travels through model context, a transcript, a checkpoint, a trace, a retry queue, input delegated to a subagent. None of those components was designed as a security boundary, and all of them can read a string back.
# architectural exposure path — not exercised by T01–T13 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
These are plausible exposure surfaces, not leaks observed in the tests: the lab does not simulate how the handle changes hands, it assumes it already has. The point is not that every orchestrator exposes these values. It is that, if the backend authorizes through 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.
State has not disappeared, it has only moved. And the new address is somewhere nobody had yet placed an authorization check, because until now the session took care of it.
[04] Threat model
| Element | In the lab |
|---|---|
| Asset | Fictitious job and private note associated with its owner. |
| Principals | Alice and tenant-a:svc, the owners; Mallory and tenant-a, authenticated but unauthorized for the first two's records. |
| Entry point | read_scan(handle) / complete_scan(handle) |
| Trust boundary | Token → verified principal; handle → application record. |
| Weakness | A lookup that ignores the owner, or that includes it in an ambiguous key. |
| Impact | Cross-principal read or modification: an application-level equivalent of IDOR/BOLA on the handle. |
This is not a category we invented. Revision 2026-07-28 calls it State Handle Hijacking and forbids it with a MUST NOT: “MCP servers MUST NOT treat possession of a state handle as authentication”. On the same page it recommends a SHOULD: bind the handle server-side to the authenticated user, “for example by keying stored state as <user_id>:<handle>”, with the id derived from the verified token and not supplied by the client (Security Best Practices). The lab implements that SHOULD literally, and then breaks it.
The threat model starts from a declared precondition: the attacker already holds the handle. For why that precondition is realistic rather than a lab convenience, see [03].
[05] Lab: environment and rules
The lab runs entirely in-process: the MCP client and handler talk through a controlled fetch function, with no sockets, DNS or network. create_scan scans nothing — the name is the only aggressive thing about the tool: it creates an in-memory object with the fixed target demo.local, a status and a demonstration note.
- SDK
@modelcontextprotocol/clientandserver 2.0.0, Zod4.4.3, install from lockfile. Runtime, commit and source hashes are not transcribed here:npm run evidencerecords them in the environment manifest, which is the only authoritative record of the published run. - The SDK's official requirement is Node.js
>=20;>=22.19is only this lab's selected baseline. createMcpHandler(factory, { legacy: 'reject' })versionNegotiation: { mode: { pin: '2026-07-28' } }- Four opaque fixture tokens, not presented as a real OAuth implementation. Two ordinary principals (
alice,mallory) and two structured ones (tenant-a,tenant-a:svc), which T13 needs. - Fixture verifier in front of the handler: missing or unknown token →
401; valid tokens share the same OAuth client ID but produce distinct principals from the verifiedsubclaim, with scopes and expiry. - Global fetch is replaced with a function that fails if invoked: the tested path must use only the in-process transport.
- Target constrained by schema to
demo.local; no port; a fresh store for every test. - Two deliberately separate time domains: the injectable
clockgoverns application record TTL, while credential expiry stays on the wall clock, because the bearer middleware validates it.
OAuth client, client metadata and user are not the same thing
The lab contains three identifiers answering different questions, and confusing them is the most elegant way to compromise yourself. clientInfo describes the software instance declared by the client; clientId identifies the OAuth client; the verified sub claim identifies the principal to which resource policy applies. All principals intentionally share the same clientId: using that value as owner would have merged four distinct users into a single authorization domain, while looking like the right thing to do.
// fixture verifier used by the lab const verifier = { async verifyAccessToken(token) { const actor = Object.entries(TOKENS) .find(([, knownToken]) => knownToken === token)?.[0]; if (!actor) { throw new OAuthError(OAuthErrorCode.InvalidToken, "unknown token"); } return { token, clientId: "state-handle-lab", // same for every principal scopes: ["mcp"], expiresAt: Math.floor(Date.now() / 1000) + 3600, // real time on purpose extra: { sub: actor } // alice, mallory, tenant-a, tenant-a:svc }; } }; const actor = context.authInfo?.extra?.sub;
extra.sub is an explicit fixture choice, not an identity mechanism normatively imposed by MCP. In production, mapping depends on the verifier and authorization server; the architectural requirement is to derive the principal from verified credentials, never from tool arguments or self-declared metadata. The full code is in the test harness.
The authentication gate runs before the MCP handler
const gate = requireBearerAuth({
verifier,
requiredScopes: ["mcp"]
});
const handler = createMcpHandler(factory, { legacy: "reject" });
const auth = await gate(request);
if (auth instanceof Response) {
response = auth; // missing or unknown token: 401
} else {
metrics.handlerRequests += 1;
response = await handler.fetch(request, { authInfo: auth });
}T02 and T03 verify that a missing or unknown token ends with 401 before the handler and without tool dispatch. This demonstrates the gate's position in the lab, not an end-to-end OAuth flow: issuer, audience and real token acquisition remain out of scope. Most importantly, authenticating the caller does not automatically authorize the individual tool or requested record.
# identities used only inside the owned lab alice-demo-token → principal: alice mallory-demo-token → principal: mallory tenant-a-demo-token → principal: tenant-a tenant-a-svc-demo-token → principal: tenant-a:svc # structured id, used by T13 # simulated tools create_scan(target="demo.local", note="alice-private") read_scan(handle) complete_scan(handle)
# commands run from a clean lockfile install
npm ci
npm test
npm run evidenceCode, lockfile, test matrix and sanitized output are in the reproducible lab. Machine-readable evidence is available as TAP and an environment manifest.
[06] Baseline: am I really speaking MCP 2026?
In the documented migration path for the TypeScript SDK v2, the client must explicitly opt in to the modern revision. Before testing any control, then, the baseline has to prove the negotiated era is modern (migration guide). This looks like pedantry. It is in fact the only way not to publish an article about a revision your own client never negotiated — a class of mistake that is silent, elegant and deeply embarrassing.
# observed and sanitized request — test T01 POST /mcp HTTP/1.1 Accept: application/json, text/event-stream Content-Type: application/json MCP-Protocol-Version: 2026-07-28 Mcp-Method: tools/call Mcp-Name: create_scan Authorization: Bearer [REDACTED] { "jsonrpc": "2.0", "id": 0, "method": "tools/call", "params": { "name": "create_scan", "arguments": { "target": "demo.local", "note": "[NOTE_REDACTED]" }, "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { "name": "alice-lab-client", "version": "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {} } } }
- Observed
getProtocolEra() === 'modern'and negotiated revision2026-07-28. - Observed
server/discoverandtools/callwith consistent modern headers and metadata. - No
initializeorMcp-Session-Idobserved in recorded requests and responses. - Authorization and controlled sensitive fields redacted in the publishable transcript.
[07] When the implementation turns the handle into a bearer credential
The first variant stores jobs in a process-level shared Map, outside the per-request context. It uses opaque handles generated with randomUUID(), but lookup considers the handle alone. Alice creates a job; Mallory presents the same value returned to Alice. If the server returns the record, knowledge of the handle has effectively become a bearer credential.
Controlled experiment: the key changes, not the entropy
The three variants use the same tokens, the same tools, the same UUID v4 generator and the same inputs. The only variable is the key used to store and retrieve the record. That isolates the ownership check, with no way to blame a weak or predictable identifier — the favourite alibi whenever an IDOR turns up.
// real delta from lab/src/harness.mjs const nextHandle = handleGenerator ?? (() => randomUUID()); const KEY_STRATEGIES = { vulnerable: (owner, handle) => handle, naive: (owner, handle) => `${owner}:${handle}`, hardened: (owner, handle) => JSON.stringify([owner, handle]) }; const keyFor = KEY_STRATEGIES[mode]; const handle = nextHandle(); const scan = { handle, owner: actor, target, note, status: "pending", expiresAt: clock() + ttlMs }; store.set(keyFor(actor, handle), scan); const loaded = store.get(keyFor(actor, handle));
T04 turns the threat model into a very simple assertion: Alice creates the record; Mallory uses exactly the handle returned to Alice; the test passes only if Mallory obtains the owner's record and private note. T05 repeats the path for a write.
// executable assertion from test/03-vulnerable.test.mjs const created = toolJson(await alice.callTool({ name: "create_scan", arguments: { target: "demo.local", note: "alice-private" } })); const stolen = toolJson(await mallory.callTool({ name: "read_scan", arguments: { handle: created.handle } })); assert.equal(stolen.owner, "alice"); assert.equal(stolen.note, "alice-private"); // observed sequence — tests T04 and T05 Alice → create_scan() → [HANDLE_REDACTED] Mallory → read_scan(handle) → owner=alice · noteMatched=true Mallory → complete_scan(handle) → status=completed Alice → read_scan(handle) → status=completed
In the intentionally vulnerable variant, Mallory — authenticated as a distinct principal — read Alice's note and modified her record using the handle returned to Alice. The handle-only lookup protected neither confidentiality nor integrity.
[08] Hardened variant: ownership before entropy, and a key that cannot be bent
The fix is not making the handle longer. A UUID lowers the odds of guessing it; it does not stop it from ending up in a chat, a log or a subagent's prompt, where odds no longer matter. The decisive control is binding the record to the authenticated principal and verifying that relationship on every call. But how matters: that is where the spec leaves a door open.
// hardened lookup — conceptual form const principal = verifiedPrincipal(request); // the pair as a tuple, not as a concatenated string const scan = scans.get(JSON.stringify([principal.id, handle])); if (!scan || scan.expiresAt <= clock()) { audit({ actor: principal.id, action: "read_scan", handleHash: sha256(handle), outcome: "denied", traceId }); return { content: [{ type: "text", text: "scan not found" }], isError: true }; } audit({ actor: principal.id, action: "read_scan", handleHash: sha256(handle), outcome: "allowed", traceId }); return mcpTextResult(scan);
- Opaque handle using
crypto.randomUUID(). - A key that carries ownership as an encoded pair, not as a concatenated string.
- TTL and on-access cleanup for expired state.
- Generic error on the outside, precise reason in the internal audit.
- Audit with a handle hash; never log tokens or plaintext handles.
Denying the attacker without breaking the legitimate path
A negative authorization test is not enough if the control also blocks the owner. T07 verifies both halves: Mallory cannot complete the record; Alice still sees it as pending and can complete it. The fix protects the resource while preserving expected functional behavior.
// executable assertions from T07 const denied = await mallory.callTool({ name: "complete_scan", arguments: { handle: created.handle } }); const beforeOwnerWrite = toolJson(await alice.callTool({ name: "read_scan", arguments: { handle: created.handle } })); const ownerWrite = toolJson(await alice.callTool({ name: "complete_scan", arguments: { handle: created.handle } })); assert.equal(denied.isError, true); assert.equal(beforeOwnerWrite.status, "pending"); assert.equal(ownerWrite.status, "completed");
Binding to the principal denied both read and write to Mallory with the same generic error, while Alice retained access and write capability. Also verified: expiry and on-access cleanup without sleep, a trace ID, a SHA-256 handle hash, and absence of the four controlled secrets from the serialized audit.
Deterministic TTL: testing time without waiting for it (and without cheating)
Using sleep would have made the suite slow and potentially flaky. The lab injects a clock function instead: T08 advances time by 1,001 ms, presents the handle again and verifies both denial and on-access removal from the Map. The boundary deserves precision: that clock governs the TTL of the application record, not credential expiry, which stays on the wall clock because the bearer middleware validates it. Pinning the token to the injected clock too would have produced a suite that passes today and fails tomorrow — the kind of test you find broken in CI on a Friday afternoon. This is not periodic garbage collection and does not simulate TTL in a distributed datastore: it demonstrates the implemented application rule, and nothing more.
// deterministic expiry test — T08 let now = Date.UTC(2026, 6, 29, 12, 0, 0); const lab = createLabHarness({ mode: "hardened", clock: () => now, ttlMs: 1_000 }); const created = toolJson(await alice.callTool({ name: "create_scan", arguments: { target: "demo.local", note: "alice-private" } })); now += 1_001; const denied = await alice.callTool({ name: "read_scan", arguments: { handle: created.handle } }); assert.equal(denied.isError, true); assert.equal(lab.store.size, 0); assert.equal(lab.audit.at(-1)?.reason, "expired");
Correlatable audit without turning logs into a second leak
The lab logger uses allowlisted events: actor, action, decision, reason, trace ID and a SHA-256 hash of the handle. The asymmetry is deliberate: towards the caller the error is always the same scan not found, so “does not exist” cannot be told apart from “is not yours”; in the internal audit the reason is explicit, not_found_or_not_owner or expired. Opaque outside, precise inside — otherwise the oracle you closed at the front door walks back in through the logs. T09 serializes the full audit and checks it contains neither the UUID, nor the private note, nor the fixture tokens: a property verified against four controlled secrets, not a universal redaction guarantee. A stable hash remains correlatable and is not equivalent to anonymization.
// implementation and negative disclosure assertion — T09
function hashHandle(handle) {
return createHash("sha256").update(handle).digest("hex");
}
audit.push({
actor,
action,
outcome,
...(reason ? { reason } : {}),
handleHash: hashHandle(handle),
traceId
});
const serialized = JSON.stringify(lab.audit);
for (const secret of [
created.handle,
"alice-private",
TOKENS.alice,
TOKENS.mallory
]) {
assert.ok(!serialized.includes(secret));
}In production, for lower-entropy or enumerable identifiers, an HMAC with a separate correlation key reduces offline reconstruction risk. The key must not be embedded in code or logs; the actor identity may itself require pseudonymization. Hashes and HMACs support operational correlation, never authorization.
The key the spec suggests, and why it is not enough
The Security Best Practices document recommends keying state “for example by keying stored state as <user_id>:<handle>”. Implemented literally, in JavaScript that becomes `${owner}:${handle}`, and it is what anyone would write. The problem is that concatenation does not preserve the boundary between the two parts: ("a", "b:c") and ("a:b", "c") produce the same string. If the principal id can contain the delimiter, the boundary is movable by the caller — and the handle arrives as a tool argument, so the caller controls all of it.
Structured principals are not a textbook curiosity: multi-tenant issuers produce them, and so does the canonical iss + sub form used in the AWS sketch in [10]. tenant-a:svc's record with handle H lands under tenant-a:svc:H. The attacker, authenticated as tenant-a, presents handle svc:H: the server concatenates and gets the same key. Ownership was verified. The check simply answered the wrong question.
// executable assertions from test/06-key-binding.test.mjs — T13 const VICTIM = "tenant-a:svc"; const ATTACKER = "tenant-a"; // identical bytes on the wire in both variants; only the server-side key differs const smuggled = `svc:${created.handle}`; const attempt = await attacker.callTool({ name: "read_scan", arguments: { handle: smuggled } }); // the same smuggled lookup, run against each key strategy const naiveRun = await attemptSmuggledLookup(createLabHarness({ mode: "naive" })); const hardenedRun = await attemptSmuggledLookup(createLabHarness({ mode: "hardened" })); // naive: `tenant-a` + `svc:H` concatenates onto `tenant-a:svc` + `H` assert.notEqual(naiveRun.attempt.isError, true); 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");
The fix is to stop joining the pair and start encoding it: JSON.stringify([owner, handle]), or a length-prefixed key, or two separate attributes in a datastore that keeps them distinct by construction — which is exactly what partition key and sort key do in [10], and one of the rare cases where DynamoDB saves you without asking anything in return. None of these is slower, longer or harder than concatenation.
To be fair: the spec writes “for example”, so it illustrates an idea rather than mandating a format. But an example inside a security best-practices document does not get read as an illustration — it gets copied. And it ends up in production with the same delimiter, in the same place, looking compliant.
With identical bytes sent, the concatenated key returned another principal's record and private note; the tuple key answered scan not found. Both variants check ownership: only one checks it unambiguously.
Server-side handle or self-contained state?
The lab verifies only the first model: an opaque handle points to an authoritative record held by the server. A stateless architecture may also return signed or encrypted self-contained state to the client, but this moves the controls rather than removing them.
| Model | What travels through the client | Advantage | Controls and costs |
|---|---|---|---|
| Server-side | Opaque handle | Revocation, TTL, one-time use and updates on an authoritative record. | Storage, lookup, consistency and cleanup; ownership verified on every access. |
| Self-contained | Protected claims and payload | Fewer lookups to reconstruct state; compute is easier to keep stateless. | Binding to principal, tool, audience and expiry; MAC/signature, optional AEAD, key rotation and replay control. |
state = seal(
{ sub: principal.id, action: "complete_scan", exp, jti, payload },
{ aad: "mcp-state:v1" }
);
claims = open(state);
assert(claims.sub === principal.id);
assert(claims.action === requestedTool);
assert(claims.exp > now);
await consumeOnce(claims.jti); // for non-repeatable side effectsSigning or encryption can protect integrity and confidentiality, but they do not automatically authorize the caller and do not prevent replay on their own. This is design guidance: it was not executed and is not evidence about the normative MRTR requestState field.
[09] Controls the protocol actually provides
The new revision defines normative errors that stop these inconsistencies before dispatch: -32020 for a header/body mismatch, -32602 for a modern envelope missing required _meta, and -32022 for an unsupported version (specification). The lab observed them with the TypeScript SDK 2.0.0 createMcpHandler (SDK guide). In all three cases the tool counter stayed unchanged: no dispatch. This is the part where the protocol does its job, and does it well — worth saying, since so far I have used it as a backdrop for someone else's problems.
| Test | Manipulation | Expected outcome | Observed outcome |
|---|---|---|---|
| Header/body | Mcp-Name: read_scan + body complete_scan | 400 / -32020; dispatch count unchanged | PASS · T10 |
| Envelope | Modern header with missing _meta | 400 / -32602 | PASS · T11 |
| Version | Header and _meta with the same future revision | 400 / -32022 | PASS · T12 |
A pre-dispatch control, shown as a test
T10 snapshots the tool counters, intentionally sends read_scan in the header and complete_scan in the body, then checks HTTP status, request ID, JSON-RPC code and absence of dispatch. The test therefore does more than look for an error: it proves the inconsistent tool was not executed.
// executable assertions from test/05-protocol.test.mjs const before = { ...lab.dispatch }; const body = modernEnvelope({ id: "mismatch-1", name: "complete_scan", arguments: { handle: "scan-x" } }); const { response, payload } = await post( lab, body, modernHeaders({ name: "read_scan" }) ); assert.equal(response.status, 400); assert.equal(payload.id, "mismatch-1"); assert.equal(payload.error.code, -32020); assert.deepEqual(lab.dispatch, before);
These controls protect MCP envelope consistency, and they protect it well. They do not replace application authorization: a perfectly valid request, with consistent headers, complete _meta and the right revision, can still ask for another principal's record. The protocol checks that you are speaking MCP correctly, not that you are entitled to ask for what you are asking.
[10] AWS translation
In AWS, the same separation maps as follows:
JWT authorizer
tool/resource authorization
PK principal · SK handle
From Map to DynamoDB: ownership in the key
The following sketch assumes API Gateway HTTP API with a JWT authorizer and payload v2: other API or authorizer types expose claims in different structures. The Lambda derives a canonical principal from iss + sub, never accepts an owner from the body, and uses that principal in the partition key. Note the detail [08] just made interesting: PK and SK are separate attributes, not a concatenated string — the boundary between owner and handle is enforced by the datastore, not by your template literal. Here the correct shape comes for free, by architecture.
const claims = event.requestContext.authorizer?.jwt?.claims;
if (
typeof claims?.iss !== "string" ||
typeof claims?.sub !== "string"
) return unauthorized();
const principal = `${claims.iss}#${claims.sub}`;
const Key = {
PK: `PRINCIPAL#${principal}`,
SK: `SCAN#${handle}`
};
const { Item } = await ddb.send(new GetCommand({
TableName,
Key,
ConsistentRead: true
}));
if (!Item || Item.expiresAt <= now) {
return genericNotFound();
}For a state transition, a condition expression prevents a second request from completing an item that has already changed. This is single-item atomicity, not evidence of end-to-end idempotency or multi-region correctness.
// architectural sketch: conditional state transition
await ddb.send(new UpdateCommand({
TableName,
Key,
UpdateExpression: "SET #s = :done",
ConditionExpression:
"attribute_exists(PK) AND #s = :pending AND expiresAt > :now",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: {
":done": "completed",
":pending": "pending",
":now": now
}
}));Removal of expired items is asynchronous and may happen after expiry. Therefore expiresAt must be checked on reads and, for writes, in the condition expression. A shared Lambda IAM role also does not provide row-level authorization on its own: the application key and policy remain necessary.
- The principal comes from a verified token, not from
clientInfoor tool arguments. - The JWT authorizer authenticates; policy in the handler authorizes the tool and resource.
- DynamoDB uses a key incorporating ownership and handle; conditional writes support deduplication and state transitions.
- OpenTelemetry or CloudWatch correlate principal, tool, decision and outcome without logging tokens.
None of these lines is an architectural opinion: they are the same things the lab verified in-process, translated into services that send you an invoice.
[11] Hardening checklist
Nothing that follows requires a refactor. All of it requires having decided, once and in writing, who owns what.
- For protected servers, authenticate and authorize every request, not the old session.
- Validate the authorization-response issuer and the access token's audience, expiry and scopes.
- Authorize the individual tool and resource.
- Do not trust user, tenant or resource IDs passed in arguments.
- Bind each application handle to its principal and apply TTL and cleanup appropriate to the use case.
- Build that key unambiguously: an encoded pair, a length-prefixed key or separate attributes. Never a concatenation, if the principal id can contain the delimiter.
- Protect the integrity and, when needed, confidentiality of
requestState; use HMAC or AEAD if the state is self-contained and crosses the client. - Use application idempotency keys for side effects.
- Log principal, tool, decision, outcome and trace ID; not tokens and secrets.
- Test mismatches, version, downgrade and compatibility before production.
Conclusion boundary: measured and not measured
Modern revision negotiated; bearer gate ahead of the handler; cross-read and cross-write with handle-only lookup; blocking via principal binding; concatenated-key collision and tuple-key resistance; on-access record TTL; audit against the four controlled secrets; three pre-dispatch errors.
Real exposure through an LLM or subagent; end-to-end OAuth; credential expiry; AWS and DynamoDB; replay, idempotency and concurrency; self-contained state; MRTR requestState; interoperability and other SDKs.
[12] Limitations
- Local opaque tokens: this is not a test of the MCP OAuth flow, and the fixture verifier validates neither issuer nor audience.
- Three implementations written by us and a single TypeScript SDK version. That a concatenated key is ambiguous is a property of the format, not of this SDK; that this SDK behaves as described is verified here only.
- In-process storage and transport: they do not represent distributed consistency, concurrency, interoperability or failure modes. A
Mapis not a datastore, fortunately for everyone. - No LLM, AWS account, real traffic or third-party service. The AWS section is an architectural mapping and should be read as one.
- Replay, idempotency and downgrade remain recommendations, not experimental results from this suite.
- Lab developed and executed through the collaborative human–AI workflow after a clean lockfile install; not yet reproduced by a third party. The code is there precisely for that.
[13] Lab verdict
Statelessness is useful for scaling, not a security certification. It reduces MCP lifecycle state; it does not eliminate identity, ownership, idempotency or auditing — it moves them onto your desk. And once moved, you find that even the right recommendation, written with the wrong delimiter, behaves as if you had never applied it. The session is gone. The business logic, rather stubbornly, remains.
npm ci, 13/13 tests and evidence generation finish with exit code zero on a clean lockfile install. The published manifest was generated from a clean worktree and records runtime, commit and source hashes: if anything in this article disagrees with that file, trust the file.
[14] Primary sources and artifact
How the evidence is built
npm run evidence reruns the tests in deterministic order, normalizes only the local path and variable timings, saves the TAP transcript, and computes SHA-256 for the harness, tests, lockfile and generator itself. The gate becomes true only with exit code zero and exactly 13 tests, 13 passes and 0 failures. The expected count lives in a single constant: adding a test without updating it fails the gate, which is precisely the desired behavior.
// excerpt from scripts/generate-evidence.mjs const hashTargets = [ "README.md", "package.json", "package-lock.json", "scripts/generate-evidence.mjs", "src/harness.mjs", ...tests ]; for (const relative of hashTargets) { const bytes = await readFile(path.join(root, relative)); sourceHashes[relative] = createHash("sha256") .update(bytes) .digest("hex"); } // single source of truth for the gate const EXPECTED_TESTS = 13; const publicationGate = { expectedTests: EXPECTED_TESTS, passed: run.status === 0 && summary.tests === EXPECTED_TESTS && summary.pass === EXPECTED_TESTS && summary.fail === 0 };
The manifest links an output to the sources that produced it, but it is not a signed attestation and does not guarantee bit-for-bit reproducibility across runtimes. Runtime, commit and hashes do not appear in the prose precisely so they cannot diverge from it: if text and manifest disagreed, the manifest would be right. The test count is the only duplicated number, which is why on the other side it is a constant that fails the gate if someone adds a test and gets distracted. The published manifest reports sourceDirty: false: it was regenerated after the sources were committed, which is the only order in which that field means anything.
- Reproducible lab — README and test matrix
- Lab evidence — normalized TAP output
- Lab evidence — environment and source hashes
- MCP Specification 2026-07-28
- MCP 2026-07-28 — Key Changes
- MCP 2026-07-28 — Deprecated Features
- Streamable HTTP
- MCP Authorization
- Authorization Security Considerations
- Security Best Practices — State Handle Hijacking
- TypeScript SDK v2 — Supporting protocol revision 2026-07-28
- AWS API Gateway — JWT authorizers for HTTP APIs
- Amazon DynamoDB — Condition expressions
- Amazon DynamoDB — Time to Live
- AWS Lambda — Node.js logging