Envoy AI Gateway, renamed Agent Router with its v1.2.0 release, let any client that held an MCP session ID resume that session, whoever had opened it. The gateway wrote the caller's identity into every session ID to prevent exactly that, then never compared it on the way back in. Its Helm chart also encrypted those IDs with a key derived from a public default seed, default-insecure-seed, so the IDs could be forged as well as replayed.
This post is for platform teams running the gateway in front of MCP servers, especially with more than one user or tenant on a route. It covers how the bug worked, what v1.2.0 changed, the two ways an upgraded cluster can keep the public seed in play, the other MCP fixes in the same release, and a script that checks a cluster from kubectl output.
The short answer
GHSA-953h-535j-jg2v affects Envoy AI Gateway (Go module github.com/envoyproxy/ai-gateway) up to and including 1.1.0 and is fixed in 1.2.0, released 7 October 2026. Before the fix, the MCP proxy resumed a session from its ID without checking that the caller's authenticated subject matched the one embedded in the ID, and the Helm chart's default session encryption seed was a publicly known string. Upgrade to 1.2.0, make sure the seed is a random secret, and remove any fallback seed set to default-insecure-seed once clients have reconnected.
GitHub's global advisory database added the advisory on 19 October, rated medium (CVSS 4.0 6.8). The project's own repository advisory, published 7 October, rates it high. There is no CVE.
How MCP sessions work in the gateway
MCP's Streamable HTTP transport gives each client session an Mcp-Session-Id header. The gateway sits between clients and one or more backend MCP servers, so a single client session maps to a session on each backend. Rather than keep that mapping in a database, the gateway packs it into the session ID it hands the client. The plaintext looks like this:
{route name}@{subject}@{backend1}:{base64(session1)},...,{backendN}:{base64(sessionN)}
That string is encrypted with AES-GCM under a key derived from a seed, and the result is what the client sees. The {subject} part is the authenticated user from the route's JWT, when there is one. The source comment in session.go says it's there to help prevent session hijacking, which matches the MCP specification's advice that servers "SHOULD bind session IDs to user-specific information".
What went wrong in 1.1.0
Two things, and each made the other worse.
The subject was written but never read. In 1.1.0, sessionFromID decrypted the ID and pulled out the route and the per-backend session IDs. The subject was parsed and thrown away. Nothing compared it with the subject of the request trying to resume the session. Any client holding someone else's session ID could send tools/call or resources/read on it and the gateway would forward the call on that user's backend session.
The seed was public. The 1.1.0 chart set controller.mcp.sessionEncryption.seed to default-insecure-seed and passed it to the controller as a literal command-line argument. The binaries use the same string as the flag default. The values file did say to change it for production. But anyone who knows the seed can decrypt an observed session ID and encrypt a new one with whatever contents they choose, so an attacker didn't even need to steal a valid ID.
The reporters of the advisory are listed as aeonframework, PraveenKumarInjam and kanywst, with the fix by Ignasi Barrera (nacx).
What v1.2.0 changed
Both fixes landed on 28 September and shipped in v1.2.0-rc1 on 6 October and v1.2.0 on 7 October.
- Commit 0d1e730 (#2740) passes the current request's subject into
sessionFromIDand rejects the resume when it differs from the one in the ID. The mismatch returns HTTP 401. The subject comes from a header the gateway fills in from the verified JWT. - Commit 9945796 (#2755) stops the chart from shipping a fixed seed. With
seedleft empty, the new default, the chart generates a random 64-character seed, stores it in a Secret named after the controller with-mcp-session-encryptionappended, and reuses it on later upgrades. The controller now reads it from that Secret through an environment variable instead of a literal argument.
The commit message for #2755 spells out why both were needed: with a known seed, an attacker can rewrite a session ID so the subject check passes.
One limit is worth stating plainly. The check compares subjects, so it only separates callers who carry an authenticated subject. On a route with no JWT authentication, every caller's subject is empty, and an empty subject matches an empty subject. If a route serves more than one user, it needs authentication for this fix to mean anything.
Two ways the public seed survives an upgrade
Upgrading to 1.2.0 fixes the code. It doesn't guarantee your cluster stopped using the public seed.
1. The fallback seed from the upgrade guide. The 1.2.0 release notes warn that the new random seed ends active MCP sessions, and offer a way to keep them: set fallback.seed to default-insecure-seed for the upgrade, then remove it once clients have reconnected. That works because decryption tries the primary key first and then the fallback. It also means that while the fallback is set, session IDs made with the public seed still decrypt, including ones an attacker built. The subject check still applies, so the attacker would need a session ID issued before the upgrade to hijack an existing session. "Remove it once clients have reconnected" is the step that closes the gap, and it's the easiest one to forget. In the 1.2.0 chart the fallback is also still passed as a literal argument in the pod spec.
2. A seed set explicitly. The chart only generates a random seed when seed is empty. If your values file sets seed: default-insecure-seed, for example because it began as a copy of the old chart's values, the 1.2.0 chart writes that value into the new Secret and uses it. Installs that don't use the chart at all, such as hand-written manifests that omit --mcpSessionEncryptionSeed, get the binary's default, which in 1.2.0 is still default-insecure-seed. The external processor logs a warning when it starts with that value and MCP is enabled. It doesn't refuse to start.
There's a third, milder trap. If you deploy with helm template, as many GitOps tools do, the chart can't look up the Secret it created last time, so it generates a new seed on every render and every sync ends active sessions. The release notes recommend creating your own Secret and pointing existingSecret at it.
The rest of the 1.2.0 security fixes
GHSA-953h-535j-jg2v is one of ten repository advisories the project published on 7 October, all affecting 1.1.0 and earlier and all fixed in 1.2.0. Scores are as each repository advisory publishes them, and the numeric ones are CVSS 3.1.
| Advisory | What it fixes | Score |
|---|---|---|
| GHSA-953h-535j-jg2v | MCP session resume ignores the embedded subject; public default seed | high (repository), 6.8 CVSS 4.0 (GitHub) |
| GHSA-qcwp-vgvh-h3cm | CEL errors in MCP authorization skip Deny rules (fail open) | 7.5 |
| GHSA-q522-4f3c-h4g8 | MCP authorization rules installed without JWT verification when OAuth isn't configured | 6.5 |
| GHSA-p895-wr7h-mchv | BackendSecurityPolicy reads Secrets from other namespaces without a ReferenceGrant | 6.5 |
| GHSA-xwr3-h29g-7wqx | Cross-namespace backendRef honored without a ReferenceGrant | 6.5 |
| GHSA-7q4h-5hjr-g49v | Deleting a ReferenceGrant doesn't trigger a reconcile | 6.5 |
| GHSA-x8g6-w83h-p745 | Resource use from JSON Schema $ref expansion | 6.5 |
| GHSA-xgq4-8q3r-4gr4 | ReferenceGrant checks ignore to.name; revoked grants keep working | 6.4 |
| GHSA-w5rr-gc4v-5946 | Duplicate JSON keys in stream_options suppress token accounting | 5.3 |
| GHSA-r86v-rw5r-2cpj | Session encryption seed passed as a literal argument in the pod spec | 4.3 |
The first three matter most to anyone using MCP authorization. GHSA-q522-4f3c-h4g8 is the companion to the session bug: an MCPRoute with authorization rules but no securityPolicy.oauth read JWT claims without verifying the token. In 1.2.0, MCP CEL rules that reference request.auth.jwt now require securityPolicy.oauth, and CEL evaluation errors now deny. Both are listed as breaking changes, so routes that relied on the old behavior will start returning errors after the upgrade.
If your scanner reads GitHub's global advisory database or OSV, most of this list is invisible to it. On 19 October at 21:02 UTC, only 2 of the project's 18 repository advisories were in either: GHSA-953h-535j-jg2v and a May advisory fixed in 0.6.0. Matching on the Go module path or the envoyproxy/ai-gateway-controller image won't surface the other nine 1.2.0 fixes until they're reviewed.
Check your cluster
This script reads the JSON from kubectl get deployments,secrets -o json in the gateway's namespace and reports, for each controller container, whether the version is below 1.2.0, whether the session seed or fallback seed is the public default, and whether the seed is missing entirely. It compares seed values and never prints them. It uses only the Python standard library, and also reads helm template output if PyYAML is installed.
#!/usr/bin/env python3
"""Check an Agent Router (formerly Envoy AI Gateway) install for the MCP
session problems fixed in v1.2.0: controller version, a publicly known
session encryption seed, and a fallback seed left at the old default.
Usage:
kubectl get deployments,secrets -n envoy-ai-gateway-system -o json > gw.json
python3 mcp_seed_check.py gw.json
It also reads `helm template` output if PyYAML is installed. Seed values are
compared, never printed.
"""
import base64
import json
import re
import sys
KNOWN_SEED = "default-insecure-seed"
FIXED = (1, 2, 0)
SEED_FLAG = "mcpSessionEncryptionSeed"
FALLBACK_FLAG = "mcpFallbackSessionEncryptionSeed"
def load(path):
text = open(path).read()
try:
doc = json.loads(text)
docs = doc.get("items", [doc]) if isinstance(doc, dict) else doc
except json.JSONDecodeError:
import yaml # only needed for `helm template` output
docs = [d for d in yaml.safe_load_all(text) if d]
return [d for d in docs if isinstance(d, dict)]
def flag(args, name):
"""Return the flag's value, or None if the flag is absent."""
for i, a in enumerate(args):
m = re.fullmatch(r"--?" + name + r"(?:=(.*))?", a)
if m:
if m.group(1) is not None:
return m.group(1)
return args[i + 1] if i + 1 < len(args) else ""
return None
def version(image):
m = re.search(r":v?(\d+)\.(\d+)\.(\d+)", image or "")
return tuple(int(x) for x in m.groups()) if m else None
def secret_value(secrets, name, key):
data = secrets.get(name, {}).get(key)
return base64.b64decode(data).decode() if data else None
def resolve(value, env, secrets):
"""Expand $(VAR) from an env entry backed by a Secret."""
m = re.fullmatch(r"\$\((\w+)\)", value or "")
if not m:
return value, "literal argument"
ref = (env.get(m.group(1)) or {}).get("valueFrom", {}).get("secretKeyRef")
if not ref:
return None, f"env var {m.group(1)} (not from a Secret)"
v = secret_value(secrets, ref["name"], ref.get("key", ""))
where = f"Secret {ref['name']}"
return v, where if v is not None else where + " (not in input; check it separately)"
def check(docs):
secrets = {d["metadata"]["name"]: d.get("data") or {} for d in docs if d.get("kind") == "Secret"}
found = 0
for d in docs:
if d.get("kind") != "Deployment":
continue
for c in d["spec"]["template"]["spec"].get("containers", []):
if "ai-gateway-controller" not in c.get("image", ""):
continue
found += 1
args, env = c.get("args", []), {e["name"]: e for e in c.get("env", [])}
name = f"{d['metadata']['name']}/{c['name']}"
v = version(c.get("image"))
print(f"{name} image {c.get('image')}")
if v is None:
print(" ? can't read a version from the image tag; confirm it is v1.2.0 or later")
elif v < FIXED:
print(" FAIL controller is older than v1.2.0: session resume ignores the caller's subject")
else:
print(" ok v1.2.0 or later: session resume checks the caller's subject")
raw = flag(args, SEED_FLAG)
if raw is None:
print(f" FAIL no --{SEED_FLAG}: the binary falls back to the public default")
else:
seed, where = resolve(raw, env, secrets)
if seed == KNOWN_SEED:
print(f" FAIL session seed is the public default ({where})")
elif seed is None:
print(f" ? session seed comes from {where}")
else:
print(f" ok session seed is not the public default ({where})")
if where == "literal argument" and seed != KNOWN_SEED:
print(" warn seed is a literal argument, visible to anyone who can read the pod spec")
fb = flag(args, FALLBACK_FLAG)
if fb is not None:
fseed, _ = resolve(fb, env, secrets)
if fseed == KNOWN_SEED:
print(" FAIL fallback seed is the public default: session IDs made with it still decrypt."
" Remove it once clients have reconnected")
else:
print(" warn a fallback seed is set; remove it after the rotation window")
if not found:
print("no ai-gateway-controller container found in the input")
return found
if __name__ == "__main__":
for path in sys.argv[1:]:
print(f"== {path}")
check(load(path))
We ran it against six inputs. Five were rendered with helm template from the chart source at the v1.1.0 and v1.2.0 tags: the 1.1.0 defaults, the 1.2.0 defaults, 1.2.0 with the fallback from the upgrade guide, 1.2.0 with seed: default-insecure-seed in the values, and 1.2.0 with existingSecret. The sixth is a 1.2.0 deployment with the seed flag removed, standing in for a hand-written install. Nothing was deployed.
== a-v110-default.json
ai-gateway-controller/ai-gateway-helm image docker.io/envoyproxy/ai-gateway-controller:v1.1.0
FAIL controller is older than v1.2.0: session resume ignores the caller's subject
FAIL session seed is the public default (literal argument)
== b-v120-default.json
ai-gateway-controller/ai-gateway-helm image docker.io/envoyproxy/ai-gateway-controller:v1.2.0
ok v1.2.0 or later: session resume checks the caller's subject
ok session seed is not the public default (Secret ai-gateway-controller-mcp-session-encryption)
== c-v120-fallback.json
ai-gateway-controller/ai-gateway-helm image docker.io/envoyproxy/ai-gateway-controller:v1.2.0
ok v1.2.0 or later: session resume checks the caller's subject
ok session seed is not the public default (Secret ai-gateway-controller-mcp-session-encryption)
FAIL fallback seed is the public default: session IDs made with it still decrypt. Remove it once clients have reconnected
== d-v120-oldvalues.json
ai-gateway-controller/ai-gateway-helm image docker.io/envoyproxy/ai-gateway-controller:v1.2.0
ok v1.2.0 or later: session resume checks the caller's subject
FAIL session seed is the public default (Secret ai-gateway-controller-mcp-session-encryption)
== e-v120-existing.json
ai-gateway-controller/ai-gateway-helm image docker.io/envoyproxy/ai-gateway-controller:v1.2.0
ok v1.2.0 or later: session resume checks the caller's subject
? session seed comes from Secret mcp-session-seed (not in input; check it separately)
== f-v120-kustomize-noflag.json
ai-gateway-controller/ai-gateway-helm image docker.io/envoyproxy/ai-gateway-controller:v1.2.0
ok v1.2.0 or later: session resume checks the caller's subject
FAIL no --mcpSessionEncryptionSeed: the binary falls back to the public default
The third and fourth results are the point of the script. Both clusters run the fixed version and would pass a version check.
Reading Secrets needs the right RBAC, so run it as someone who already administers the gateway. If you pin images by digest, the version line will show a question mark, and you'll need to confirm the version another way.
What to do
- Upgrade to 1.2.0 or later, after reading its breaking changes. The issuer check, the CEL fail-closed change and the OAuth requirement for JWT-based CEL can each turn working routes into 401s or 403s.
- Leave
seedempty or useexistingSecretwith a value from a secure random source. Never set it todefault-insecure-seed, and search your values files and overlays for that string. - Remove a fallback seed of
default-insecure-seedas soon as clients have reconnected. Put a date on it, because nothing in the gateway will remind you. - Authenticate every MCP route that serves more than one user. The subject check can't separate callers who have no subject.
- Track the repository's advisories directly while the global database catches up. Subscribing to the repository's security advisories is enough.
The fair view
The maintainers did the right things quickly. The fix commits came with an explicit write-up of why both halves were needed, the release notes call out the seed change as breaking, and the upgrade guide explains the trade-off rather than hiding it. Offering a fallback so live sessions survive an upgrade is a reasonable choice for a gateway that people run in production.
Upgrade guides are written for availability, though, and this one asks operators to keep a public key in service for a while. That's safe only if someone comes back to remove it. The same pattern shows up wherever a fix depends on configuration as well as code: the version number says fixed, and the cluster is in whatever state its values file left it.
If you put a gateway or guardrail in front of MCP servers, this is the kind of policy you'd want it to enforce per user and per tool. Here's Safeguard's interactive walkthrough of an MCP guardrail:
For the broader principle, see our posts on why an MCP session ID is not authentication and on securing MCP servers in the enterprise.
Check yourself
You upgraded to 1.2.0 and set fallback.seed to default-insecure-seed, as the upgrade guide suggests. Is the session bug closed?
The code bug is closed: resumes now check the caller's subject. But while the fallback is set, the gateway still decrypts session IDs made with the public seed, so sessions issued before the upgrade stay exposed. Remove the fallback once clients have reconnected.
Your MCP route has no securityPolicy.oauth. Does the 1.2.0 subject check stop one client from resuming another client's session?
No. Without authentication, every caller's subject is empty, so the comparison always matches. The check only separates authenticated users. Add OAuth to any route that serves more than one user.
Your dependency scanner shows no advisories for the gateway on 1.1.0 apart from this one. Does that mean 1.1.0 is otherwise clean?
No. On 19 October only 2 of the project's 18 repository advisories were in GitHub's global database or OSV, so nine other issues fixed in 1.2.0 weren't visible to tools that read those sources. Check the repository's security advisories page.