← Back to Blog
For: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

Securing MCP Servers: Context Injection & Data Exfiltration

The 2026-07-28 spec deleted the session, and with it principal binding - the one guarantee your state keys, rate limiters and redaction rules were all quietly leaning on.

#model-context-protocol#mcp-security#context-injection#data-exfiltration#state-handle-hijacking#oauth#multi-tenancy#security-patterns#token-audience-validation#outputschema-redaction

Updated 2026-09-09. First published 2026-01-23, before the MCP 2026-07-28 revision removed protocol sessions. Three of its five layers were wrong afterwards, and one was wrong in a way the specification now has a name for. Auth, state, and the response sanitizer have been rewritten; rate limiting and audit logging needed adjustments; every code sample is new. What survived is the decomposition, not the code.

Your MCP Server Did Not Change. Its Threat Model Did.

Your Model Context Protocol (MCP) server from January is still running. It passes its tests, and nothing in its logs marks 2026-07-28 as unusual.

That is the day it became vulnerable.

Protocol-level sessions went away in the 2026-07-28 revision, along with the initialize handshake and the Mcp-Session-Id header. Servers now mint their own state handles and receive them back as ordinary tool arguments, so a string that used to be issued and tracked by the transport is now a parameter sitting in the same argument list as everything else the model made up.

In its first edition, this article told you to key conversation state in Redis on a conversation_id. There is now a section of the specification called State Handle Hijacking, and it describes that pattern as the vulnerable case. I wrote the bug I am now telling you to fix, so what follows is a correction rather than a lecture.

Principal Binding: The Question the Session Was Answering

Most readings of 2026-07-28 are operational: sessions are gone, sticky routing is gone, any instance can serve any request, and deployment gets easier. All true, and all beside the point for anyone responsible for the security of the server.

Here is the claim this article owns. The session was answering exactly one question - do these requests belong to the same authenticated actor - and on 2026-07-28 it stopped. Every control that was quietly reading that answer now has to compute it, per request, from a verified token.

That question has a name: principal binding. It sounds like plumbing, and it is the load-bearing assumption underneath four separate controls in a typical server. Your state lookup assumed it when it keyed on an identifier the caller supplied. Your rate limiter assumed it when it counted per conversation. Your response filter assumed it when it decided once who was asking. Your authorization assumed it when it checked scopes at the top of a connection and not afterwards.

None of those controls announce the assumption. That is the whole difficulty. A control that quietly depends on a guarantee which has been withdrawn does not fail loudly; it keeps returning plausible answers computed from nothing. Nothing broke on 2026-07-28 for the same reason a wire cut behind a wall does not make a sound.

Two of those four controls are the subject of most of this article, because they are where a wrong answer becomes a disclosure rather than a bug. A third produces something genuinely new that no one is writing about, and it gets a name below: Declared Redaction.

What the MCP Session Used to Enforce for You

Under 2025-11-25 and earlier, an MCP session was a real protocol object: the client sent initialize, the server issued an Mcp-Session-Id, and later requests carried it. Your load balancer pinned the client to an instance, and your session store held whatever context accumulated along the way.

Three security properties came free with that arrangement.

Requests arrived pre-grouped, because anything sharing a session identifier had passed through one handshake, so the server could treat the group as one actor without re-proving it on every call. Identifiers were issued rather than asserted: the server minted them, and a client could not simply name a session it wanted to join. Continuity was the transport's problem, and if the connection dropped the session ended, which bounded the useful lifetime of a stale identifier.

None of those hold now. MCP Went Stateless: Your 2026-07-28 Migration Guide covers the mechanics of the migration and what you owe the protocol after it. This article takes the second-order consequence: what the removal does to a server's security posture, and which defenses were leaning on the session without saying so.

Why the Two Corrections Below Are Not Symmetrical

I have named the general shape of this elsewhere: Protocol-to-Application State Migration is what happens when a capability the wire protocol used to guarantee is removed, its difficulty is unchanged, and it lands in your harness with no default owner. That article works the whole surface: version negotiation, delivery guarantees, state continuity, per-request authorization.

This article takes one item off that list and follows it all the way down, because it is the one that turns into a vulnerability rather than a bug: principal binding.

Be precise about what the session was and was not doing, because it is easy to credit it with more than it earned. It grouped requests with each other. It never bound a token to the correct resource server, and it never checked an audience claim on your behalf.

So the two corrections below are not symmetrical, and I am not going to pretend they are. The state key genuinely broke on 2026-07-28: it was safe only because the connection was doing the grouping, and removing the connection removed the safety. The audience check was always required, has been a MUST since the 2025-06-18 revision introduced resource indicators, and I simply did not do it. One is a spec change that invalidated my design. The other is a spec requirement I missed for fourteen months. Both land on the same question, which is how a server decides who is asking, and that is the honest reason they belong in one article.

State Handle Hijacking: The Wrong Way, And It Was My Way

Here is what the first edition recommended.

python
# WRONG. The key is built from a value the caller supplies.class ConversationContextManager:    def get_context(self, conversation_id, auth_context):        key = f"mcp:context:{conversation_id}"        data = self.redis.get(key)        if not data:            return None        context = ConversationContext(**json.loads(data))        if (context.tenant_id != auth_context.tenant_id or                context.user_id != auth_context.user_id):            return None        return context

Two things are wrong and one thing is right, and the split matters, because the fix is smaller than it looks.

Wrong: the Redis key derives from conversation_id, which arrives as a tool argument, so any caller who can guess or observe another caller's identifier reaches straight into that entry. When MCP had sessions the identifier was server-issued and connection-scoped, which made guessing it a poor use of an attacker's time. It is now a plain string travelling through model context, where a prompt injection planted in an issue body or a support ticket can drop one in or read one back out.

Wrong: the caller also chooses the identifier at creation time, so an attacker can declare the key they intend to occupy.

Right, and worth keeping: the function reloads the stored record and compares its tenant and user against the verified credentials before returning it. That check is what stops a hijack from succeeding, and the specification now mandates it, so the original code got the check right and the key wrong.

A second defect has nothing to do with the spec: the save path serialized accessed_resources to a JSON list, and the load path rebuilt the dataclass by splatting the parsed JSON straight back in. Reproduced on Python 3.13.9:

text
first turn OK, accessed: {'tenant://t1/a'}after redis round-trip, type: listSECOND TURN FAILS: 'list' object has no attribute 'add'

So the resource ceiling that was supposed to defend against enumeration raised an exception on the second turn of any conversation that touched Redis, and the same round trip returned created_at as a string rather than a datetime, breaking every age calculation downstream. Published code deserves an integration test that exercises save, load, and mutate in sequence. This one did not have one.

Keying MCP State Under the Verified Principal

On the fix, the current Security Best Practices page is direct. Servers MUST NOT treat possession of a state handle as authentication, and SHOULD use non-deterministic handles from a secure random source. Beyond that, servers SHOULD bind handles server-side to the authenticated user, keying stored state as <user_id>:<handle> where the user ID is derived from the verified token rather than supplied by the client.

That last clause is the whole correction: the principal is the half of the key the caller cannot influence. This hardens the shared state store the general architecture calls for rather than replacing it.

Principal below is the verified-claims object built in the next section, carrying tenant_id, user_id, and scopes taken from a validated token. Read the two sections as one unit, because neither half works alone.

python
import jsonimport secretsfrom datetime import datetime, timedelta, timezone# Check-and-record in one atomic step. Two concurrent calls carrying the# same handle must not both pass a ceiling check and then both write._TRY_ACCESS = """local key      = KEYS[1]local member   = ARGV[1]local ceiling  = tonumber(ARGV[2])if redis.call('SISMEMBER', key, member) == 1 then  return 1endif redis.call('SCARD', key) >= ceiling then  return 0endredis.call('SADD', key, member)return 1"""class ToolStateStore:    """Handles are server-minted, opaque, and keyed under the principal."""    MAX_AGE = timedelta(hours=8)          # absolute lifetime    RESOURCE_CEILING = 100    def __init__(self, redis_client, idle_ttl_seconds: int = 1800):        self._redis = redis_client        self._idle_ttl = idle_ttl_seconds        self._try_access = redis_client.register_script(_TRY_ACCESS)    def _meta_key(self, principal: "Principal", handle: str) -> str:        # Tenant and issuer both belong in the key. `sub` is unique per        # issuer, not globally, and this server accepts several issuers.        return (f"mcp:state:{principal.tenant_id}"                f":{principal.user_key}:{handle}")    def _set_key(self, principal: "Principal", handle: str) -> str:        return self._meta_key(principal, handle) + ":resources"    def mint(self, principal: "Principal") -> str:        handle = secrets.token_urlsafe(32)        meta = {            "tenant_id": principal.tenant_id,            "user_key": principal.user_key,            "created_at": datetime.now(timezone.utc).isoformat(),        }        self._redis.set(            self._meta_key(principal, handle),            json.dumps(meta),            ex=self._idle_ttl,        )        return handle    def resolve(self, principal: "Principal", handle: str) -> "dict | None":        raw = self._redis.get(self._meta_key(principal, handle))        if raw is None:            return None        try:            meta = json.loads(raw)            created_at = datetime.fromisoformat(meta["created_at"])        except (TypeError, ValueError, KeyError):            # Yesterday's record shape during a rolling deploy is a miss,            # not a 500. State versions independently of your code.            return None        # Not redundant. If the key ever loses a component, this is the        # only thing standing between two tenants. Keep it.        if meta.get("tenant_id") != principal.tenant_id:            return None        if meta.get("user_key") != principal.user_key:            return None        if datetime.now(timezone.utc) - created_at > self.MAX_AGE:            return None        self._redis.expire(self._meta_key(principal, handle), self._idle_ttl)        return meta    def try_access(        self,        principal: "Principal",        handle: str,        resource_uri: str,    ) -> bool:        """Check and record together. There is no version that does one."""        allowed = self._try_access(            keys=[self._set_key(principal, handle)],            args=[resource_uri, self.RESOURCE_CEILING],        )        self._redis.expire(self._set_key(principal, handle), self._idle_ttl)        return bool(allowed)

Five properties are doing the work, and two of them are corrections to the correction.

Handles are minted with secrets.token_urlsafe(32), which carries 256 bits of entropy and encodes nothing an attacker can parse. The migration guide recommends a signed handle; this one is unsigned and principal-keyed instead. Both work, and they are the same idea: a signature buys you the caller binding that a principal-scoped key already provides, so doing both is belt on braces. Structure in a handle is what the specification warns against for exactly this reason: one that embeds a tenant or a row identifier invites both guessing and parsing.

Storage keys carry the tenant and the issuer, not just the subject. This is the correction I nearly shipped without. A sub claim is unique within an issuer, and TokenVerifier above accepts a set of issuers, which is the ordinary business-to-business arrangement where each customer brings their own identity provider. Two customers on two providers can present the same sub, so a key built from the subject alone collides across tenants. The comparison inside resolve catches the collision and turns it into a miss rather than a disclosure, which is exactly why the comment there tells you not to delete it. A check that only fires when another control is broken still has a job.

Misses return None rather than an authorization error, deliberately, because a distinct "exists but forbidden" response turns the store into an oracle for which handles are live.

Access checks are atomic, because a read-modify-write cycle across a network stopped being safe on the day instance affinity went away. Two concurrent calls carrying the same handle will each load a state holding 99 resources, each pass a ceiling of 100, and each write. Fire enough of them in parallel and the ceiling is advisory. The Lua script closes that, and try_access exists instead of a separate check method and record method so a caller cannot perform one without the other.

Two different clocks bound a handle, and conflating them is a mistake I made in the draft of this rewrite. The idle timeout refreshes on every use, so an attacker enumerating slowly keeps a handle alive forever. MAX_AGE is the one that actually bounds an attack, and it never refreshes. The spec sets neither number; its own worked example uses 24 hours of inactivity for a shopping cart. State whichever you pick in the description of the tool that mints the handle, because the model is what has to cope with expiry.

Token Audience Validation: The MUST Most MCP Servers Miss

My original auth layer decoded a JSON Web Token (JWT) with a shared HS256 secret, checked the issuer against an allowlist, and moved on. Who the token was issued for went unchecked.

python
# WRONG. No audience check, so any token this key signed is accepted.payload = jwt.decode(    token,    self.jwt_secret,    algorithms=["HS256"],    options={"verify_exp": True},)if payload.get("iss") not in self.allowed_issuers:    return None

Under the Token Passthrough section this is an audience validation failure: a server that does not verify tokens were intended for it "may accept tokens originally issued for other services", which "breaks a fundamental OAuth security boundary". Mitigation runs to one sentence. MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.

Be precise about what is mandatory here, because overclaiming is easy. Audience validation is a MUST. Signing algorithms are not specified at all, so the shared symmetric secret is a consensus practice failure rather than a spec violation. Fix it anyway: under HS256 the verification key is the signing key, so every resource server holding it can mint tokens indistinguishable from the authorization server's, and a single leak forges tokens for every tenant at once.

python
from dataclasses import dataclassimport jwtfrom jwt import PyJWKClientdef _parse_scopes(claims: dict) -> frozenset[str]:    """OAuth says a space-delimited string. Real issuers also send arrays."""    for name in ("scope", "scp"):        raw = claims.get(name)        if isinstance(raw, str):            return frozenset(raw.split())        if isinstance(raw, (list, tuple)):            return frozenset(str(item) for item in raw)    return frozenset()          # absent scope grants nothing@dataclass(frozen=True)class Principal:    """Derived only from verified claims. Never from request parameters."""    issuer: str    tenant_id: str    subject: str    scopes: frozenset[str]    @property    def user_key(self) -> str:        # `sub` is unique within an issuer, not across issuers. Two customers        # on two identity providers can present the same `sub`.        return f"{self.issuer}|{self.subject}"    def has_scope(self, scope: str) -> bool:        return scope in self.scopesclass TokenVerifier:    """One verification per request. There is no session to cache it in."""    def __init__(        self,        jwks_url: str,        canonical_server_uri: str,        allowed_issuers: set[str],    ):        self._jwks = PyJWKClient(            jwks_url,            cache_jwk_set=True,   # bounded, refreshed every `lifespan`            lifespan=300,         # so a revoked signing key stops verifying            timeout=5,            # never park a worker on a hung provider        )        self._audience = canonical_server_uri        self._allowed_issuers = allowed_issuers    def verify(self, token: str) -> "Principal | None":        try:            signing_key = self._jwks.get_signing_key_from_jwt(token)            claims = jwt.decode(                token,                signing_key.key,                algorithms=["RS256", "ES256"],                audience=self._audience,          # the RFC 8707 MUST                options={                    "require": [                        "exp", "iat", "aud", "iss", "sub", "tenant_id",                    ],                },            )        except jwt.PyJWTError:            # PyJWTError, not InvalidTokenError. PyJWKClientError is a            # sibling of InvalidTokenError, so the narrower except turns an            # unknown `kid` - which any caller can send - into a 500.            return None        if claims["iss"] not in self._allowed_issuers:            return None        return Principal(            issuer=claims["iss"],            tenant_id=claims["tenant_id"],            subject=claims["sub"],            scopes=_parse_scopes(claims),        )

Whatever you pass as canonical_server_uri is the value clients send as the resource parameter under RFC 8707, and lining those two up is what makes the check bind to anything at all.

Two details in that constructor are security controls wearing the clothes of performance tuning. lifespan bounds how long a fetched key set is trusted, so revoking a compromised signing key at your identity provider actually stops tokens verifying; an unbounded key cache means revocation does nothing until you redeploy, which would undo the entire argument for moving off a shared secret. And timeout exists because PyJWKClient performs a synchronous fetch on your request path, where the default of 30 seconds is long enough for one unreachable provider to park every worker you have.

The except clause is the other detail worth stopping on, and I got it wrong first. PyJWKClientError descends from PyJWTError but is a sibling of InvalidTokenError, not a subclass, so catching the narrower class lets a JWKS failure escape. An unknown kid is trivially attacker-supplied, so that narrower except turns a bad token into a 500 rather than a 401. It also makes unknown-key distinguishable from bad-signature by response code, which is an oracle you did not mean to publish. One caveat the code does not express: a token that fails to verify is a 401, while an identity provider you cannot reach is a 503. Collapsing both to None turns a four-hour outage into what looks like four hours of user error.

Pin PyJWT>=2.13.0. CVE-2026-48526 is an algorithm confusion flaw in earlier releases that lets an attacker use a public key as an HMAC secret when a server accepts both asymmetric and HMAC algorithms, which is the exact shape this code would have if you left HS256 in the algorithms list during a migration. CVE-2026-48523 is an algorithm allowlist bypass affecting 2.9.0 through 2.12.1 by way of PyJWKClient, which this code uses.

One more correction, and it is the kind that survives review because it looks like nothing. My original code built an expiry with datetime.fromtimestamp(payload["exp"]) and no timezone, producing naive local time, then compared it against datetime.utcnow(). On a machine at UTC+05:30 those two differ by five and a half hours, so expired tokens verified successfully for most of a working morning. datetime.utcnow() has been deprecated since Python 3.12 as well. The version above sidesteps both by deleting the hand-rolled expiry check entirely and letting jwt.decode enforce exp, which it was always going to do better.

Response Redaction Now Breaks outputSchema Conformance

Tools may declare an outputSchema, and when they do, servers MUST return structured results conforming to it. So the response sanitizer in the first edition, which replaced field values with "***REDACTED***" or None after execution, can produce a result that a conforming client rejects outright.

Being accurate about the timeline, since this is the claim most worth fact-checking: the conflict has existed since outputSchema shipped in the 2025-06-18 revision, and I missed it then too. What 2026-07-28 changed is that SEP-2106 loosened these schemas to full JSON Schema 2020-12, which hands you union types and turns the workaround into something you can actually declare.

python
# The tool declares this. The server MUST conform to it.OUTPUT_SCHEMA = {    "type": "object",    "required": ["customer_id", "email"],    "properties": {        "customer_id": {"type": "string"},        "email": {"type": "string"},    },}

Redacting email to the string "***REDACTED***" conforms by luck, because the placeholder is still a string. Redacting it to None violates the declared type and the client discards the entire result, and dropping the field violates required. A redaction policy quietly became a schema conformance problem.

That is Declared Redaction biting. The rule follows from the name: under a published output schema, a field can only be withheld at runtime if the schema said in advance that it might be. You cannot hide what you never declared as hideable.

Anyone who has shipped a GraphQL API will recognize the technique, because nullable fields returning null on an authorization failure has been the answer there since 2016, and JSON:API sparse fieldsets solve a neighbouring problem. The technique is not new and I am not claiming it. What is new is the enforcement. GraphQL field authorization is a convention your own resolvers honour; under MCP the schema is published to a client that SHOULD validate against it, so a redaction policy that used to be a convention is now a conformance test somebody else runs on your output.

python
# RIGHT. Redactable fields are declared nullable and optional up front.OUTPUT_SCHEMA = {    "type": "object",    "required": ["customer_id"],    "properties": {        "customer_id": {"type": "string"},        "email": {"type": ["string", "null"]},    },}def project_for(principal: Principal, record: dict) -> dict:    """Project a record down to what this principal may see."""    return {        "customer_id": record["customer_id"],        "email": record.get("email") if principal.has_scope("pii:read")                 else None,    }

Validate that projection against your own schema in continuous integration, with jsonschema.validate(result, OUTPUT_SCHEMA). The client will.

The Redaction Hole Almost Everyone Will Ship

Now the part that makes this a data exfiltration problem rather than a conformance problem, and the reason it belongs in an article with this title.

The specification says that for backwards compatibility, a tool returning structured content SHOULD also return the serialized JSON in a text content block. Its own worked example shows exactly that: structuredContent holding the object, and a content block holding the same object serialized into text.

Two representations of one result. Redact one of them.

The natural implementation builds structuredContent from your careful projection and builds the text block from the tool's raw return value, because the raw value is what is in scope at that point in the function. Both leave the server. The model reads the text block. You have published the field you just withheld, in the representation the model is most likely to act on, and your schema validation passes because structuredContent is impeccable.

python
# WRONG. The projection guards one representation of two.return {    "structuredContent": project_for(principal, record),    "content": [{"type": "text", "text": json.dumps(record)}],}# RIGHT. Project once. Everything that leaves is derived from the projection.visible = project_for(principal, record)return {    "structuredContent": visible,    "content": [{"type": "text", "text": json.dumps(visible)}],}

The rule is one sentence and belongs in your code review checklist: the projection is the only thing that may become a response, in either representation. A redaction applied to one serialization of a result is not a redaction.

Declared Redaction leaks one bit, and you should decide about that deliberately rather than discover it later: a client can tell that email exists and was withheld, because the schema says the field is there and nullable. Where even that is unacceptable, publish two tools with two schemas and gate them on scope, instead of returning one schema with holes in it.

Three consequences follow, and they are the reason this is worth a name rather than a footnote.

Your sensitive-field list becomes a design artifact instead of a runtime behaviour. Somebody has to write it down before the tool ships, in a file a reviewer can read, which is a better place for it than scattered through a sanitizer.

Widening it later is now a schema change. Adding a field to the redactable set means republishing the schema, and every client that validates against the old one notices. A redaction policy that used to drift quietly under pressure from whoever wanted one more field now has to drift in public.

Redaction needs a step-up channel, and picking the wrong one is easy. Returning null because the caller lacks pii:read is conformant and also a dead end, since the model has no way to know the value exists and could be unlocked.

The obvious move is a WWW-Authenticate challenge naming the scope. It does not work, and I recommend against it. A partially redacted result is a successful tools/call: a JSON-RPC result over HTTP 200. WWW-Authenticate is defined for 401 responses, and the insufficient_scope error for 403. Putting it on a 200 is nonconformant and no client will act on it.

Use the channels that fit the status code. For a partial redaction, which is a 200, carry the signal in the payload: either _meta naming the withheld fields and the scope that would unlock them, or an InputRequiredResult under the Multi Round-Trip Requests pattern when you want the client to go and get the scope before continuing. Reserve the transport-level challenges for the transport-level cases, meaning a 401 with resource_metadata when there is no usable token at all, and a 403 with error="insufficient_scope" when you refuse the entire call. Skip this and you have built a server that withholds data correctly and never tells anyone why.

Which Layers Survived the Spec Change

Let me be exact about the arithmetic, because it is the frame this article opens and closes with. Three layers were wrong: auth, state, and the response sanitizer. Two needed an adjustment rather than a correction. Nothing came through untouched. What survived is the decomposition itself, which is a narrower claim than the one I would rather make.

Rate limiting needed a rename, from per-conversation to per-handle. The revision offers a genuine improvement alongside it: Mcp-Method and Mcp-Name are now required Streamable HTTP headers, so a gateway can rate-limit by tool at the control plane without parsing request bodies. Note what those headers are, though. They are client-asserted, so the attack is declaring Mcp-Name: cheap_tool in the header while the body calls the expensive one, which evades the very limit you deployed them for. The server has to verify that the header matches the tool it actually dispatched and reject on mismatch, with the -32020 HeaderMismatch error the spec defines. Otherwise you have built a rate limiter that callers address by name.

Audit logging needed a dependency move. MCP's own Logging feature is deprecated as of 2026-07-28, alongside Sampling and Roots, on a minimum twelve-month clock. SEP-414 documents OpenTelemetry trace context in _meta instead, which suits structured, versioned context logging better than protocol logging ever did. While you are in there, log a truncated hash of the state handle rather than the handle itself, since it is a capability string that now travels through model context.

Request sanitization came through unchanged.

One sentence from the original model has to go: it said the authentication context "never changes during a conversation". There is no protocol conversation now, and authentication is re-established on every request. The three-context model survives, with the principal rather than the session as its invariant.

mermaid
flowchart TD
    R["Tool call arrives<br/>no session, no handshake"] --> V{"Token verified?<br/>signature, iss, aud"}
    V -->|no| D1["401 + WWW-Authenticate<br/>Bearer resource_metadata=..."]
    V -->|yes| P["Principal derived from claims<br/>tenant_id, sub, scopes"]
    P --> K["State key built as<br/>tenant + issuer|sub + handle"]
    K --> L{"Handle live under<br/>this principal?"}
    L -->|no| D2["Uniform not-found<br/>no existence oracle"]
    L -->|yes| A{"Scope ok and atomic<br/>ceiling check passes?"}
    A -->|no| D3["403 error=insufficient_scope<br/>scope=..., audit the denial"]
    A -->|yes| E["Execute tool"]
    E --> S{"outputSchema<br/>declared?"}
    S -->|yes| C["Project through<br/>nullable declared fields"]
    S -->|no| C2["Project by scope"]
    C --> AU["Audit: request id, principal,<br/>decision, handle hash"]
    C2 --> AU

    style R fill:#4A90E2,color:#FFFFFF
    style V fill:#7B68EE,color:#FFFFFF
    style L fill:#7B68EE,color:#FFFFFF
    style A fill:#7B68EE,color:#FFFFFF
    style S fill:#7B68EE,color:#FFFFFF
    style P fill:#98D8C8,color:#2C2C2A
    style K fill:#98D8C8,color:#2C2C2A
    style E fill:#6BCF7F,color:#2C2C2A
    style C fill:#FFD93D,color:#2C2C2A
    style C2 fill:#FFD93D,color:#2C2C2A
    style D1 fill:#E74C3C,color:#FFFFFF
    style D2 fill:#E74C3C,color:#FFFFFF
    style D3 fill:#E74C3C,color:#FFFFFF
    style AU fill:#95A5A6,color:#FFFFFF

Every decision on that path runs on every request, which is the shape of a server with no session to lean on.

What the Spec Added That Your Threat Model Probably Misses

Three requirements sit in the current specification that the first edition of this article never mentioned, and each is short to implement and expensive to skip.

Validate the Origin header. Servers MUST validate Origin on all incoming connections and respond 403 when it is present and invalid. When running locally they SHOULD bind to 127.0.0.1 rather than all interfaces. This is the DNS rebinding class, and it is not theoretical: CVE-2025-49596 in MCP Inspector scored 9.4 on CVSS because a malicious web page could reach a local MCP endpoint and launch stdio commands. Fixed in Inspector 0.14.1.

Treat tool annotations as untrusted. Clients MUST consider tool annotations untrusted unless they come from a trusted server. So readOnlyHint and destructiveHint are hints for presentation, and the specification explicitly refuses to let them carry security weight. If your authorization decision reads an annotation, it is reading attacker-controlled input.

Elicitation brings its own identity problem. Servers MUST NOT use form-mode elicitation for passwords, API keys, tokens, or payment credentials, and MUST use URL mode for those. More importantly for this article, servers MUST NOT rely on client-provided user identification without server verification, because it can be forged. Identity must come from the authorization credentials, meaning the sub claim. Its phishing chain is spelled out: one user triggers an elicitation, tricks a second user into completing the OAuth flow, and the resulting tokens bind to the first user's identity.

One pattern runs across all three, and it is the one this article started with in January, now with spec text behind it. Derive identity and authority from verified credentials. Never from something the caller handed you.

Pitfalls and Failure Modes

All five failure modes from the first edition are still real, and three of them now have canonical names worth adopting, so your writing matches what the rest of the field searches for.

Context injection through parameter manipulation. Without malice, a model requests customer_id="admin" or a path-traversal string, because that is how it solves problems. Detection: a spike in 403 responses and resource identifiers that do not match expected patterns. Prevention: derive identifiers from verified credentials and validate against the state record. OWASP tracks this class as Context Injection and Over-Sharing. Its MCP Top 10 is still v0.1 beta, so use the names and expect the numbering to move.

Conversation context expansion. An attacker widens scope across many turns until the workflow reaches resources outside its original purpose. Detection: resource counts per handle, and resource types drifting from the initial scope. Prevention: the resource ceiling above, plus handle expiry. Its canonical name is privilege escalation via scope creep, OWASP MCP02, and the specification's Scope Minimization section is the matching control.

Rate limit bypass through handle rotation. Formerly conversation rotation: an attacker mints new handles to reset per-handle limits. Prevention: per-user and per-tenant limits must exist independently. Per-handle limits catch mistakes, per-user limits catch attacks.

Response leakage through error messages. Distinguishing "exists but forbidden" from "not found" tells an attacker which resources exist. Prevention: uniform responses for both, which is why load above returns None on a miss.

Multi-tenant isolation bypass through shared resources. This one has external proof now. Asana disabled its MCP server for twelve days in June 2025, from the 5th to the 17th, after a flawed tenant-isolation check let users see other organizations' projects, tasks, comments, and files. Customer counts differ between outlets and Asana confirmed none, so take the mechanism as the lesson rather than the blast radius. Strict tenant equality with no exception for admin or support accounts is the control.

Three failure modes belong on the list that were not on it in January.

State handle hijacking. Covered above: the newest entry, and the one the spec change created.

Tool poisoning and line jumping. Invariant Labs named tool poisoning in April 2025, demonstrating instructions hidden in a tool's description that exfiltrated SSH keys through an innocuous-looking add(a, b) tool. Trail of Bits framed the same class as line jumping on 2025-04-21: content enters model context at discovery time, before any tool call, so per-call approval gates never fire. Your request sanitizer cannot see this, because nothing has been requested yet.

Exfiltration through entirely trusted tools. No compromised tool appears anywhere in the most instructive MCP incident so far. In May 2025 Invariant Labs showed a prompt injection planted in a public GitHub issue causing an agent to read a private repository and write its contents into a public pull request, with every tool legitimate, every description honest, and every call authorized. The payload was in the data. Simon Willison's lethal trifecta names the precondition: private data access, exposure to untrusted content, and a way to communicate outward. Since a server author usually controls only one of the three, exfiltration defense cannot be finished inside a single server.

An MCP Server Security Conformance Checklist

Work through this against a running server rather than a design document. Items marked MUST are specification requirements at 2026-07-28.

  • MUST reject any token not issued for this server, by validating the audience claim against your canonical server URI (RFC 8707).
  • MUST NOT accept or forward tokens issued for other resources.
  • MUST NOT treat possession of a state handle as authentication.
  • MUST validate Origin against an allowlist and reject anything not on it. Absent Origin is legitimate for non-browser clients, so decide that case explicitly rather than defaulting to allow, and bind locally-run servers to 127.0.0.1 so the question does not arise.
  • MUST conform to any outputSchema your tools declare, including after redaction. Apply Declared Redaction: every field you might ever withhold is nullable and optional in the published schema, and nothing else is withheld at runtime.
  • MUST apply the projection before building the text content block, not after. A redaction applied to one serialization of a result is not a redaction.
  • MUST NOT trust client-supplied user identity in elicitation flows; use the sub claim.
  • SHOULD mint state handles from a secure random source, opaque and structureless.
  • SHOULD key stored state under the principal from the verified token, not from the request, and include the tenant and the issuer, since sub is unique only within an issuer.
  • Signal a partial redaction in the payload, through _meta or an InputRequiredResult, never through WWW-Authenticate, which cannot ride on the 200 that a partially redacted result is.
  • Implement RFC 9728 protected resource metadata, return WWW-Authenticate: Bearer resource_metadata=... on 401, and error="insufficient_scope" on 403.
  • Verify asymmetrically against a JSON Web Key Set (JWKS) rather than a shared symmetric secret, and bound the key cache lifetime so revocation takes effect without a redeploy.
  • Catch PyJWTError, not InvalidTokenError, so an unknown kid is a 401 rather than a 500. Distinguish a bad token from an unreachable provider; the second is a 503.
  • Pin PyJWT>=2.13.0 if you verify JWTs in Python.
  • Replace every datetime.utcnow() with datetime.now(timezone.utc), and pass tz=timezone.utc when reading token timestamps.
  • Make the resource ceiling atomic. A check followed by a separate write is a ceiling any concurrent caller can walk past.
  • Bound handles with two clocks: an idle timeout that refreshes, and an absolute maximum age that does not.
  • Return uniform responses for missing and forbidden resources.
  • Enforce rate limits at tenant and user granularity, not only per handle, and verify that Mcp-Name matches the tool you actually dispatched.
  • Write an integration test that mints a handle, persists it, reloads it, and mutates it. That test is what the first edition of this article was missing.

Then try to break it. Write tests that present one principal's handle under another principal's token, that submit a token minted for a different resource, that send a token with an unknown kid, that fire twenty concurrent calls at a ceiling of ten, and that request a redacted field and grep the text content block for it. If none of those fail against an unpatched build, the tests are wrong.

What This Changes About Securing MCP Servers

The decomposition held. Authentication, state, rate limiting, sanitization, and audit are still the right five places to put security, and the spec change strengthened the case for treating them as independent. Three of the five implementations behind them did not hold, which is a smaller claim than I would like to be making about my own article, and the honest one.

Principal binding is the thread running through all of it. The protocol used to answer whether a group of requests came from one authenticated actor, and on 2026-07-28 it stopped. Every control that had been reading that answer for free now has to compute it from a verified token, on every request, with nothing to amortize the work across. A state key that names anything the caller chose, a ceiling counted without atomicity, a projection applied to one of two serializations: each of those is the same missing answer wearing different clothes.

That is the question to carry into the next deprecation, and more are already scheduled. Sampling, Roots, and Logging are on a twelve-month clock. For each one, do not ask whether anything broke, because nothing will. Ask which question that feature was answering on your behalf, and then go and find the line of code that is still assuming it gets an answer.

References


AI Engineering

AI Security

Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Get the next article by email

One email when a new piece goes up. No digest, no drip sequence.

One email per new article. Unsubscribe in one click.

Books by Ranjan Kumar

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments