
{"id":74,"date":"2026-09-19T12:15:37","date_gmt":"2026-09-19T12:15:37","guid":{"rendered":"https:\/\/www.0xprathamesh.com\/blog\/?p=74"},"modified":"2026-09-21T06:03:18","modified_gmt":"2026-09-21T06:03:18","slug":"unreachable-not-a-control-authentication-decorative","status":"publish","type":"post","link":"https:\/\/www.0xprathamesh.com\/blog\/unreachable-not-a-control-authentication-decorative\/","title":{"rendered":"Unreachable Is Not a Control: When Authentication Becomes Decorative"},"content":{"rendered":"<p class=\"article-deck\"><strong>A CVSS 9.1 was published yesterday against Mnemosyne, a memory layer for AI agents.<\/strong> Its authentication check base64-decoded the token&#8217;s header and payload, then passed that token to a JWT library with options that effectively disabled signature verification. The server accepted any well-formed token, including tokens declaring <code>alg: none<\/code> and tokens signed with the wrong key.<\/p>\n<p><strong>Answer first:<\/strong> the advisory&#8217;s own mitigation guidance contains the more interesting finding. For anyone who cannot patch immediately, it recommends restricting network access to the sync endpoint, and states plainly that the vulnerability is not exploitable against an unreachable endpoint. That is a vendor telling you which control was actually protecting your deployment, and it was not the authentication.<\/p>\n<p>Three advisories from the same day show the same shape at three other points in the same pipeline. Which is why the useful thing to take from this is not a bug pattern but a way of marking where each control actually operates.<\/p>\n<h2>The 9.1, and what decoding a token proves<\/h2>\n<p>Start with the mechanism, because it is small and completely ordinary. The auth check read the JWT&#8217;s header and payload by base64-decoding them, which is all that is required to read the claims inside a JWT. Then it called the JWT library, with options that turned verification off. The result is that the server could read every claim in the token and could confirm none of them.<\/p>\n<p>Two consequences follow directly. Tokens declaring <code>alg: none<\/code> were accepted, because nothing was checking the algorithm. Tokens signed with the wrong key were accepted, because nothing was checking the signature. The server was parsing credentials and calling it authentication.<\/p>\n<p>The fix is worth reading, because it is a stronger position than the one it replaced. Rather than correct the options passed to the library, the maintainers replaced the broken decode with a from-scratch HS256 verifier using only the Python standard library. That is a deliberate choice: when a dependency&#8217;s configuration surface has a footgun that silently disables the security property you are relying on, a small implementation you can audit end to end can be the safer option. I would not generalise it into advice for every case, and here it is defensible and clearly documented.<\/p>\n<p>What the setting makes clear is why this matters more than a normal authentication bypass. The affected component is an agent&#8217;s memory layer. Memory is where an agent&#8217;s future behaviour is stored, so read access to it is a disclosure problem and write access is a persistence problem. An attacker who can write to an agent&#8217;s memory is not exploiting a session, they are editing a record the agent will consult later. That is a different kind of impact from reading a token.<\/p>\n<figure>\n<img decoding=\"async\" src=\"https:\/\/www.0xprathamesh.com\/assets\/blog\/unreachable-not-a-control\/credential-pipeline.svg\" alt=\"The four stages of a credential pipeline, receive, parse, verify and authorise, with four advisories marked at the stage each one failed: a decode mistaken for verification, a token verified but never scoped, a second listener that never reached the verifier, and a boundary that was network isolation rather than authentication\" width=\"1600\" height=\"1000\" loading=\"lazy\"><figcaption>Four stages, four components, and the assumption underneath all of them.<\/figcaption><\/figure>\n<h2>The model: four stages that get mistaken for one another<\/h2>\n<p>Here is the framing I take from the batch, and it applies to any system that accepts a credential.<\/p>\n<p>A credential pipeline has four stages. <strong>Receive<\/strong>: did the request arrive with a credential at all. <strong>Parse<\/strong>: can I read the claims inside it. <strong>Verify<\/strong>: is it authentic, and was it meant for me. <strong>Authorise<\/strong>: is this principal permitted to do this thing.<\/p>\n<p>Each stage is commonly confused with the one after it, and each confusion has its own failure mode.<\/p>\n<p><strong>Parse mistaken for verify<\/strong> is Mnemosyne. Reading claims proves nothing about their origin, because a JWT is base64 text with three sections that anyone can produce. Decoding is a parsing operation that has no security meaning on its own, and it happens to look like progress because the claims appear.<\/p>\n<p><strong>Verify mistaken for authorise<\/strong> is the audience and scope problem, and it is the one that survives a correct signature check. A token that is validly signed is authentic and says nothing about whether it should be usable against the service receiving it, or what it should be allowed to do there. The companion from the same day is a clean instance: an OAuth flow issued an access token carrying the victim&#8217;s full set of groups rather than scoping it to the single tool server that had been requested, so a token meant for one destination worked against everything the victim could reach.<\/p>\n<p><strong>Receive skipped entirely<\/strong> is the third shape, and it is a deployment failure rather than a code one. The same day&#8217;s Process Compose advisory describes an MCP listener started separately from the REST API, so it did not inherit the token middleware that protected the REST routes, and the MCP path accepted browser-origin requests before any host check, origin check or caller-secret check. The protection existed and the second entry point did not have it.<\/p>\n<p><strong>None of the four, because something else was holding<\/strong> is the fourth, and it is the one this piece is really about.<\/p>\n<h2>The fourth case: the container was the safety argument<\/h2>\n<p>The ToolHive advisory from the same day is worth reading carefully, because its mechanism is not a broken check. It is a boundary that was assumed to be doing work it was not doing.<\/p>\n<p>ToolHive runs Model Context Protocol servers as local containers. Prior to the fixed versions, those containers used the default network permission profile with no network isolation, which left the Docker gateway reachable as <code>host.docker.internal<\/code>. The ToolHive API and its MCP proxy endpoints were also reachable without authentication. So a malicious or compromised MCP server did not need to escape anything: it could use the gateway to contact host-local services, sibling MCP proxies managed by the same tool, and the control plane itself.<\/p>\n<p>The detail I would put in front of anyone running a local-tooling stack is this one. ToolHive Studio passed <code>network_isolation<\/code> as false, explicitly overriding the backend&#8217;s secure default. A product layer turned off the isolation that the engine had enabled by default, which is the pattern where the hardened component is defeated by the convenience wrapper in front of it.<\/p>\n<p>Read against the other three, the shape is consistent. In every case there was a reason to believe the endpoint was safe: it was behind a proxy, it was on localhost, it was in a container, it was on an internal network. And in every case the reason was a property of the topology rather than a decision by the service.<\/p>\n<h2>Unreachable is a circumstance, not a control<\/h2>\n<p>This is the part worth carrying forward, and I want to state it as plainly as I can.<\/p>\n<p>A control is something the service does for itself, on every request, whether or not anyone is watching. It holds when the network changes. It holds on the day the service is accidentally exposed, which is the only day it matters. And it produces a visible signal when it refuses, because a refusal is an event.<\/p>\n<p>Unreachability is a property of the environment. It is true today because of a firewall rule, a localhost bind, or a segmentation decision, and it was usually decided by someone who has since moved on and recorded nowhere as a security property. It fails silently, because nothing is monitoring a claim nobody made, and it fails exactly when a later change opens a path: a proxy reconfigured, a service moved, a debugging port left open, a product layer that sets isolation to false.<\/p>\n<p>The reason this is uncomfortable rather than merely correct is that unreachability wins reviews. It is invisible, it is free, it needs no maintenance, and it produces no findings, so a review that looks for controls finds nothing to fix and moves on. The Mnemosyne advisory is the clearest example I have seen of a vendor naming this directly: a critical authentication bypass whose mitigation for unpatched users is to restrict network access, because unreachability was the only thing that had ever held.<\/p>\n<figure>\n<img decoding=\"async\" src=\"https:\/\/www.0xprathamesh.com\/assets\/blog\/unreachable-not-a-control\/unreachable-circumstance.svg\" alt=\"Unreachability compared with a control: a circumstance holds only while nothing changes, while a control still holds when the network boundary moves, the proxy is reconfigured or a later change exposes the service, with the three tests that distinguish them\" width=\"1600\" height=\"1000\" loading=\"lazy\"><figcaption>Both keep an attacker out today. They differ on the day the network is no longer what it was this morning.<\/figcaption><\/figure>\n<h2>What this means for agents specifically<\/h2>\n<p>The AI dimension here is not incidental, and I want to be precise about it rather than gesture at it.<\/p>\n<p>Agent systems are unusually dependent on local and internal endpoints. Tool servers run on the developer&#8217;s machine. MCP proxies sit on localhost or on a container network. Memory layers sync over an internal service. Sandboxes, when they are used, are local processes. Each of those components is unreachable by default in the sense that matters: it is not on the internet, and it is not expected to be talked to by anything except its own client.<\/p>\n<p>That expectation is the whole security argument in many of these deployments, and it is a circumstance. What makes agents a specific case rather than a general one is that the components holding the most useful data, memory and tool access, are exactly the ones most likely to be unreachable by accident and least likely to have their own authentication tested. A memory sync service on an internal network does not get a penetration test, because reaching it is not supposed to be possible.<\/p>\n<p>The consequences follow the same asymmetry as the Mnemosyne advisory. A read is a disclosure. A write to memory is a change to what the agent will believe and do next, which is persistent and survives the session in which the access happened. That is a materially different risk from a stolen token, and it is the reason I would test the credential handling of an agent&#8217;s memory layer before its prompt handling.<\/p>\n<h2>What does not follow<\/h2>\n<p>Three things I am not claiming, because each would be an overread of a set of advisories.<\/p>\n<p>This is not an argument that JWT libraries are unsafe. The Mnemosyne defect came from options passed to the library that disabled verification, and the library documented what those options did. That is an integrator error with a library-shaped footgun, which is a real problem worth writing about separately, and it is not the same as a broken library.<\/p>\n<p>It is not an argument that network segmentation is worthless. Segmentation is one of the most effective things an organisation can do, and it is why these findings were mostly theoretical against most deployments. The claim is narrower: segmentation is not authentication, and the two should not be substituted for one another in a design document. A service that is reachable only from its own client still needs to decide who its client is.<\/p>\n<p>And it is not an argument that the vendors handled this badly. All four advisories are specific, they name affected and fixed versions, and the Mnemosyne one goes further than most by telling unpatched users exactly which mitigation to apply and why. Naming unreachability as the load-bearing property is unusually honest documentation, and I would rather read that than a claim that the vulnerability was theoretical.<\/p>\n<h2>How to test this<\/h2>\n<p>Three tests, none of which needs a specialist, and the first one is free.<\/p>\n<p><strong>Reverse the topology.<\/strong> Take a service that should only be reachable from its own client and reach it from somewhere else. Not to attack it, to observe what happens to the credential logic when the network assumption is removed. Where the answer is that it refuses <em>because of something it checks<\/em>, you have a control. Where the answer is that it accepts a request it should not, the network was the control, and now you know.<\/p>\n<p><strong>Tamper deliberately.<\/strong> For every endpoint that accepts a token, send three requests: one with a well-formed token signed with the wrong key, one declaring no algorithm, and one validly signed for a different audience. All three should be refused, and the refusals should be logged. If the first two are accepted, you have the Mnemosyne shape. If the third is accepted, you have the audience problem, and no amount of signature validation will catch it because the signature is correct.<\/p>\n<p><strong>Mark the stage.<\/strong> For each authentication mechanism in the estate, write down which of the four stages it operates at: receive, parse, verify, authorise. I have found this is where the uncomfortable answers live, because a surprising number of mechanisms turn out to be operating at parse. If nobody can state the stage, then no stage is covered, and the thing holding the boundary is whatever the network happens to be doing today.<\/p>\n<h2>Evidence to look for in your own stack<\/h2>\n<table>\n<thead>\n<tr>\n<th>Question<\/th>\n<th>What to look at<\/th>\n<th>Why it matters<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Which stage does each mechanism operate at?<\/td>\n<td>Every component that accepts a credential, marked receive, parse, verify or authorise<\/td>\n<td>Four advisories in one day failed at four different stages, and a review that treats them as one thing cannot see any of them<\/td>\n<\/tr>\n<tr>\n<td>Do we ever read claims without verifying them?<\/td>\n<td>Token handling code, and specifically the options passed to any verification library<\/td>\n<td>Decoding a JWT is a parsing operation with no security meaning, and it looks like progress because the claims appear<\/td>\n<\/tr>\n<tr>\n<td>Is any token scoped to more than its purpose?<\/td>\n<td>Tokens minted for a specific tool, service or server, and the groups or scopes they carry<\/td>\n<td>A validly signed token for the wrong audience passes every signature check and is still the wrong credential<\/td>\n<\/tr>\n<tr>\n<td>How many listeners serve the same application?<\/td>\n<td>Every separate listener, and which middleware each one inherits<\/td>\n<td>A second listener started separately does not inherit the first one&#8217;s protections, and that is a deployment gap rather than a code defect<\/td>\n<\/tr>\n<tr>\n<td>Does anything depend on the endpoint being unreachable?<\/td>\n<td>Services on localhost, container networks or internal segments, and what protects them if a path opens<\/td>\n<td>Unreachability fails silently at exactly the moment it matters, and it is never recorded as the property it is<\/td>\n<\/tr>\n<tr>\n<td>Did a wrapper override a secure default?<\/td>\n<td>Product layers, CLIs and UIs that set flags on the component underneath<\/td>\n<td>ToolHive Studio passed network isolation as false, turning off the engine&#8217;s own default<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<h2>Frequently asked questions<\/h2>\n<h3>What went wrong in the Mnemosyne vulnerability?<\/h3>\n<p>The authentication check base64-decoded the JWT&#8217;s header and payload to read the claims, then passed the token to a JWT library with options that effectively disabled signature verification. The server therefore accepted any well-formed token, including tokens declaring <code>alg: none<\/code> and tokens signed with the wrong key. It is CWE-347, improper verification of a cryptographic signature, and it is fixed by replacing the decode with a verifier that checks HS256 signatures.<\/p>\n<h3>Why is decoding a JWT not the same as verifying it?<\/h3>\n<p>Because a JWT is three base64 sections that anyone can produce. Decoding reveals the claims, and reveals nothing about who created them, whether they were altered, or whether the issuer is one you trust. Verification is the separate operation that checks a signature against a known key and confirms the claims were issued by the party you expect. Reading a token is a parsing step, and a parser has no opinion about authenticity.<\/p>\n<h3>What is audience confusion?<\/h3>\n<p>It is when a token that is validly signed is accepted by a service it was not issued for, or is issued with broader authority than the request implied. The companion advisory is a clean example: an OAuth flow issued an access token carrying the victim&#8217;s full group membership rather than scoping it to the single tool server requested, so a token intended for one destination worked against every endpoint the victim could reach. Every signature check passes in that scenario, which is why verifying authenticity is not the same as enforcing scope.<\/p>\n<h3>Is an unreachable endpoint a security control?<\/h3>\n<p>It is a property of your network rather than a decision by the service, and it holds only while the network stays as it is. That makes it valuable and it makes it different in kind from authentication, which holds on every request regardless of topology. The practical position is to treat unreachability as defence in depth rather than as the boundary, and to state explicitly which rule keeps a service closed and who reviews that rule.<\/p>\n<h3>Why does this matter more for agent systems?<\/h3>\n<p>Because agent components concentrate on local and internal endpoints that are unreachable by accident: tool servers on a developer machine, MCP proxies on localhost or a container network, memory layers syncing over an internal service. Those are precisely the components holding the most useful data and the least likely to have their own authentication tested, and in the case of a memory layer a write access changes what the agent will believe and do in future sessions rather than just what it exposes now.<\/p>\n<h3>What should be done first?<\/h3>\n<p>Send the three tampered tokens to every endpoint that accepts one. A well-formed token with the wrong signature, a token declaring no algorithm, and a valid token for a different audience. That takes an afternoon, it needs no tooling, and it separates the mechanisms that verify from the ones that only parse, which is the distinction all four advisories turn on.<\/p>\n<h2>Why this one is worth more than its CVSS<\/h2>\n<p>The individual defects here are ordinary. A flag passed wrongly, a listener that did not inherit middleware, an OAuth flow that over-scoped a token, a network profile left at its default. None of them is a novel technique.<\/p>\n<p>What makes the batch worth writing about is that all four were protected by the same unstated assumption, and in one case the vendor said it out loud: the endpoint could not be reached, so the credential did not have to work. That assumption is invisible in a design review, it produces no findings, and it is the one that agents have made at scale because so much of their tooling lives on localhost.<\/p>\n<p>Companions in this series that sit alongside it: <a href=\"https:\/\/www.0xprathamesh.com\/blog\/oauth-token-exchange-second-door-revocation\/\">the session-issuance path that skipped the checks the other path ran<\/a>, and <a href=\"https:\/\/www.0xprathamesh.com\/blog\/agent-authority-provenance-four-sources\/\">where an agent&#8217;s authority actually comes from<\/a>.<\/p>\n<h2>Primary sources<\/h2>\n<ul>\n<li><a href=\"https:\/\/github.com\/advisories\/GHSA-xcw4-53cc-hv32\" target=\"_blank\" rel=\"noopener noreferrer\">GHSA-xcw4-53cc-hv32: Mnemosyne JWT signature verification bypass in the sync server (CVE-2026-59163, CVSS 9.1)<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/advisories\/GHSA-qg2g-g9w3-m5h8\" target=\"_blank\" rel=\"noopener noreferrer\">GHSA-qg2g-g9w3-m5h8: ToolHive, containerized MCP servers reaching host services via the Docker gateway (CVE-2026-58197)<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/advisories\/GHSA-5gm3-9crp-6g3v\" target=\"_blank\" rel=\"noopener noreferrer\">GHSA-5gm3-9crp-6g3v: Process Compose, DNS rebinding reaching the local MCP SSE listener (CVE-2026-77339)<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/advisories\/GHSA-xwmw-prc4-v3cr\" target=\"_blank\" rel=\"noopener noreferrer\">GHSA-xwmw-prc4-v3cr: Obot OAuth token theft via audience confusion<\/a><\/li>\n<li><a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc8725\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8725: JSON Web Token Best Current Practices<\/a><\/li>\n<li><a href=\"https:\/\/www.rfc-editor.org\/rfc\/rfc7519\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 7519: JSON Web Token<\/a><\/li>\n<li><a href=\"https:\/\/cwe.mitre.org\/data\/definitions\/347.html\" target=\"_blank\" rel=\"noopener noreferrer\">CWE-347: Improper Verification of Cryptographic Signature<\/a><\/li>\n<\/ul>\n<p><!--0XPRATHAMESH-CTA--><\/p>\n<h2>One next action<\/h2>\n<p>If you run an agent that executes tools, <a href=\"https:\/\/github.com\/prathamesh-ops-sudo\/AgentCrash\" target=\"_blank\" rel=\"noopener noreferrer\">AgentCrash<\/a> is an open-source harness that crash-tests it in a synthetic workplace: synthetic tools, visual evidence and regression tests. It is built for exactly this problem, turning a containment claim into something you can run against the system instead of arguing about it in review.<\/p>\n<p><script type=\"application\/ld+json\">{\"@context\":\"https:\/\/schema.org\",\"@type\":\"FAQPage\",\"mainEntity\":[{\"@type\":\"Question\",\"name\":\"What went wrong in the Mnemosyne agent memory vulnerability?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"The authentication check base64-decoded the JWT header and payload to read the claims, then passed the token to a JWT library with options that effectively disabled signature verification. The server accepted any well-formed token, including tokens declaring alg none and tokens signed with the wrong key. Fixed by replacing the decode with an HS256 verifier.\"}},{\"@type\":\"Question\",\"name\":\"Why is decoding a JWT not the same as verifying it?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"A JWT is three base64 sections anyone can produce. Decoding reveals the claims and says nothing about who created them or whether the issuer is trusted. Verification is a separate operation checking a signature against a known key, so a parser has no opinion about authenticity.\"}},{\"@type\":\"Question\",\"name\":\"What is audience confusion in token handling?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"It is when a validly signed token is accepted by a service it was not issued for, or carries broader authority than the request implied. Every signature check passes, which is why verifying authenticity is not the same as enforcing scope or audience.\"}},{\"@type\":\"Question\",\"name\":\"Is an unreachable endpoint a security control?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"It is a property of the network rather than a decision by the service, so it holds only while the network stays as it is and fails silently when a path opens. Treat unreachability as defence in depth rather than as the boundary, and record which rule keeps the service closed.\"}}]}<\/script><\/p>\n<p><script type=\"application\/ld+json\">{\"@context\":\"https:\/\/schema.org\",\"@type\":\"BreadcrumbList\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/www.0xprathamesh.com\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Field Notes\",\"item\":\"https:\/\/www.0xprathamesh.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":3,\"name\":\"Unreachable Is Not a Control: When Authentication Becomes Decorative\",\"item\":\"https:\/\/www.0xprathamesh.com\/blog\/unreachable-not-a-control-authentication-decorative\/\"}]}<\/script><\/p>\n","protected":false},"excerpt":{"rendered":"<p>An agent memory layer accepted any JWT with its signature unchecked. The remedy is an unreachable endpoint, and unreachable is a circumstance, not a control.<\/p>\n","protected":false},"author":0,"featured_media":75,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[111,92,112,113,110,71,114],"class_list":["post-74","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-notes","tag-agent-memory","tag-authentication","tag-cve-2026-59163","tag-cwe-347","tag-jwt","tag-mcp","tag-reachability"],"jetpack_featured_media_url":"https:\/\/www.0xprathamesh.com\/blog\/wp-content\/uploads\/2026\/09\/unreachable-not-a-control-authentication-decorative.png","_links":{"self":[{"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/posts\/74","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/comments?post=74"}],"version-history":[{"count":2,"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/posts\/74\/revisions"}],"predecessor-version":[{"id":81,"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/posts\/74\/revisions\/81"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/media\/75"}],"wp:attachment":[{"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/media?parent=74"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/categories?post=74"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.0xprathamesh.com\/blog\/wp-json\/wp\/v2\/tags?post=74"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}