Your Agent Platform Fails Open: No Identity Provider Should Mean No Access
When AWS Loom shipped with no identity provider configured, any network client became an admin. The bug is specific. The pattern is probably in your code too. Here is a configuration state you have probably shipped: the
When AWS Loom shipped with no identity provider configured, any network client became an admin. The bug is specific. The pattern is probably in your code too.
Here is a configuration state you have probably shipped: the auth integration is optional, the deployment guide lists it as a step you can finish later, and the service starts happily without it. Nothing crashes. Nothing warns. The health check returns green, the demo works, and the one control that was supposed to decide who gets in has quietly decided that everyone does.
That is not a hypothetical. On October 2, 2026, AWS published Security Bulletin 2026-124 covering three issues in Loom, its open source AI agent orchestration platform. The first, CVE-2026-103956, is the one worth sitting with. In versions before 1.6.1, a deployment with no identity provider configured let any network client obtain full administrative authority over the agent control plane through any request to the application API. Full administrative authority meant registering tool servers, reading stored integration credentials, and rewriting the IAM role policies attached to managed agent roles. No token. No password. No exploit chain. The absence of a configuration was itself the vulnerability.
The other two issues complete the picture. CVE-2026-103957 let an authenticated user point an OAuth2 discovery URL at an attacker endpoint and receive client secrets or another user's access token. CVE-2026-103958 let an authenticated user direct tool server and remote agent connection requests at arbitrary internal addresses, including the container credential endpoint, and read the responses. AWS fixed the set in Loom 1.7.0 and advised operators to rotate OAuth2 client secrets, revoke active access tokens, and review CloudTrail if container role credentials may have been reached.
Most write-ups of this bulletin will stop at "upgrade to 1.7.0," and for Loom operators that is the correct first move. The part every explainer skips is the pattern underneath. Loom is one codebase. Fail-open authentication, user-supplied URLs fetched without an allowlist, and a local development escape hatch that survives into production are patterns, and patterns replicate into codebases that have never heard of Loom. This piece names the three patterns precisely, shows each one reduced to runnable code, and gives you the checks that close them in an afternoon.
Name the Failure Precisely: Missing Auth Is Not a Default
The CVE record tags CVE-2026-103956 with CWE-306 (missing authentication for a critical function) and CWE-1188 (insecure default initialization of a resource). The second tag is the honest one. The bug was not a broken login page or a bypassable token check. The initialization default was wrong: when the identity provider was absent, the system initialized into its most permissive state instead of its least permissive one.
Think about why that default exists, because your codebase has the same pressure behind it. Authentication is friction during development. A new engineer cloning the repo wants the control plane to start, the demo wants to run in a sales call, and the integration test wants to skip the token dance. So someone makes auth conditional. The conditional is reasonable on a laptop. The problem is that nothing in the code distinguishes the laptop from the deployment, and the conditional travels with the artifact. AWS's own workaround names the escape hatch directly: confirm that the LOOM_ALLOW_UNAUTHENTICATED_LOCAL_DEV environment variable is unset in any deployed environment. A flag whose entire purpose is local convenience became a production setting worth a line in a security bulletin.
A control that turns itself off when it is not configured is not a control with a configuration option. It is two different systems sharing one binary, and you do not get to choose which one your deploy runs.
The rule that prevents this is short enough to put in a code review checklist: absence of configuration is a stop condition, never a permission. If the identity provider is not configured, the service refuses to start, or it serves nothing beyond loopback with the limitation printed in the startup log. It never, under any combination of flags, serves administrative endpoints to unauthenticated network callers.
Watch the Bug Happen, Then Close It
Let me give you an example. This is the vulnerable shape and the fixed shape side by side, in one stdlib-only Python file. It is a reduction, not a reimplementation of Loom, but every line corresponds to a decision in the real incident.
# fail_closed.py -- stdlib only: the fail-open bug, and the fixed version
import ipaddress
from urllib.parse import urlparse
ADMIN_ACTIONS = {"register_tool_server", "read_credentials", "rewrite_iam_policy"}
class Config:
def __init__(self, idp_issuer=None, allow_unauth_local_dev=False, bind="0.0.0.0"):
self.idp_issuer = idp_issuer # None means: nobody configured auth
self.allow_unauth_local_dev = allow_unauth_local_dev
self.bind = bind
# ---------- the vulnerable version, reduced to its shape ----------
def vulnerable_authorize(cfg, token):
if cfg.idp_issuer is None:
return {"principal": "anonymous", "scopes": ["admin"]} # auth skipped entirely
if token is None:
return None
return {"principal": "user", "scopes": ["read"]}
# ---------- the fixed version ----------
def fixed_startup_check(cfg):
local_dev = cfg.allow_unauth_local_dev and cfg.bind in ("127.0.0.1", "::1")
if cfg.idp_issuer is None and not local_dev:
raise SystemExit("refusing to start: no identity provider configured")
def fixed_authorize(cfg, token, valid_tokens):
if token is None or token not in valid_tokens:
return None # no token, no principal
return valid_tokens[token]
def check_outbound_url(url, allowed_hosts):
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValueError("outbound URL must be https")
host = parsed.hostname or ""
try:
ip = ipaddress.ip_address(host)
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError(f"outbound URL points at a non-public address: {host}")
except ValueError as e:
if "points at" in str(e):
raise
if host not in allowed_hosts:
raise ValueError(f"outbound host is not on the allowlist: {host}")
return url
Run the scenario at the bottom of the file and you get this:
vulnerable, no token -> {'principal': 'anonymous', 'scopes': ['admin']}
vulnerable, can register a tool server: True
fixed, same config -> startup refused: refusing to start: no identity provider configured
fixed, no token -> None
fixed, reader token -> {'principal': 'anusha', 'scopes': ['read']}
outbound blocked: https://169.254.169.254/latest/meta-data/ - outbound URL points at a non-public address: 169.254.169.254
outbound blocked: https://attacker.example/oauth-discovery - outbound host is not on the allowlist: attacker.example
outbound allowed: https://mcp.example.com/mcp
A few things are worth noting about this example. First, the vulnerable function does not look malicious. It looks like a convenience branch someone added in week two, which is exactly how it gets past review. Second, the fix is not a better branch in authorize. The fix moves the decision to startup: a misconfigured deployment fails loudly at boot, in front of the operator, instead of silently at request time, in front of an attacker. Third, notice what the fixed authorize returns for a valid reader token: read scope only. The anonymous principal in the vulnerable version received admin, which means the fail-open path was not merely unauthenticated, it was maximally privileged. When you audit your own code, that combination is the one to hunt: no-auth paths that land on broad scopes because the branch was written for a developer who needed everything to work.
Treat Every URL the User Hands You as a Loaded Request
The second and third Loom issues share one mechanism: the server fetched a URL a user supplied, and the response or the connection itself became the payload. A discovery document told the backend where to send secrets. A tool server connection request reached the container credential endpoint at 169.254.169.254 and read what came back. This is server-side request forgery wearing agent clothing, and agent platforms invite it constantly, because connecting to user-specified tool servers and remote agents is the product.
The pipeline in a typical agent platform looks like this:
user configures a URL --> server fetches it --> response is parsed as trusted config
| | |
discovery doc, no allowlist, secrets sent to the
MCP server, private IPs endpoint named in
remote agent reachable the document
Each stage is a place to intervene, and you need at least two of them because each one fails differently. At configuration time, validate the URL against an allowlist of hosts and require https. At fetch time, resolve the host and refuse private, loopback, and link-local addresses, then re-check after any redirect, because a public URL that redirects to 169.254.169.254 is the oldest version of this trick. At response time, treat the fetched document as untrusted input: a discovery document may name endpoints, but your client secrets go only to endpoints on the allowlist, never to an address the document itself supplied. The check_outbound_url function above covers the first two stages in miniature. It will not stop a DNS rebinding attack on its own, which is why the allowlist, not the IP check, is the load-bearing control.
While you are in there, run the audit commands against your own deployment before someone else does:
# Is any unauthenticated mode enabled in the deployed environment?
env | grep -i "ALLOW_UNAUTHENTICATED\|AUTH_DISABLED\|DEV_MODE"
grep -rn "allow_unauthenticated\|auth.*optional\|skip_auth" --include="*.py" --include="*.ts" .
# Does the control plane answer an unauthenticated request? It should say 401.
curl -s -o /dev/null -w "%{http_code}\n" https://your-control-plane.example/api/admin/tool-servers
# If you run Loom itself: upgrade, then rotate what the bulletin names.
# Loom 1.7.0 fixes all three issues; 1.6.1 fixed only CVE-2026-103956.
That single curl is the cheapest security test in this entire piece. Add it to CI as an integration assertion: boot the service with production-like configuration, send one unauthenticated request to one administrative endpoint, and fail the build if the answer is anything other than 401 or 403. A fail-open regression then becomes a red build instead of a CVE.
Where This Breaks
- Startup refusal can lock you out. If your identity provider is down and your control plane refuses to start without it, an IdP outage becomes a platform outage. Accept the trade consciously: a break-glass procedure with a named owner, a time limit, and an audit record is the answer, not a silent unauthenticated mode.
- The IP check is not sufficient. Hostnames resolve, redirects move, and DNS answers change between check and fetch. The allowlist is the primary control; the address check catches the lazy version of the attack.
- Allowlist maintenance is real work. Every legitimate new tool server is a change request. If that friction is too high, operators will demand a wildcard, and the wildcard quietly restores the original bug.
- Scoped writers caused two of the three CVEs. CVE-2026-103957 and CVE-2026-103958 needed an authenticated user with write scopes. Fail-closed auth would not have saved a deployment from those. Outbound validation and least-privilege scoping are separate controls, and you need them separately.
- Forks do not inherit fixes. AWS advised anyone with forked or derivative Loom code to patch it themselves. The same applies to every internal copy of an auth middleware you have ever vendored.
Build It If, Skip It If
Build the full version if you operate any control plane that brokers credentials, tool registrations, or role policies for agents, especially one reachable beyond a single developer laptop. Startup refusal, an unauthenticated-request test in CI, outbound allowlisting, and a documented break-glass path are the complete set, and none of them is a platform project.
Skip the elaborate version if your agent tooling is a single-process local script with no network listener and no stored credentials. Your risk there is different, and a weekend spent allowlisting URLs for a process that never fetches any is a weekend misspent.
The minimal viable version fits in an afternoon: one startup assertion that refuses to boot without an identity provider outside loopback, one CI test that asserts an unauthenticated administrative request gets a 401, and one grep of your deployed environment for any flag with UNAUTHENTICATED in its name. Three small artifacts, each of which would have changed the Loom story from a bulletin to a failed deploy.
Start with the curl. Run it against the control plane you operate today, read the status code out loud, and fix the deployment from what the number says rather than from what the configuration file promised you. What does your own control plane answer when a request arrives with no token at all?
Resources
- AWS Security Bulletin 2026-124: the Loom issues, affected versions, and rotation guidance
- CVE-2026-103956 record: authentication bypass when no identity provider is configured
- CVE-2026-103958 record: outbound request handling in tool server and remote agent connections
- Your agent should not borrow your API key: scoped, expiring credentials per agent
- Your agent took an action you did not intend: policy gates at the tool boundary
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.