Guide · Backend
Loom Auth Bypass Explained (CVE-2026-103956): Fail-Closed Login and SSRF Fixes
21 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The Loom auth bypass, CVE-2026-103956, hit Loom, an open-source platform for running AI agents. A CVE is a public ID for one security bug. Before 1.6.1, with no identity provider (IdP, the login service) set up, Loom made every caller a super-admin, the top role that can do everything.
- It is rated 10.0 out of 10 on CVSS, the standard severity score, because anyone who could reach the server took full control without a password. Version 1.6.1 fixed it on 4 August 2026.
- Two more bugs, CVE-2026-103957 and CVE-2026-103958, are server-side request forgery (SSRF): tricking a server into fetching a web address you chose. Logged-in users allowed to register tool or agent connections could leak secrets or cloud credentials. Version 1.7.0 fixed both.
- The bulletin came out on 2 October 2026 and asks for at least 1.7.0. Before 1.8.4, anyone who could reach Loom could still run agents through its chat WebSocket (a live two-way connection) without logging in. So upgrade to 1.9.0, check that a request with no login token gets 401 (not logged in), then rotate (replace) client secrets, tokens and cloud role credentials.
- Beginners can stop after the fail-open vs fail-closed section. Engineers then get what Loom's source shows: a database error that also failed open and an empty mapping table that trusted the IdP. They also get how a second copy of the login check drifted, and a tested outbound URL guard.
What goes wrong when an agent platform has no identity provider set?
It can let everyone in as the most powerful user. That is what the Loom auth bypass, CVE-2026-103956, did in Loom before version 1.6.1. With no login service configured, every request got full admin rights, even with no token at all.
Loom is an open-source platform for building, deploying and managing AI agents through a web console and an API. An identity provider (IdP) is the login service that proves who you are. A token is the signed pass it gives you after you log in.
Picture a common setup. A houseboat-booking team in Kochi runs Loom with two agents: one answers booking questions, the other issues refunds of up to ₹25,000 through a payments tool. The setup is made up, but the bug is real.
In July the team put a staging copy of Loom 1.6.0 behind a load balancer for a partner demo. They planned to add the IdP the next sprint. Until then, anyone on the internet was an administrator.
Which words do you need before reading about CVE-2026-103956?
These terms come back throughout the guide.
- Super-admin: Loom's top role, an admin-type user (group t-admin) who is also in the group g-admins-super, which holds every scope.
- Scope: a named permission, such as mcp:write, that lets a user change one kind of resource.
- MCP server: a tool server that speaks the Model Context Protocol, an open standard for connecting agents to tools.
- A2A agent: a remote agent reached over the Agent2Agent protocol, an open standard for agents calling other agents.
- OAuth2 and client secret: OAuth2 is the standard way programs get access tokens. A client secret is a password a program uses to get one.
- Token endpoint: the web address where a program swaps its client secret for an access token.
- Loopback address: 127.0.0.1 or ::1, addresses that only the same machine can reach.
What is the Loom auth bypass, CVE-2026-103956?
It is a missing-authentication bug in Loom's login check, the code that runs before every API call. Loom accepts two kinds of IdP: a Cognito user pool (Amazon's hosted user directory) or an external IdP such as an OpenID Connect provider.
When neither was configured, the check returned a fixed super-admin identity with every scope. It did so for every request, even one with no Authorization header, the part of a request that carries the token.
Anyone who could reach the backend could then read, change or delete agents, memories, credentials and settings, and run any agent. The bulletin adds registering tool servers and rewriting the IAM role policies (cloud permission rules) of managed agent roles.
The advisory notes that a freshly deployed instance is affected until an operator finishes IdP setup. The team's staging copy was exactly that state.
One request, no identity provider: before and after 1.6.1
Drag to exploreFollow the No arrows. Before 1.6.1 a missing IdP meant super-admin; from 1.6.1 it means 401, unless a developer opted in and the caller is on the same machine.
Diagram connections:
- Incoming API request to Is an IdP configured?
- Is an IdP configured? to Verify the token (Yes)
- Is an IdP configured? to Before 1.6.1: super-admin (No, before 1.6.1)
- Is an IdP configured? to 1.6.1: opt-in and loopback? (No, 1.6.1 and later)
- 1.6.1: opt-in and loopback? to Local dev admin (Both true)
- 1.6.1: opt-in and loopback? to Reject with 401 (Anything else)
- Incoming API request: Any caller, token or not
- Is an IdP configured?: Cognito user pool or an active external IdP
- Verify the token: Normal path: scopes come from the IdP
- Before 1.6.1: super-admin: Fixed local-dev admin with every scope
- 1.6.1: opt-in and loopback?: Local-dev flag plus a loopback caller
- Local dev admin: Only for callers on the same machine
- Reject with 401: Fail closed: no identity, no access
What is the difference between fail-open and fail-closed authentication?
A fail-open check lets the request through when something it needs is missing or broken. A fail-closed check refuses it. Loom's old login check failed open: no IdP meant no checking at all.
The fix in 1.6.1 made the missing-IdP case fail closed with a 401 (the HTTP code for not logged in). The shortcut survives only for local development, behind the LOOM_ALLOW_UNAUTHENTICATED_LOCAL_DEV flag and a loopback caller.
ADMIN = {"user": "local-dev", "groups": ["t-admin", "g-admins-super"]}
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
def current_user_fail_open(token, idp):
if idp is None: # no login service set up yet
return ADMIN # so every caller becomes admin
return idp.verify(token)
def current_user_fail_closed(token, idp, local_dev, client_ip):
if idp is None:
if local_dev and client_ip in LOOPBACK:
return ADMIN # only callers on this same machine
raise PermissionError("401: no identity provider configured")
if not token:
raise PermissionError("401: missing token")
return idp.verify(token) # checks signature, expiry, scopesThis is a simplified sketch, not Loom's code. Loom's real check lives in backend/app/dependencies/auth.py and accepts the same three loopback names. Called with no IdP and a remote caller, the fail-closed version raises the 401 even when the flag is on. Never set the flag on a machine where a tunnel, a program that relays outside traffic in, forwards requests to Loom: those requests arrive from 127.0.0.1 too.
| Situation | Fail-open result (version) | Fail-closed result (version) |
|---|---|---|
| No IdP set up yet | Every caller becomes super-admin (before 1.6.1) | Every remote caller gets 401 (1.6.1 and later) |
| No Cognito pool, and loading the external IdP hits a database error | Error read as no IdP, so super-admin (before 1.6.1) | Same path now gives remote callers 401 (1.6.1 and later) |
| External IdP with an empty group mapping table | IdP group names used as Loom group names (before 1.8.2) | User logs in with no scopes (1.8.2 and later) |
How did the two Loom SSRF bugs leak secrets and cloud credentials?
Both made Loom's backend send requests to places an attacker chose. Each needed a logged-in user holding the mcp:write or a2a:write scope. These are delegated admin scopes, below super-admin.
Picture a hotel receptionist who fetches parcels from any room you name. The receptionist is Loom's backend, the room number is the URL, and the master key on their belt is the cloud credential.
If nobody checks the room number, you can send them into the manager's office and have them read out what they find. Where the picture breaks: a receptionist might get suspicious, but a server fetches whatever it is told, thousands of times a day.
Discovery URLs that sent secrets away (CVE-2026-103957)
When you register an MCP server or A2A agent that logs in with OAuth2, you give Loom a discovery URL. The file at that URL names the token endpoint. Before 1.7.0, an attacker could serve a file naming their own server as the token endpoint.
Loom would then send that server the integration's client secret. In on-behalf-of mode, where an agent acts for a signed-in user, it sent another user's real access token instead.
Pause and predict: 1.6.1 already blocked internal addresses on this path. Would that stop an attacker whose server is public and uses HTTPS?
It did not. Version 1.6.1 blocked internal and cloud metadata addresses on this path, and required HTTPS. It still trusted any public HTTPS host. Version 1.7.0 added the missing check: the token endpoint's host must be a login server the deployment already trusts.
In Loom's source that trusted list holds the platform's own IdPs and the authorizers a security:write admin has registered. Holding mcp:write alone can no longer add a host to it.
How a discovery URL leaked a client secret before 1.7.0
Drag to exploreEach box is one message. Notice that no step asks who owns the token endpoint; 1.7.0 adds that check before the token request.
Diagram connections:
- Register MCP server to Fetch discovery file
- Fetch discovery file to Fake discovery reply
- Fake discovery reply to Token request
- Token request to Attacker keeps secret
- Register MCP server: Attacker → Loom: discovery URL on own host
- Fetch discovery file: Loom → attacker server: GET the file
- Fake discovery reply: Attacker server → Loom: token endpoint is mine
- Token request: Loom → attacker server: secret or user token
- Attacker keeps secret: Can now call the partner API as the team
Connection URLs that reached the credential endpoint (CVE-2026-103958)
The second bug sat in the code that registers, updates or tests a connection to an MCP server or A2A agent. Before 1.7.0, Loom did not check the IP address a hostname resolved to, and did not re-check redirect targets.
So a crafted URL could reach internal addresses, including the container's credential-vending endpoint. That is a local address that hands out temporary cloud credentials for the app's role. Through a redirect, the response body could also reach the attacker.
Which Loom versions are affected, and how severe is each CVE?
The auth bypass affects Loom before 1.6.1. Both SSRF bugs affect Loom before 1.7.0. CVSS scores run from 0 to 10, and 3.1 and 4.0 are two versions of its formula. CWE numbers are standard labels for kinds of weakness.
The GitHub advisories show the CVSS 3.1 scores. NVD lists 3.1 and 4.0 scores supplied by the CVE issuer. The CWE labels come from the bulletin and NVD, because the GitHub advisories list none.
| CVE | What it is | CVSS 3.1 | CVSS 4.0 | Fixed in |
|---|---|---|---|---|
| CVE-2026-103956 | Missing authentication when no IdP is set (CWE-306, CWE-1188) | 10.0 critical | 10.0 critical | 1.6.1 |
| CVE-2026-103957 | SSRF in OAuth2 discovery that leaks secrets or tokens (CWE-918, CWE-201) | 6.2 medium | 8.2 high | 1.7.0 |
| CVE-2026-103958 | SSRF in MCP and A2A connection handling (CWE-918) | 7.6 high | 8.3 high | 1.7.0 |
The auth bypass scores 10.0 because it needs no login, no help from a user and little effort, and it gives full control. The SSRF bugs need an attacker who already holds a write scope. CVE-2026-103957 is also scored as needing a user's action, which lowers its 3.1 score from 6.8 to 6.2.
The team had two problems. Staging ran 1.6.0 with no IdP, so CVE-2026-103956 applied. Production had an IdP but ran 1.6.1, so both SSRF bugs still applied there. Its chat WebSocket also took prompts from anyone who could reach it.
What did Loom change in versions 1.8 and 1.9 in October 2026?
After the bulletin, the maintainers shipped more fail-closed fixes in quick succession. Many stop one group of users from reaching another group's resources. These are the ones that matter for this guide, from the release notes.
- 1.8.1 (6 October): Loom refuses to start when the local-dev flag is set and it sees container-runtime variables such as ECS_CONTAINER_METADATA_URI_V4 or AWS_EXECUTION_ENV. It also refuses when FORWARDED_ALLOW_IPS trusts proxies beyond loopback.
- 1.8.1 also refuses the local-dev shortcut for any request that carries a proxy forwarding header, such as X-Forwarded-For or X-Real-IP.
- 1.8.2 (6 October): external IdP group mapping fails closed. An empty mapping table no longer passes the IdP's group names into Loom. Cognito deployments are unaffected.
- 1.8.4 (6 October): the agent invoke WebSocket, a long-lived connection the console uses to chat with an agent, now needs a valid login, the invoke scope and a group check.
- 1.8.5 (7 October): resources without a loom:group tag, the label naming the owning group, are visible only to super-admins, and new resources must carry one.
- 1.9.0 (7 October): closes seven externally reported cross-group authorization issues, requires HTTPS for IdP discovery and blocks all of 0.0.0.0/8 and the IPv6 metadata address. Loom no longer creates or changes IAM roles.
A correction dated 7 October says untagged agents stayed invokable in 1.8.5 and 1.8.6 by any caller with the invoke scope, through a separate check on the invoke path. It names 1.8.7 as the fix. As of 19 October 2026 there is no 1.8.7 tag, but the 1.9.0 code has the fixed check.
Why the proxy rule? Loom's loopback test trusts the client address that uvicorn, its web server, reports. Uvicorn rewrites that address from X-Forwarded-For when FORWARDED_ALLOW_IPS trusts the sender. Widen that setting behind a load balancer, and a remote caller could pass as 127.0.0.1.
Uvicorn 0.54.0 trusts those headers only from 127.0.0.1 and ::1 by default, so the risk appears only after someone widens the setting.
Why did fail-open bugs keep turning up in Loom's code?
Because the same decision lived in more than one place, and missing input was treated as permission. The release notes name each fix. The code details below come from Loom's source at the release tags; they are undocumented as of 19 October 2026, so pin your version.
A database error looked like no IdP
Before 1.6.1, the function that loads the active external IdP caught any database error and returned nothing. With no Cognito pool configured, a failed lookup therefore took the same super-admin path as a missing IdP. Loom caches that lookup for 60 seconds, so one failure kept the door open for up to a minute.
That matches the advisory's note that the bug also applied if the IdP configuration became unreachable. From 1.6.1, that path gives remote callers 401.
An empty mapping table trusted the IdP
Before 1.8.2, an external IdP with no group mappings had its group names used directly as Loom group names. The 1.9.0 code comment explains the risk in the maintainers' words.
A user holding only security:write could register an IdP of their own. Its tokens could then claim g-admins-super and log in as full super-admin. From 1.8.2 an empty table means no groups and no scopes.
A second copy of a check drifted
Up to 1.8.3, the agent invoke WebSocket accepted connections with no login check at all, while the HTTP invoke route beside it required one. In 1.8.5, the invoke route kept its own copy of the group check, which skipped untagged agents.
Both fixes swapped the copy for shared code. In 1.9.0 the WebSocket checks the token through the same authenticate_bearer_token function as HTTP, and both invoke paths call check_resource_group_access.
How do you guard outbound URLs against SSRF?
Check every user-supplied URL right before you connect, and again for every redirect. Loom 1.9.0 does this in backend/app/services/net_guard.py, with two levels of strictness for two kinds of destination.
| Rule | OAuth2 discovery and token calls | MCP and A2A connections |
|---|---|---|
| Schemes | HTTPS only | HTTP or HTTPS |
| Private addresses such as 10.x and 192.168.x | Blocked | Allowed, for servers inside your network |
| Loopback, link-local, metadata, 0.0.0.0/8 | Blocked | Blocked |
| Redirects | Not followed | Each hop checked again, at most 5 |
| Where secrets may go | Only hosts of trusted IdPs and authorizers | No secret-sending check at this level |
Pinning matters because of DNS rebinding. That trick makes a hostname resolve to a safe public IP during your check, then to an internal IP when you connect. Loom resolves once, rejects the name if any resolved address is disallowed, then connects to the checked IP.
This sketch follows the same rules in about 30 lines. It is ours, not Loom's code, and it leaves out the connection and redirect loop.
import ipaddress
import socket
from urllib.parse import urlparse
THIS_NETWORK = ipaddress.ip_network("0.0.0.0/8")
METADATA_V6 = ipaddress.ip_address("fd00:ec2::254")
def never_allowed(ip):
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped # ::ffff:127.0.0.1 is loopback too
if ip.version == 4 and ip in THIS_NETWORK:
return True
return (ip == METADATA_V6 or ip.is_loopback or ip.is_link_local
or ip.is_multicast or ip.is_reserved or ip.is_unspecified)
def check_outbound(url, sends_secret=False, trusted_hosts=frozenset()):
parts = urlparse(url)
if not parts.hostname:
raise ValueError("no host in URL")
if sends_secret:
if parts.scheme != "https":
raise ValueError("secrets travel over https only")
if parts.hostname.lower() not in trusted_hosts:
raise ValueError("secrets go only to our own login servers")
elif parts.scheme not in ("http", "https"):
raise ValueError("only http and https are allowed")
ips = {ipaddress.ip_address(info[4][0])
for info in socket.getaddrinfo(parts.hostname, None)}
for ip in ips:
if never_allowed(ip) or (sends_secret and ip.is_private):
raise ValueError(f"blocked address {ip}")
return str(next(iter(ips))) # connect to this IP; check every redirect againWhat just happened? On Python 3.13.16, a URL on 10.0.4.20 passes, because private addresses are allowed for tool servers. Addresses 0.0.0.1, 169.254.170.2, localhost and fd00:ec2::254 are all refused.
Two details explain the extra lines. Python's is_unspecified flag is true only for 0.0.0.0, not the rest of 0.0.0.0/8. And fd00:ec2::254, the IPv6 metadata address, counts only as private, so a guard that allows private addresses must name it.
A guarded outbound fetch, step by step
Drag to exploreNotice the split at Carries a secret: only that branch must hit the trusted list. Calls that carry a secret never follow redirects; connection calls re-check every hop, at most 5.
Diagram connections:
- User-supplied URL to Carries a secret?
- Carries a secret? to HTTPS and trusted host (Yes)
- Carries a secret? to Resolve and check IPs (No)
- HTTPS and trusted host to Resolve and check IPs (Host is trusted)
- Resolve and check IPs to Connect to pinned IP (Every address allowed)
- Connect to pinned IP to Redirect returned?
- Redirect returned? to Resolve and check IPs (Yes, check the new URL)
- User-supplied URL: MCP endpoint, A2A base URL or discovery URL
- Carries a secret?: Client secret or a user's access token
- HTTPS and trusted host: Only the deployment's own login servers
- Resolve and check IPs: Block loopback and metadata; private too if secret
- Connect to pinned IP: Same IP as checked, so no DNS rebinding
- Redirect returned?: Connection calls only; at most 5 hops
What should Loom operators do after CVE-2026-103956?
Upgrade first, prove the fix is live, then rotate secrets as if they leaked. The bulletin and advisories give most of these steps; the two checks are ours.
Cut off exposed instances
Restrict network or security-group access to any backend without a working IdP. The team took staging offline within an hour (made up).
Upgrade to 1.9.0
1.7.0 closes the three CVEs, but before 1.8.4 the agent WebSocket ran agents for callers with no login. As of 19 October 2026 the newest release is 1.9.0. Read its breaking changes first.
Check the version and the 401
Run the two commands below against your backend's address. The first must show 1.8.4 or later; the second must print 401.
Check the local-dev flag
Make sure LOOM_ALLOW_UNAUTHENTICATED_LOCAL_DEV is unset everywhere except a developer's machine. From 1.8.1, Loom refuses to start if the flag is set where ECS container variables are present.
Set up the IdP before exposing Loom
Configure a Cognito user pool or an external IdP before the backend is reachable beyond loopback. For an external IdP, fill in group_mappings too.
Trim write scopes
Give mcp:write and a2a:write only to trusted administrators. The team cut the holders from nine people to three (made up).
Rotate secrets and tokens
Rotate OAuth2 client secrets for MCP and A2A integrations. Revoke and reissue access tokens that were active during the affected period.
Rotate role credentials and read logs
If container role credentials may have leaked, rotate the role's session credentials and review CloudTrail, the cloud's audit log, for unexpected use.
# 1. Which version is running? The root path needs no login.
curl -s https://LOOM_BACKEND/
# expected on 1.9.0:
# {"service":"Loom Backend API","version":"1.9.0","status":"running"}
# 2. Does a call with no token get refused? Expect 401.
curl -s -o /dev/null -w "%{http_code}\n" https://LOOM_BACKEND/api/agents
# expected: 401Replace LOOM_BACKEND with your backend's address. Both commands were run against Loom 1.9.0 and 1.6.1 on a local machine, with no IdP set. Each returned its version and 401 with the body No identity provider configured.
A 200 from the second command means anyone can read your agent list, so go back to the first step. A 401 proves only that the HTTP API refuses anonymous calls. The WebSocket has no curl check here, so the version must show 1.8.4 or later. The team's rotation took one afternoon (made up). They also added a deploy check that fails when staging has no IdP.
How do you explain the Loom auth bypass and SSRF in a system design interview?
Tell it as a story about defaults and about URLs a server should never fetch blindly.
Name the failure mode
The login check failed open: no identity provider meant super-admin for every caller, with no token.
Give the fail-closed fix
Missing config returns 401. A developer shortcut needs an explicit flag, a loopback caller and no proxy headers. From 1.8.1, Loom also refuses to start with that flag in an ECS container.
Explain SSRF in one line
The server fetched user-supplied URLs, so a user could aim it at internal addresses or an attacker's token endpoint.
Describe the outbound guard
Check the scheme, resolve and check every IP, pin the connection, re-check redirects and send secrets only to allowlisted login servers.
Close with recovery
Patching stops new attacks but cannot undo old ones. Rotate secrets and read logs, since nobody can prove the gap went unused.
Questions people ask
- What is CVE-2026-103956?
- It is a critical authentication bypass in Loom, the open-source AI agent platform, before version 1.6.1. With no identity provider configured, every request became super-admin, even without a token. Version 1.6.1 fixed it on 4 August 2026, and the advisory was published on 2 October 2026.
- Was my Loom deployment affected if I had an identity provider configured?
- For the auth bypass, not if a Cognito user pool was configured, because then the bypass never ran. With only an external IdP, it could still run before 1.6.1 if Loom failed to load that IdP from its database. The two SSRF bugs affect every version before 1.7.0 either way. Before 1.8.4 the agent WebSocket also skipped login whatever IdP you had, so upgrade to 1.9.0.
- Why does the auth bypass score 10.0 while the SSRF bugs score lower?
- CVSS rates how easy and how damaging an attack is. The auth bypass needs no login and gives full control. The SSRF bugs need an attacker who already holds the mcp:write or a2a:write scope, and CVE-2026-103957 is also scored as needing a user's action.
- What is SSRF in simple words?
- Server-side request forgery means tricking a server into making a web request for you. The server sits inside a private network and holds credentials, so it reaches places you cannot. A guard must check every URL, and every redirect, before connecting.
- Does upgrading Loom remove the need to rotate secrets?
- No. Upgrading stops new attacks but cannot undo old ones. The bulletin asks you to rotate OAuth2 client secrets, reissue access tokens from the affected period and, if container credentials may have leaked, rotate them and review your audit logs.
- Which Loom version should I run in October 2026?
- Run 1.9.0, the newest release as of 19 October 2026. The bulletin asks for at least 1.7.0, which fixes all three CVEs. But versions before 1.8.4 let callers with no login run agents over the WebSocket, a hole with no CVE or advisory. 1.9.0 has breaking changes, such as naming an existing IAM role (role_arn) on deploy, so read its upgrade steps first.
- How can I check whether my Loom backend still lets anonymous callers in?
- Send a request with no token to /api/agents on your backend and look at the status code. A correctly set up backend returns 401. That proves only that the HTTP API refuses anonymous calls. Versions before 1.8.4 still ran agents over the WebSocket with no login, so the version check must also show 1.8.4 or later. If either check fails, cut access and upgrade.
Sources and further reading
- Security bulletin 2026-124: CVE-2026-103956, CVE-2026-103957 and CVE-2026-103958 in Loom (2 October 2026)
- Security advisory GHSA-vgmj-998f-r8mp: missing authentication in Loom (CVE-2026-103956)
- Security advisory GHSA-jcxf-gpf4-58hm: SSRF in Loom OAuth2 discovery (CVE-2026-103957)
- Security advisory GHSA-w6g6-h8pv-6mc7: SSRF in Loom connection handling (CVE-2026-103958)
- Loom 1.6.1 release notes (4 August 2026): auth bypass fix and net_guard.py
- Loom 1.7.0 release notes (20 September 2026): trusted token hosts and connection SSRF fix
- Loom releases page: notes for 1.8.1 to 1.9.0 and the 7 October correction (as of 19 October 2026)
- Loom source at v1.6.0: backend/app/dependencies/auth.py (the fail-open check)
- Loom source at v1.9.0: backend/app/dependencies/auth.py (bypass guards, group mapping)
- Loom source at v1.9.0: backend/app/services/net_guard.py (outbound guards)
- NVD entry for CVE-2026-103956
- NVD entry for CVE-2026-103957
- NVD entry for CVE-2026-103958