One Unverified Email Claim Becomes a Permanent Admin — LiteLLM CVE-2026-93355 Is Still Unpatched

OX Security published CVE-2026-93355 on 29 September 2026, and the interesting part is not the bug class. It is that the bug does not just let an attacker impersonate an existing LiteLLM admin — it rewrites the victim's account so the attacker's own identity becomes the account, permanently, as a side effect of the first request. The advisory was filed with the vendor on 18 May 2026. As of publication there is no patch.

NVD recorded the CVE on 28 September 2026 as CVSS v3.1 8.1 (High), CWE-290, alongside GHSA-g83g-3c4g-cfm5. This is the second LiteLLM authentication boundary to make headlines this month, after CVE-2026-59822 became the first MCP flaw in CISA's KEV catalog.

The two-stage lookup that trusts the wrong field

LiteLLM resolves a JWT-bearing request to a user in two stages. Stage one is a direct match on user_id or sso_user_id. On a miss — which is the normal outcome for any identity's very first request — it calls the fallback, _get_fuzzy_user_object, which matches on the token's email claim instead.

We read the current main branch of BerriAI/litellm to confirm the behaviour independently. Two things in litellm/proxy/auth/auth_checks.py matter:

  • The email match is plain equality, case-insensitive, and unverified. The fallback issues a find_first on user_email with mode: "insensitive". Nothing in that function checks whether the presented email was ever verified.
  • A hit does not just authenticate — it rebinds. On a successful email match with an sso_user_id present, the code fires an asyncio.create_task that updates the matched row's sso_user_id to the attacker's token subject. It is not awaited, so it lands regardless of what the rest of the request does. The source comment is candid: "background task to update user with sso id."

The claim that reaches this path is never validated upstream either. email_verified does not appear anywhere in litellm/proxy/auth/handle_jwt.py — we grepped the current file and confirmed zero occurrences. get_user_email reads whatever sits at the configured claim path and returns it as-is.

Why the rebind is the actual severity driver

A one-shot impersonation on an unverified claim would already be a serious finding. The write turns it into persistence, and it inverts the usual remediation logic:

  • The forged claim is needed exactly once. After the rebind, the attacker's subject matches on the fast path. No fallback, no email, no forged claim — just a normal, validly-signed token from a trusted issuer, resolving directly to a proxy_admin row.
  • Fixing the claim upstream afterwards does not undo it. Enforcing email_verified at your IdP tomorrow closes the door; it does not evict anyone already inside, because their access no longer depends on the email path.
  • The victim's own login is now the anomaly. The legitimate owner's subject no longer matches the row it used to. Depending on configuration, the real admin gets provisioned a fresh, lower-privileged account — which looks like a support ticket, not an incident.

That last property is why this belongs in a hunt, not just a patch queue. The database state is the evidence.

What the attacker actually needs

Less than the CVSS vector's PR:L suggests in practice. The prerequisites are: a validly-signed JWT from an issuer the proxy already trusts, carrying the victim's email address in the configured claim, and a victim account that exists in LiteLLM's user table.

The uncomfortable detail is how ordinary the first condition is. Plenty of IdP and self-service signup flows populate an email claim before — or entirely without — confirming mailbox ownership, and issue email_verified: false alongside it. LiteLLM never reads that field, so the two tokens are indistinguishable to the proxy. No misconfiguration is required on the defender's side; the default posture is the vulnerable one.

The one adjacent control that exists, the user_allowed_email_domain allowlist, is off by default — is_enforced_email_domain() returns false unless an administrator sets it. And even when set, it blocks foreign-domain spoofing only. A same-domain attacker claiming a colleague's address passes it cleanly.

A note on the two CVSS scores

OX's advisory states CVSS v3.1 8.8 with an availability impact (A:H). NVD and GitHub record 8.1 with A:N. Both vectors are otherwise identical, and neither is wrong exactly — an attacker holding proxy_admin can plainly disrupt the proxy, so the disagreement is a scoping judgement about whether that counts as availability impact to the vulnerable component. Treat the delta as noise. Nothing about your response changes between 8.1 and 8.8; what changes your response is that there is no fix.

Why the target matters more than the score

LiteLLM is an AI gateway with roughly 59,000 GitHub stars, deployed as the single chokepoint through which an organisation's model traffic flows. That position is the whole point of the product — and it means the proxy holds, in one place, the upstream provider API keys for OpenAI, Anthropic, Azure, Bedrock, Vertex and everything else configured, plus every virtual key, budget, and spend record.

An account takeover here is not lateral movement into one more SaaS tenant. A proxy_admin session reaches key/list and user/list, which is to say it reaches the organisation's provisioned credentials for every model provider behind the gateway. This is the same concentration-of-secrets problem that made the MCP auth bypass a KEV entry rather than a routine advisory.

The disclosure timeline is the other story

OX reports: vulnerability reported 18 May 2026; follow-up requesting status 27 July 2026, no response; public disclosure proceeding 14 September 2026 after roughly 120 days of silence. The CVE was assigned 17 September, published by NVD 28 September, and the research write-up landed 29 September.

OX's advisory cites the then-current release, 1.100.1 (published 10 September 2026), as affected and unpatched. We checked further: PyPI's current LiteLLM release is 1.103.0, and the fallback-plus-rebind code path is present unchanged in the repository's main branch as of this writing. There is no upstream fix to apply. Treat every deployment configured for JWT authentication as affected until BerriAI ships one.

What to do today

  • Enforce email_verified before the token reaches the proxy. This is the one control that directly neutralises the attack, and it has to live outside LiteLLM because LiteLLM does not read the field. Enforce it at the IdP, or strip and rewrite the email claim at a reverse proxy or claim-mapping layer for any token lacking email_verified: true.
  • Pin identity to a stable subject claim, not email. Configure user_id_jwt_field so every trusted issuer supplies a unique, stable subject from the first login, minimising how often the fallback is reached at all.
  • Hunt the rebind, do not just block it. The exploit leaves a specific artefact: an sso_user_id on a privileged row that does not correspond to the subject that account's real owner authenticates with. Query the user table for privileged accounts whose sso_user_id changed, and reconcile each against your IdP's subject for that person. This is a database question, not a log question — and the reports date the disclosure window back to May.
  • Do not leave privileged accounts pre-provisioned but unlinked. The attack needs an existing row whose email is guessable and whose identity has not yet been bound. Pre-created proxy_admin accounts waiting for their first SSO login are the ideal target; create them at first login instead, or bind them immediately.
  • Set user_allowed_email_domain anyway. It is defence-in-depth, not a fix — it stops external-domain spoofing and nothing else — but it is a one-line configuration that is off by default.
  • Rotate upstream provider keys if you find a rebind. Admin context on an AI gateway means the provider credentials behind it should be considered exposed, on the same reasoning we applied to LiteLLM's earlier KEV entry.

The pattern worth naming

Identity fallbacks exist because first logins are genuinely awkward: the proxy has a token for someone it has never seen, and a user row it would like to match. Every AI gateway, agent platform, and MCP host solves that with some variation of "try the strong identifier, then try something friendlier." The friendlier identifier is almost always email, and email is almost always the one claim the issuer will hand out before verifying anything.

This is the same structural failure as the WSO2 JWT bypass that reached KEV five months after its patch: a token that is validly signed is treated as a token whose contents are trustworthy. Signature validity says the issuer produced the token. It says nothing about whether the issuer checked the claims inside it. If your authentication code resolves identity on any claim other than the subject, the question to ask is not "is this token valid" but "who, exactly, verified this field, and when."

Sources: