Authenticated Is Not Authorized: What a 9.3 Fail-Open Default in an Agent Mesh Teaches About Tool Gateways
TL;DR What was reported: On 2026-10-19, CVE-2026-103091 was published for Google OSS Agent Mesh (SAM), versions 0.1.0-alpha.1 through 0.1.0-alpha.8. It is rated CVSS 9.3 (critical). The record describes missing autho
TL;DR
- What was reported: On 2026-10-19, CVE-2026-103091 was published for Google OSS Agent Mesh (SAM), versions 0.1.0-alpha.1 through 0.1.0-alpha.8. It is rated CVSS 9.3 (critical). The record describes missing authorization in the MCP proxy plus insecure defaults in the enrollment flow.
-
The chain is boring, which is the point:
GET /infopublicly discloses the OIDC configuration,POST /registerauto-enrolls any valid OIDC token holder without claim filtering or ban checks, and the default policy "bindssam:system:authenticatedto wildcard services and targets". Put together, a stranger with any valid token reaches every tool behind the proxy. - The fix: 0.1.0-alpha.9 ships a fail-closed default policy, explicit policy-binding requirements on enrollment, and ban checks.
- Not marked as known exploited in the record I read, so this is a design lesson, not an incident report.
- What you can do today: treat "authenticated" and "authorized" as two separate facts, make enrollment grant zero access, ban wildcards in shipped policy, and put three small tests in CI. Code below, all of it run before publishing.
What does CVE-2026-103091 actually say?
Two public aggregators of the record agree on the core facts, and I cross-checked them against each other before writing this.
| Field | Value in the record |
|---|---|
| Product | Google OSS Agent Mesh (SAM) |
| Affected |
>= 0.1.0-alpha.1, < 0.1.0-alpha.9
|
| Fixed | 0.1.0-alpha.9 |
| Published | 2026-10-19 |
| Severity | CVSS 4.0 base score 9.3, critical, network-reachable, no privileges, no user interaction |
| Weaknesses | CWE-862 (missing authorization), CWE-1188 (insecure default initialization), plus CWE-306, CWE-863, CWE-200 |
| Components | MCP proxy (HandleStreamPassThrough), GET /info, POST /register
|
One caveat on scope. A search-engine summary of the same CVE also mentioned a second gateway project. The two record pages I could actually open list only SAM as affected, so this article covers SAM and nothing else. If you run a different gateway, check its own advisory instead of inferring from this one.
Why is "authenticated" not the same as "authorized"?
Authentication answers "is this token genuine?". Authorization answers "may this principal call this tool with these arguments?". The reported default policy collapses the two: every principal that proves it holds a valid token gets a role, and that role is bound to every service and target.
That is the same shape as a classic web bug: a login page that works perfectly and a permission system that says yes to everyone who passed it. For an agent gateway the stakes are higher than for a web app, because the thing on the other side of the proxy is a tool catalog. Tools write files, run commands, move money, and call other tools. The record states the end result as full read, write and execution access on backend services.
An agent gateway has a second reason to be strict. The caller is often an agent acting on text it has read. If the gateway trusts "authenticated", then a successful prompt injection inherits whatever the agent's identity can reach. A gateway that grants nothing by default turns that blast radius from "everything" into "what someone explicitly bound".
Why do three small defaults add up to a critical?
Read the chain as three independent weaknesses, each of which a reviewer might wave through alone.
-
Information disclosure:
GET /infotells an unauthenticated caller which OIDC provider and settings the mesh trusts. Many products expose discovery metadata on purpose, so this looks harmless in isolation. -
Open enrollment:
POST /registerenrolls any holder of a valid token. "Valid" only means signed by an issuer the mesh accepts. If the accepted issuer is a public identity provider, valid tokens belong to millions of people, so the set of eligible callers is far larger than the set of intended users. -
Wildcard default binding: the freshly enrolled principal lands in
sam:system:authenticated, which the default policy binds to wildcard services and targets.
Step 1 tells the attacker where to go, step 2 gets them a principal, step 3 hands that principal everything. Any one control, if it had been strict, would have broken the chain.
The next chart is a simulation I wrote to make the enrollment step concrete. It is illustrative, not measured on any real deployment: 10,000 holders of valid tokens, of whom 2% belong to the intended tenant (the 2% is my assumption).
With open enrollment, all 10,000 enroll. With an issuer allowlist plus a required tenant claim, 217 do in this seeded run (the exact number moves with the seed, the order of magnitude does not). Enrollment is the cheapest place to shrink the audience, and it costs one conditional.
What does a fail-closed policy look like in code?
The principle is that the absence of a rule is a "no". The engine below is deliberately tiny. It matches targets exactly, never expands a wildcard, and treats an unknown role or an unknown target as deny.
from dataclasses import dataclass, field
@dataclass
class Policy:
bindings: dict = field(default_factory=dict) # role -> set of "service:tool"
def allowed(self, principal_roles, target):
for role in principal_roles:
for pat in self.bindings.get(role, ()):
if pat == target: # exact match only, no wildcards
return True
return False # fail closed
def lint(policy):
return [(r, p) for r, pats in policy.bindings.items() for p in pats if "*" in p]
p = Policy({"admin": {"billing:refund"}, "reader": {"docs:search"}})
assert not p.allowed(["authenticated"], "billing:refund") # fresh principal reaches nothing
assert not p.allowed([], "docs:search")
assert not p.allowed(["reader"], "docs:delete") # unknown target denies
assert not p.allowed(["ghost"], "docs:search") # unknown role denies
assert lint(p) == [] # shipped default has no wildcard
assert lint(Policy({"authenticated": {"*:*"}})) == [("authenticated", "*:*")]
print("fail-closed conformance: ok")
I ran this before publishing and it prints fail-closed conformance: ok. Three properties matter more than the specific code:
- The default is empty. A new role that someone forgets to configure gets nothing, and the forgetting shows up as a support ticket, not a breach.
- Wildcards are a lint failure. If a team truly needs a broad grant, it should be a named role with a reviewer, not a shipped default.
- The tests encode the sentence "a fresh principal reaches nothing". That sentence was false in the affected versions, and a one-line assertion would have caught it.
The next chart shows what each default choice means for a freshly enrolled principal in a 40-tool catalog (16 read, 12 write, 8 exec, 4 admin; illustrative numbers from my simulation).
How should enrollment be gated?
Enrollment is where identity becomes a principal, so it deserves its own checks. Verify the issuer, verify the claims that tie the token to your tenant, check a ban list, and then grant no roles. Roles come from a separate, explicit step.
def enroll(claims, *, allowed_issuers, required, banned):
"""Return the roles a new principal gets. Empty list means enrolled with zero access."""
if claims.get("iss") not in allowed_issuers:
raise PermissionError("issuer not allowed")
if claims.get("sub") in banned:
raise PermissionError("banned")
for k, v in required.items():
if claims.get(k) != v:
raise PermissionError(f"claim {k} mismatch")
return [] # enrollment never implies a binding
ok = {"iss": "https://idp.example", "sub": "u1", "tenant": "acme"}
assert enroll(ok, allowed_issuers={"https://idp.example"}, required={"tenant": "acme"}, banned=set()) == []
Notice what the function returns on success: an empty list. The affected flow, as described in the record, did the opposite. Its enrollment endpoint had no claim filtering or ban checks, and its default binding supplied broad access automatically. Separating "became a known principal" from "was given power" is the structural fix, and it is also the one that survives the next default somebody adds.
I tested the failure paths too: wrong tenant, wrong issuer and a banned subject all raise before any role exists. The script prints enrollment gate: ok.
What happens as the tool catalog grows?
A wildcard policy has a property that makes it worse over time: it silently covers tools that did not exist when it was written. Every release that adds an exec or admin tool widens the exposure without a single policy edit.
The chart below simulates eight releases of five new tools each, with 30% of new tools being exec or admin class, on top of a 40-tool starting catalog that already has 12 exec/admin tools. Under a wildcard default the exposed high-risk count climbs from 12 to 22 in this seeded run. Under explicit bindings it stays at 0 until someone binds something on purpose. It is illustrative: the 30% rate and the five-per-release cadence are my assumptions.
The practical reading is that explicit bindings convert a growing, invisible risk into a reviewable diff. Each new tool arrives with a pull request that says who may call it.
How do you test your own gateway before someone else does?
None of this needs the vendor's help. A short checklist you can automate against a staging gateway:
- Fresh-principal test. Enroll a brand-new test identity with the minimum claims and call a catalog of every registered tool. The expected result is deny for all of them. Any success is a finding.
- Wrong-tenant test. Mint a valid token from an issuer or tenant you do not intend to serve. Enrollment should fail, not succeed with low privilege.
- Discovery review. Fetch every unauthenticated endpoint and list what it reveals. Some metadata is fine to expose, but know exactly what an outsider can read.
-
Wildcard scan. Grep the shipped default policy and every example policy for
*. Examples get copied into production, so treat them as defaults. - Catalog diff in CI. Compute the live tool registry at test time and assert it equals the set of policy keys, so a new tool without a binding fails the build.
-
Upgrade check. If you run SAM, confirm the version is at or above
0.1.0-alpha.9, and re-run steps 1 and 2 after upgrading because defaults can differ from what your config assumed.
Run items 1, 2 and 5 on every deploy. They are cheap, and they test the one property that failed here.
What does this not tell us?
- No exploitation claim. The record I read does not mark this as known exploited. A critical score describes how bad it could be, not how many people were hurt.
-
Alpha software. The affected builds are
alphareleases. Early projects ship permissive defaults to make demos work, which is understandable, and the lesson is to flip them before the first real user, not after. - I did not test SAM. I read the published record. Where this article describes behavior, it quotes or paraphrases that record, and the code samples are my own illustrations of the principle.
- Simulation numbers are not findings. Every chart here comes from a seeded script with assumptions stated next to it.
FAQ
What is CVE-2026-103091 in one sentence?
A critical (CVSS 9.3) flaw in Google OSS Agent Mesh alpha builds where any valid OIDC token holder could auto-enroll and, through a wildcard default policy, call every tool behind the MCP proxy.
Which versions are affected and what is the fix?
0.1.0-alpha.1 up to but not including 0.1.0-alpha.9. Upgrade to 0.1.0-alpha.9 or later, which enforces a fail-closed default policy, explicit policy bindings on enrollment, and ban checks.
Is a valid token not good enough proof of identity?
It proves the token was issued by a trusted issuer. It says nothing about whether that principal should reach your tools. Authorization needs its own explicit rule.
Why not just add more checks inside each tool?
Do both. Tool-level checks are the last line of defense, but a gateway that allows everything shifts all the risk onto every tool author. Deny-by-default at the gateway gives one place to review.
How do I stop a policy from drifting back to a wildcard?
Lint for * in CI, fail the build when the tool registry and policy keys disagree, and require a reviewer for any role that binds more than a handful of targets.
Does this matter if my agents never face the public internet?
Yes. Internal tokens leak, tenants multiply, and an injected prompt makes an internal agent behave like an outside caller. The cost of fail-closed is small either way.
Further reading
Earlier posts in this series:
- The Model Is Not a Security Boundary: What Two Semantic Kernel CVEs Teach About Gating Tool Calls (published 2026-10-22, same series)
- Should Your AI Agent Act? An Engineer's Guide to Action Gates, Confidence, and the Latency Budget (published 2026-10-05, same series)
Sources
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.