litellm - 💡(How to fix) Fix [Bug]: JWTAuthManager.auth_builder uses raw JWT user_id, not canonical UserTable.user_id, silently splitting BYOK credentials / spend / team membership across two identities for the same SSO user

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

"error": "byok_auth_required",

Root Cause

In litellm/proxy/auth/handle_jwt.py at v1.85.1 (auth_builder runs lines 1584–1815):

  1. Line 1647user_id is taken straight from the JWT subject:

    user_id, user_email, valid_user_email = await JWTAuthManager.get_user_info(
        jwt_handler, jwt_valid_token
    )

    With user_id_jwt_field: "email", user_id is the email.

  2. Line 1741get_objects() is called and successfully resolves the canonical row:

    (
        user_object,
        org_object,
        end_user_object,
        team_membership_object,
    ) = await JWTAuthManager.get_objects(
        user_id=user_id,
        user_email=user_email,
        ...
    )

    get_objectsauth_checks.get_user_object (line 1473) → _get_fuzzy_user_object (line 1430). The fuzzy match looks up the row by sso_user_id, falls back to a case-insensitive user_email match, and back-fills sso_user_id if missing. So user_object.user_id is the canonical UUID from LiteLLM_UserTable.

  3. Line 1801–1814 — the local user_id variable is never reconciled with user_object.user_id, and the raw JWT subject is what's returned:

    return JWTAuthBuilderResult(
        is_proxy_admin=is_proxy_admin,
        team_id=team_id,
        team_object=team_object,
        user_id=user_id,             # raw JWT subject
        user_object=user_object,     # has canonical .user_id, but ignored
        ...
    )

That JWTAuthBuilderResult.user_id flows into UserAPIKeyAuth.user_id, which is the key every downstream lookup uses, including _check_byok_credential in litellm/proxy/_experimental/mcp_server/server.py (line 1950) → get_user_credential(user_id=...) (line 1942 / 2019).

The result: for the same SSO identity, the sk-key path uses the canonical UUID (the sk-key is FK-bound to it) and the JWT path uses whatever user_id_jwt_field resolves to. They don't match, so per-user state is silently split.

Fix Action

Fix / Workaround

Status (2026-05-21): Verified unfixed in v1.85.1 (latest stable) and main at HEAD. The relevant region of auth_builder is byte-identical across v1.83.14-stable, v1.83.14-stable.patch.3, v1.85.1, and main. No commit in the recent history of litellm/proxy/auth/handle_jwt.py touches user_id reconciliation. Issue/PR search for byok_auth_required user_id, JWT user_id mismatch sso_user_id, and JWT user_id BYOK returned no hits.

  •    # When get_objects() resolved the user via sso_user_id / email fuzzy
  •    # match, the canonical UserTable.user_id is what every downstream
  •    # lookup keys off (BYOK credentials, spend logs, team membership,
  •    # audit trail). Without this reconciliation, JWT-authenticated and
  •    # sk-key-authenticated requests for the same SSO identity end up
  •    # with two different user_id values.
  •    if (
  •        user_object is not None
  •        and getattr(user_object, "user_id", None)
  •        and user_id != user_object.user_id
  •    ):
  •        user_id = user_object.user_id
  •    # Derive org_id from org_object if resolved by alias
       resolved_org_id = org_object.organization_id if org_object else org_id

- `v1.83.14-stable` and `v1.83.14-stable.patch.3` (latest patch on this minor)
- `v1.85.1` (latest stable, released 2026-05-21)
- `main` at `HEAD`

Code Example

{
  "error": "byok_auth_required",
  "server_id": "<server-uuid>",
  "server_name": "mcp_jedai_jira",
  "message": "No stored credential found for this BYOK server. Complete the OAuth authorization flow to provide your API key."
}

---

user_id, user_email, valid_user_email = await JWTAuthManager.get_user_info(
       jwt_handler, jwt_valid_token
   )

---

(
       user_object,
       org_object,
       end_user_object,
       team_membership_object,
   ) = await JWTAuthManager.get_objects(
       user_id=user_id,
       user_email=user_email,
       ...
   )

---

return JWTAuthBuilderResult(
       is_proxy_admin=is_proxy_admin,
       team_id=team_id,
       team_object=team_object,
       user_id=user_id,             # raw JWT subject
       user_object=user_object,     # has canonical .user_id, but ignored
       ...
   )

---

) = await JWTAuthManager.get_objects(
             user_id=user_id,
             user_email=user_email,
             ...
         )

+        # When get_objects() resolved the user via sso_user_id / email fuzzy
+        # match, the canonical UserTable.user_id is what every downstream
+        # lookup keys off (BYOK credentials, spend logs, team membership,
+        # audit trail). Without this reconciliation, JWT-authenticated and
+        # sk-key-authenticated requests for the same SSO identity end up
+        # with two different user_id values.
+        if (
+            user_object is not None
+            and getattr(user_object, "user_id", None)
+            and user_id != user_object.user_id
+        ):
+            user_id = user_object.user_id
+
         # Derive org_id from org_object if resolved by alias
         resolved_org_id = org_object.organization_id if org_object else org_id

---
RAW_BUFFERClick to expand / collapse

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

What happened?

Status (2026-05-21): Verified unfixed in v1.85.1 (latest stable) and main at HEAD. The relevant region of auth_builder is byte-identical across v1.83.14-stable, v1.83.14-stable.patch.3, v1.85.1, and main. No commit in the recent history of litellm/proxy/auth/handle_jwt.py touches user_id reconciliation. Issue/PR search for byok_auth_required user_id, JWT user_id mismatch sso_user_id, and JWT user_id BYOK returned no hits.

Symptom

A user provisioned via SSO has a LiteLLM_UserTable row with a UUID user_id and an sso_user_id/user_email populated by the SSO callback. They save a BYOK PAT for an MCP server from the dashboard (sk-key auth — uses the canonical UUID). When the same user calls a tool on that MCP server through OpenWebUI (JWT bearer auth — user_id_jwt_field: "email"), the proxy returns:

{
  "error": "byok_auth_required",
  "server_id": "<server-uuid>",
  "server_name": "mcp_jedai_jira",
  "message": "No stored credential found for this BYOK server. Complete the OAuth authorization flow to provide your API key."
}

The PAT row exists in LiteLLM_MCPUserCredentials keyed by the canonical UUID; the JWT-authenticated lookup queries by the email and returns nothing. Same blast radius applies to anything else keyed off UserAPIKeyAuth.user_id — spend logs, team-membership checks, audit trail.

Root cause

In litellm/proxy/auth/handle_jwt.py at v1.85.1 (auth_builder runs lines 1584–1815):

  1. Line 1647user_id is taken straight from the JWT subject:

    user_id, user_email, valid_user_email = await JWTAuthManager.get_user_info(
        jwt_handler, jwt_valid_token
    )

    With user_id_jwt_field: "email", user_id is the email.

  2. Line 1741get_objects() is called and successfully resolves the canonical row:

    (
        user_object,
        org_object,
        end_user_object,
        team_membership_object,
    ) = await JWTAuthManager.get_objects(
        user_id=user_id,
        user_email=user_email,
        ...
    )

    get_objectsauth_checks.get_user_object (line 1473) → _get_fuzzy_user_object (line 1430). The fuzzy match looks up the row by sso_user_id, falls back to a case-insensitive user_email match, and back-fills sso_user_id if missing. So user_object.user_id is the canonical UUID from LiteLLM_UserTable.

  3. Line 1801–1814 — the local user_id variable is never reconciled with user_object.user_id, and the raw JWT subject is what's returned:

    return JWTAuthBuilderResult(
        is_proxy_admin=is_proxy_admin,
        team_id=team_id,
        team_object=team_object,
        user_id=user_id,             # raw JWT subject
        user_object=user_object,     # has canonical .user_id, but ignored
        ...
    )

That JWTAuthBuilderResult.user_id flows into UserAPIKeyAuth.user_id, which is the key every downstream lookup uses, including _check_byok_credential in litellm/proxy/_experimental/mcp_server/server.py (line 1950) → get_user_credential(user_id=...) (line 1942 / 2019).

The result: for the same SSO identity, the sk-key path uses the canonical UUID (the sk-key is FK-bound to it) and the JWT path uses whatever user_id_jwt_field resolves to. They don't match, so per-user state is silently split.

Proposed fix

Reconcile user_id with user_object.user_id once get_objects() has resolved the row. Diff against litellm/proxy/auth/handle_jwt.py after the get_objects call (post line 1755):

         ) = await JWTAuthManager.get_objects(
             user_id=user_id,
             user_email=user_email,
             ...
         )

+        # When get_objects() resolved the user via sso_user_id / email fuzzy
+        # match, the canonical UserTable.user_id is what every downstream
+        # lookup keys off (BYOK credentials, spend logs, team membership,
+        # audit trail). Without this reconciliation, JWT-authenticated and
+        # sk-key-authenticated requests for the same SSO identity end up
+        # with two different user_id values.
+        if (
+            user_object is not None
+            and getattr(user_object, "user_id", None)
+            and user_id != user_object.user_id
+        ):
+            user_id = user_object.user_id
+
         # Derive org_id from org_object if resolved by alias
         resolved_org_id = org_object.organization_id if org_object else org_id

Suggested test cases (extending the existing auth_builder tests):

  1. JWT subject is the email, LiteLLM_UserTable has a UUID user_id and matching user_emailJWTAuthBuilderResult.user_id is the UUID.
  2. JWT subject is the sso_user_id, fuzzy match resolves by sso_user_idJWTAuthBuilderResult.user_id is the UUID.
  3. user_id_upsert: true, no matching row → behavior unchanged (new row created with user_id = JWT subject; user_object.user_id equals user_id, no reconciliation needed).
  4. BYOK round-trip: write to LiteLLM_MCPUserCredentials with sk-key auth, read with JWT auth — both resolve to the same row.

Component

Proxy

LiteLLM version

Reproduced on v1.83.14. Confirmed unfixed in:

  • v1.83.14-stable and v1.83.14-stable.patch.3 (latest patch on this minor)
  • v1.85.1 (latest stable, released 2026-05-21)
  • main at HEAD

The diff between v1.83.14-stable.patch.3 and v1.85.1 for handle_jwt.py is purely cosmetic (DualCacheUserApiKeyCache, added from __future__ import annotations, a new get_all_jwt_team_ids helper). The auth_builder flow and JWTAuthBuilderResult return statement are unchanged.

Steps to Reproduce

Relevant log output

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on ?

1.83.14

Twitter / LinkedIn details

No response

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING