Have an AI product going live?
Let's Talk

Zero Auth != Zero Trust: The MUST that Never Made it into MCP's New Tasks Extension

MCP's new Tasks extension ships with no authorization requirement in its normative text. The MUST that would have required one was accepted into an SEP but never made it into the spec, leaving a taskId as a credential nobody is obliged to check.

Key Takeaways

  • MCP's new Tasks extension has a missing requirement resulting in no authorization enforcement in its normative text; nothing obliges a compliant server to check who is requesting a task.
  • The missing requirement was written down twice but never shipped. SEP-2663's Security Implications carries four bullet points, the published spec carries only three of them as byte-identical, but missing the second bullet point.
  • This wasn't an oversight nobody saw. The conformance suite lists the Auth binding MUST by name and excludes it as untestable at the wire layer, making this seem more an accidental oversight rather than deliberate omission
  • Anyone holding an MCP taskId can read the task's full result, subscribe to its push notifications, inject responses into it, as well as cancel it. There's no defined error for exists-but-it-is-not-yours, so even a careful implementer can't avoid shipping a taskId existence oracle.
  • Developers using MCP should avoid letting the taskId be the only access control. Authorize every task request against the authenticated principal, key stored state on a server-derived user identifier, generate IDs with a CSPRNG, and return the same error for not-yours as for doesn't-exist.

Intro

The 2026-07-28 MCP spec landed a few days ago with the marquee change being a stateless protocol core. No initialize handshake, no Mcp-Session-Id, and no held-open bidirectional streams. While this is a good change for anyone who has tried to run MCP servers behind a load balancer, it is also a pretty big change to where security state lives and the main reason why we decided that digging deeper into the new spec and its extensions might yield some gems.

In the new MCP spec, long-running workloads have been moved out of the experimental core and into an official extension, io.modelcontextprotocol/tasks. The new execution model can be summarized as: kick off a job, get a taskId back, and then poll tasks/get or subscribe with subscriptions/listen until it's done. This seems a sensible enough shape for the problem being solved and we should move on, right?

Well, maybe not so fast. As the unholy Frankenstein's monster of an offensive researcher and a dyed-in-the-wool authx nerd, the first thing I do with new specs is grep for the words that ought to be there, in the hope that they are not (you would be horrified at how far strings and grep can still get you in 2026).

On the new MCP Tasks extension, grep'ing for “auth” returns nothing at all. Neither does grepping for “principal”, “tenant”, “credential”, “owner”, or “identity”. “token” however does get a hit "A server MAY use task IDs as bearer tokens for a server's stored state" which is the spec granting permission to use a task ID as a credential rather than saying anything about the checking of a task ID as one.

Turns out a short grep can lead to a long afternoon…..

So What Does the Spec Say?

The normative Security Considerations for Tasks is three bullets, roughly stating:

  1. Task IDs must be unguessable. A server MAY use them as bearer tokens for stored state, and MUST generate them with sufficient entropy that a third party can't enumerate or guess them.
  2. Cross-caller correlation is fine because there's no tasks/list, hence a server can't accidentally leak the existence of one caller's tasks to another.
  3. Elicitation and sampling payloads carried inside a task get the same trust treatment as standalone ones i.e. a task is not a higher-trust channel.

Bullet one is an important one to get right as it's the only thing standing between a task and the rest of the internet. Bullet three seems reasonable and well-scoped. However bullet two however is interesting as it is an argument about what the spec stops you from doing wrong, which is not the same as the requirement to do something right. What's missing altogether is any obligation to check who's asking about the Task. Otherwise known as authorization.

Taking Tasks to Task

Digging into the Task definition a little deeper starts to give a better look at the emerging attack surface we have to play with:

tasks/get is simple and unconditional. If the task status is completed, the server MUST return the task along with its result. Note that nothing is interposed in that sequence, and there's also no error code defined for the task-exists-but-it-aint-yours case. The nearest specified error is -32602 for an invalid or nonexistent taskId, forcing a diligent implementer to choose between building an existence oracle and lying about validity. 

subscriptions/listen is where it gets more interesting though. The Tasks extension bolts a taskIds filter onto the core subscription object, and the core subscriptions spec page has no access-control language in it whatsoever (yep, you guessed it, another grep!). Furthermore, every subscribed task notification issued carries the complete task with it, and this is identical to what tasks/get would respond with.

The tl;dr is if you subscribe to someone else's task ID, their results gets pushed to you along with any input requests the task raises without any polling required.

The spec does require one thing to be checked before a server answers any call, the caller MUST declare support for the Tasks extension in the capabilities list it sends along with the request. Leave that out and an error is returned, but let’s not confuse feature negotiation with authorization. The normative text establishes that the caller speaks Tasks but it never establishes that the caller owns the task being referenced.

The declaration of being able to speak Tasks is a self-asserted string sitting in the caller's own request, unsigned, unbound to any credential. This is exactly the kind of value that the core spec elsewhere correctly warns you not to make security decisions based upon. 

The Missing MUST

The superseded SEP-1686 had a full authorization section that included auth-context binding, cross-context rejection, rate limiting on task operations, and retention limits. It also carried a duty to document the absence of auth binding in situations where a server couldn't manage it (a great requirement that many other specs would benefit from adopting, in this author’s humble opinion).

SEP-2663 promoted the most important of those authorization concerns from a SHOULD to a MUST:

Auth binding. Servers MUST perform authentication and authorization checks on each task-related request to ensure that the client has permission to access a task.

Unfortunately, that sentence is not in the MCP specification that was published on 2026-07-28. Since publication of the new MCP spec, SEP-2663 now carries the standard preservation notice citing that the SEP has reached Final status and is kept as a historical record only. Furthermore, the SEP directs you to the current MCP specification and its changelog for authoritative requirements. This means the only per-request authorization MUST for Tasks lives within a document whose first prerogative is to tell you to read the document that doesn't contain any per-request authorization.

SEP-2663's Security Implications section has four bullets. The new MCP spec has only three of them, byte-identical, in order, but minus the second bullet. Hash the SEP's four bullets with the second one removed, and you get the new spec's section exactly. 

Interestingly, the missing bullet never arrived in the final spec at any point. It wasn’t that somebody quietly edited it out later, the commit that added the MUST to the SEP also wrote the three-bullet version into the docs, the fourth bullet was never in the spec-side file. Run git log --all -S 'Auth binding' in the extension repo and it returns only that one commit 29f83d5 and nothing else. Whatever happened, happened in one sitting, with no one having silently changed anything since.

Somebody Looked!

The obvious story here is "nobody noticed", but that seems wrong based on other evidence we can see.

The modelcontextprotocol/conformance repo has a traceability file for SEP-2663 that enumerates every normative requirement in the SEP and maps it either to a test or to a documented exclusion. The Auth binding MUST is in there as well, but has been marked as excluded:

excluded: 'Host-internal authentication/authorization policy; not observable at the protocol level without coupling to a specific auth scheme'

Which is a perfectly reasonable call, you can’t tell from inspecting messages on the wire whether a server checked authorization or not. Someone read this MUST, thought about it, and classified it as being excluded.

The question then becomes why that conformance test exclusion ended with the requirement leaving the spec entirely. The answer seems to be in the same file, two entries higher up in the entropy MUST clause "MUST generate them with sufficient entropy" which is excluded for materially the same reason:

excluded: ''Sufficient entropy' is a statistical property without a protocol-level threshold; cannot be asserted at the wire layer'

Two MUSTs, both consciously classified as untestable at the protocol level, however only one of them is a bullet in the published Security Considerations and the other isn't in the document at all?

Untestability clearly isn't the bar for what gets included in the spec versus what gets dropped: the project's own tooling and the project's own spec disagree about that. If the call had genuinely been "unobservable requirements don't belong in normative text", the entropy MUST would have gone too and that Security Considerations section would be a paragraph shorter. This just seems like an oversight during consolidation of texts.

taskId is Setup to Fail

If unguessability is the entire control model for taskId, then it needs to be specified like one. But even with generous reading of the spec it clearly isn't.

taskId is typed as string and nothing more. There is no format, no minimum length, no charset, and no CSPRNG requirement. "Sufficient entropy" is never given a number, so a UUIDv1 satisfies the letter of the MUST while simultaneously leaking timestamp structure. SEP-1686 at least asks for rate limiting on task operations and for monitoring of repeated failed lookups; neither survived into the final text.

Most concerningly, nothing anywhere relates task lifetime to access-token lifetime. Nothing states a task should die when the credential that created it is revoked, nor whether a fresh token from the same principal can collect a task created under an older one. ttlMs accepts null for unlimited, has no ceiling, and may be raised by the server mid-flight. A skeptical view of this would be that MCP’s conformant task ID default is a bearer token that has unbounded lifetime, unspecified entropy, and that is bound to no principal.

We already have a name for this, capability-URL security. We've understood it for over a decade and we already know exactly how it ends. The identifiers end up in logs, support tickets, pasted stack traces, and a whole host of other unexpected locations that we’d really rather they not be. Having such an identifier in a model’s context feels like the unexpected propagation problem will only be harder to solve.

"But Doesn't Core Auth Already Cover This?"

A reasonable question, and indeed the core spec does contain a relevant MUST (from the authorization security considerations):

MCP servers MUST validate access tokens before processing the request, ensuring the access token is issued specifically for the MCP server, and take all necessary steps to ensure no data is returned to unauthorized parties.

That clause is about token audience validation, authenticate the caller and reject a token that was minted for some other resource. It never says a principal may only reach objects that a principal owns. A server can validate audience flawlessly, authenticate every caller correctly, and then hand any taskId to any authenticated caller, and it has satisfied every word of that sentence while leaking across users.

We all know authentication is not per-object authorization, and usually that distinction stays academic because most protocols scope objects to something implicitly. Tasks is where this has an impact because possession of the handle is the entire access model and there's no other scope or controls left to fall back on.

The Docs Promise More than the Spec Delivers

The extension's own overview page restates those three security bullets in plain language and, in the process, quietly upgrades one into a guarantee:

one caller's tasks are not visible to another

The normative text doesn't make that claim. Instead it claims the far more restrained existence of tasks isn't inadvertently leaked, on account of there being no list endpoint. Those are very different claims, and in my experience the confident one is the one a developer will read, believe, and build upon. Take it at face value and you won't write the ownership check, because you'll think you were handed isolation through the virtue of using MCP itself.

The Tasks page on modelcontextprotocol.io is a separate document, and it has no security section at all. What it does have is a six-step implementation guide for server authors: advertise support, check client capabilities, return a CreateTaskResult, serve tasks/get, handle tasks/update, handle tasks/cancel. Follow all six and you have a conformant Tasks server that never once checks who is asking to access a task.

Step two is titled "Check client capabilities". Which is the capability negotiation we earlier noted wasn't authorization, sitting at step two of the official build guide in the slot where an authorization step should go.

"It's Only a Draft"

Yes the file is specification/draft/tasks.md. Draft is exactly where you'd hope to catch this, and pointing at draft text is not a vulnerability disclosure. I'm not filing a CVE against a markdown file here.

That said, there are reasons it still matters even when tagged as draft. Tasks shipped as an official extension in a GA release, the changelog says "Move experimental tasks out of the core protocol and into an official extension" The extensions overview lists it under Official Extension Repositories, and the GA announcement has AWS's VP of Agentic AI calling it "one of the first official MCP extensions and contributed by AWS". People are building against it this quarter regardless of what the directory it sits in is called.

Despite all that the extension repo's own README says "⚠️ Experimental Extension … It is not an official extension", the GitHub repo description says "Status: Experimental", and the docs site links to an experimental-ext-tasks repo that doesn't exist. The project doesn't currently agree with itself about whether Tasks are official or not. Which is roughly the same class of problem as a MUST going missing between two repos when you think about it.

The draft status cuts the other way too. Right now this costs one paragraph to fix versus a coordinated errata across four SDKs and every gateway vendor in eighteen months time. Cheap now, expensive later, and the whole point of reading drafts is to say so while it's still cheap (apologies for not reading this sooner!).

What this Actually Means

So what does all of this boil down to? I think three things really, and only one is about Tasks:

A missing security requirement propagates differently to a missing security check. One server with no ownership check is one bug. A specification with no requirement is a defect every conformant implementation will faithfully reproduce, and each of them will pass conformance while doing it.

Extensions create gaps that requirements can fall through. The Tasks spec lives in its own repository, on its own release cycle, separate from the core spec, separate again from the docs site, and separate again from the conformance suite. Every boundary between documents is somewhere a MUST can go missing without a reviewer noticing. 

Leaving it to the implementer is a slippery slope. Deleting sessions removed the only thing the MCP protocol could bind long-lived state to. SEP-2663 is candid about the consequence of that, binding is "left to individual servers according to their existing bespoke permission models", and "in many cases, it is not possible to perform this binding, in which case the task ID becomes the only line of defense against contamination", because with sessions gone "there is no other natural scope a server can define unilaterally".

If You're Building on or Consuming MCP

Until the actual text of the spec changes:

  • Authorize every task-related request against the authenticated principal, not against possession of the ID. Key stored state on a server-derived user identifier, never one the caller supplied.
  • Treat the ID as a name, not a capability. CSPRNG and not a UUID variant that encodes time or MAC.
  • Return the same error for not-yours and doesn't-exist, and rate-limit failed lookups so the oracle isn't free for the caller.
  • Authorize the subscription, not just the capability declaration. Check ownership of every taskId in a subscriptions/listen filter.
  • Bound task lifetime by credential lifetime. Don't accept ttlMs: null. Decide what happens to in-flight tasks on revocation, then write it down.
  • Reject inputResponses for keys that aren't currently outstanding instead of ignoring them.

If you're evaluating a gateway or a hosted MCP platform that has shipped support for MCP Tasks, those six bullets above make a decent set of opening questions to ask your vendor in order to understand how they are approaching them given none of them currently come for free with MCP spec conformance alone.

We've raised the SEP-to-spec drift with the maintainers. It looks like a slip rather than a deliberate narrowing of the spec and it's far cheaper to fix in the text now rather than across an established ecosystem of SDKs in 6 months time. 

Check for Yourself

Everything above can be condensed down to a handful of commands. Against ext-tasks at commit 2c1425d9:

     git clone git@github.com:modelcontextprotocol/ext-tasks.git

           cd ext-tasks

     git checkout 2c1425d9

    grep -ciE 'auth|principal|tenant|credential|owner|identity' specification/draft/tasks.md

     git log --all -S 'Auth binding'


The first returns zero. The second returns one commit, the same one that created the spec file with three of four bullets.

Now say goodbye to your afternoon!