Skip to content

feat(jwt): fall back to DB team memberships when JWT has no team claims - #31356

Merged
mateo-berri merged 18 commits into
litellm_internal_stagingfrom
litellm_jwt_db_team_fallback
Jul 7, 2026
Merged

feat(jwt): fall back to DB team memberships when JWT has no team claims#31356
mateo-berri merged 18 commits into
litellm_internal_stagingfrom
litellm_jwt_db_team_fallback

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Screenshots / Proof of Fix

Re-verified end to end on a licensed proxy (real LITELLM_LICENSE from the environment, accepted at startup with CHECKING PREMIUM USER - True in both runs) backed by a real Postgres DB over DATABASE_URL and real OpenAI spend. This run supersedes the earlier proof in this section. Exact commits: before = 7f991481cc069d7a069a8a50c140bcfaec9a4e6c (merge-base with litellm_internal_staging), after = 12e7547c7181fa3faf4409afcd9d4c34a0059a94 (current head). Each proxy ran from its own detached git worktree with its own venv (uv venv --python 3.12, pip install -e ".[proxy]" prisma, prisma generate --schema litellm/proxy/schema.prisma), launched as python litellm/proxy/proxy_cli.py --config <config> --port <port> --detailed_debug; no source edits, mocks, or shims of any kind

JWT auth uses a locally generated RS256 keypair with a static JWKS served over HTTP (python3 -m http.server 29873 in a dir containing jwks.json); env for both proxies: JWT_PUBLIC_KEY_URL=http://localhost:29873/jwks.json, JWT_AUDIENCE=litellm-qa-31356, JWT_ISSUER=http://localhost:29873. Both runs use this config; the before run drops the fallback_to_db_teams line because pre-PR code rejects it at startup, verified live on the merge-base:

ValueError: Invalid arguments provided: fallback_to_db_teams. Allowed arguments are: admin_jwt_scope, admin_allowed_routes, team_id_jwt_field, [...], team_claim_fallback, issuers.
model_list:
  - model_name: gpt-5.5
    litellm_params:
      model: openai/gpt-5.5
      api_key: os.environ/OPENAI_API_KEY

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY
  database_url: os.environ/DATABASE_URL
  enable_jwt_auth: true
  litellm_jwtauth:
    user_id_jwt_field: sub
    user_id_upsert: true
    team_ids_jwt_field: team_ids
    enforce_team_based_model_access: true
    fallback_to_db_teams: true

DB setup via the admin API: team qa-jwt-fb-team-7204 with models: ["gpt-5.5"] and user qa-jwt-fb-user-7204 added to it via /team/member_add, plus a second team qa-jwt-fb-team-7204-other the user is not a member of, and a second user qa-jwt-fb-user-7204-noteam with no memberships. $JWT is an RS256 token whose only claims are sub=qa-jwt-fb-user-7204, aud, iss, iat, and exp; no team claims

Before (7f991481cc, port 28517): the claimless JWT is rejected even though the user has a DB team membership, and the header cannot rescue it

$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:28517/v1/chat/completions \
    -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
    -d '{"model": "gpt-5.5", "messages": [{"role": "user", "content": "Say QA-OK and nothing else"}]}'
{"error":{"message":"No teams found in token. `enforce_team_based_model_access` is set to True. Token must belong to a team.","type":"auth_error","param":"None","code":"403"}}
HTTP 403

$ curl (same as above) -H "x-litellm-team-id: qa-jwt-fb-team-7204"
{"error":{"message":"Team 'qa-jwt-fb-team-7204' from x-litellm-team-id header is not in your JWT's allowed teams. Allowed teams: []","type":"auth_error","param":"None","code":"403"}}
HTTP 403

After (12e7547c71, port 27431, fallback_to_db_teams: true): the same claimless JWT resolves the team from DB membership and the request reaches OpenAI

$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:27431/v1/chat/completions \
    -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
    -d '{"model": "gpt-5.5", "messages": [{"role": "user", "content": "Say QA-OK and nothing else"}]}'
{"id":"chatcmpl-DynfNkCm2bZygTjzF1znqyzs57Ykq","created":1783382969,"model":"gpt-5.5","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"QA-OK","role":"assistant","provider_specific_fields":{"refusal":null},"annotations":[]},"provider_specific_fields":{}}],"usage":{"completion_tokens":22,"prompt_tokens":13,"total_tokens":35,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":10,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"service_tier":"default"}
HTTP 200

with the proxy log confirming the attribution: JWT DB team fallback: resolved team_id=qa-jwt-fb-team-7204 from user DB membership

The rest of the matrix on the after proxy

$ curl (claimless $JWT) -H "x-litellm-team-id: qa-jwt-fb-team-7204"
{"id":"chatcmpl-DynfbiKvHehza4h7Ose1PnlTX8hR1", ... "content":"QA-OK-2" ...}
HTTP 200

$ curl (claimless $JWT) -H "x-litellm-team-id: qa-jwt-fb-team-7204-other"
{"error":{"message":"Team 'qa-jwt-fb-team-7204-other' (from x-litellm-team-id header) is not in your team memberships.","type":"auth_error","param":"None","code":"403"}}
HTTP 403

$ curl with $JWT2 for sub=qa-jwt-fb-user-7204-noteam (user exists, no memberships)
{"error":{"message":"User is not a member of any team. Add the user to a team via the LiteLLM UI or API.","type":"auth_error","param":"None","code":"403"}}
HTTP 403

$ curl with $JWT3 for the same user but carrying team_ids=["qa-jwt-fb-team-7204"] (claim path, unchanged)
{"id":"chatcmpl-DynflOc4wdoH5alSxI67Bkq3X1hjj", ... "content":"QA-OK-3" ...}
HTTP 200

The disposable teams and users were deleted from the DB afterwards

Type

🆕 New Feature

Changes

Entra-backed JWT flows can mint valid user tokens that carry no LiteLLM team claims. With enforce_team_based_model_access on, vanilla LiteLLM rejects those tokens with HTTP 403 before it ever loads the user's records, even when the user already has valid team memberships in the proxy database. This adds an opt-in fallback_to_db_teams flag on LiteLLM_JWTAuth that shifts the source of team truth from JWT claims to LiteLLM's own team membership table for deployments where the IdP does not carry the team list

When the flag is enabled and the JWT has no team claims, JWTAuthManager.auth_builder defers the early "no teams in token" 403 until after the user and membership records are resolved, then attributes usage to the user's first resolvable DB team. An x-litellm-team-id header is accepted provisionally and validated against the user's DB memberships before it becomes request context, so a caller cannot select a team they do not belong to. If the user has no DB team membership and team model access is enforced, the request still fails with 403

The flag defaults to false, so existing deployments are unchanged: the upstream single-team DB fallback and strict claim-based authorization are preserved exactly. The behavior only diverges when an operator opts in, and even then only for tokens that carry no team claims

Tests extend tests/test_litellm/proxy/auth/test_handle_jwt.py and cover the header-deferral logic, the early-403 deferral in find_team_with_model_access, the DB membership resolver skipping orphaned memberships, and an end-to-end auth_builder matrix spanning single-team, multi-team, valid header, invalid header, no-membership-under-enforcement, and the flag-off control that proves upstream behavior is unchanged

A follow-up hardens the provisional header path: when the header team fails to load, the 404 from get_team_object is rewritten into the same 403 the membership check raises, so a caller cannot probe which team ids exist by varying x-litellm-team-id. Claim-backed header teams keep the upstream 404. The same commit drops an unreachable falsy-team guard in the fallback resolver and stops codecov carryforward for three dead flags whose stale line maps were sinking patch coverage on PRs touching since-edited files


Note

High Risk
Changes proxy JWT authentication, team attribution, and spend tracking paths; misconfiguration or edge-case bugs could allow wrong-team access or bypass route/model gates despite added checks.

Overview
Adds opt-in fallback_to_db_teams on LiteLLM_JWTAuth (default false) so JWTs with no team claims can attribute usage from database team memberships instead of failing early with HTTP 403 when enforce_team_based_model_access is on.

When enabled, JWTAuthManager.auth_builder defers the “no teams in token” rejection, picks the first resolvable DB membership (with the same model access, team_allowed_routes, membership budget, and passthrough checks as the claim path), and accepts x-litellm-team-id only provisionally—validated against DB membership, with no team upsert on that header. RBAC-asserted teams are not overridden by the header or re-checked against DB membership.

Related JWT fixes in the same flow: team alias resolution wins over team_id_default when there is no real team-id claim; sync preserves DB teams on claimless tokens and reconciles singular team claims when the flag is on (plural-only behavior unchanged when off).

Reviewed by Cursor Bugbot for commit 9584918. Bugbot is set up for automated code reviews on this repo. Configure here.


Note

High Risk
Changes proxy JWT authentication, team attribution, and spend tracking; misconfiguration or edge-case bugs could allow wrong-team access or bypass route/model gates despite added checks.

Overview
Adds opt-in fallback_to_db_teams on LiteLLM_JWTAuth (default false). When enabled and the JWT has no team claims, proxy JWT auth can attribute requests from database team memberships instead of failing early with HTTP 403 under enforce_team_based_model_access.

JWTAuthManager.auth_builder defers the empty-token-teams rejection, runs _resolve_db_team_fallback (first resolvable membership with the same model access, team_allowed_routes, membership budget, and passthrough checks as the claim path), and treats x-litellm-team-id as provisional—validated against DB membership, with no team upsert on that header. RBAC-pinned teams are not overridden by the header; invalid header teams get a uniform 403 (no team-existence oracle).

Related JWT behavior in the same flow: team alias wins over team_id_default when there is no real team-id claim; sync preserves DB teams on claimless tokens when the flag is on and reconciles singular team claims only in that mode.

codecov.yaml disables carryforward for three unused CI flags so stale patch coverage does not penalize unrelated PRs.

Tests in test_handle_jwt.py cover the fallback matrix, header security, and regressions.

Reviewed by Cursor Bugbot for commit 12e7547. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Added an option to let JWT-authenticated users fall back to database team memberships when team claims are missing.
    • Team selection now supports validating a team passed through request headers against stored memberships.
  • Bug Fixes

    • Improved team access handling for JWT users with partial or missing team information.
    • Refined route and model access checks to better handle fallback authentication paths and avoid incorrect denials.
    • Updated Codecov flag handling so selected flags no longer carry forward between runs.

@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri
mateo-berri marked this pull request as ready for review June 25, 2026 22:45
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an opt-in fallback_to_db_teams flag to LiteLLM_JWTAuth (default False) so deployments where the IdP does not embed team claims (e.g. Entra-backed flows) can still authenticate users by resolving their team from the proxy database instead of receiving an immediate HTTP 403.

  • Core fallback path (_resolve_db_team_fallback): iterates the user's DB team memberships in order, applying the same per-team model-access (can_team_access_model) and route-gate (team_allowed_routes) checks the claim-based path enforces, and loads the membership row for budget enforcement — mirroring the existing single-team fallback contract.
  • DB membership preservation: sync_user_role_and_teams now skips the team-removal step when the JWT has no claims and fallback_to_db_teams is on, preventing the sync from wiping the memberships the fallback would immediately need.
  • Provisional header team handling: x-litellm-team-id is accepted without JWT-team validation only when the JWT is claimless; team-object upsert is suppressed during that window; the header team is then validated against DB membership before becoming request context, and nonexistent vs. non-member teams produce identical 403 shapes to prevent enumeration.

Confidence Score: 5/5

Safe to merge; the opt-in flag defaults to false so all existing deployments are unchanged, and the active path enforces model-access, route, and DB-membership gates equivalent to the existing claim-based path.

All previously raised concerns (DB membership destruction during sync, missing membership-budget row on the fallback path, RBAC-asserted team being re-checked against DB membership, header team upsert before validation) are addressed in this revision. The only new finding is an edge-case misleading error message when team_allowed_routes is restrictive — the request is correctly denied in all cases, the message is just imprecise about the reason. No incorrect authorization or data-integrity risk was identified.

litellm/proxy/auth/handle_jwt.py — specifically _resolve_db_team_fallback's post-loop error branch, which conflates route-blocked and model-blocked failure reasons in one error message.

Important Files Changed

Filename Overview
litellm/proxy/auth/handle_jwt.py Core auth logic: adds DB-team fallback path, preserves DB memberships during sync when JWT has no claims, and validates header team against DB membership before attributing usage; one edge-case error message is misleading when team_allowed_routes blocks all candidates
tests/test_litellm/proxy/auth/test_handle_jwt.py Adds ~1600 lines of new parametrized and scenario tests covering the DB-fallback path; existing test modifications are pure style reformats (same assertions); no weakening of coverage
litellm/proxy/_types.py Adds opt-in fallback_to_db_teams: bool = False field to LiteLLM_JWTAuth with a clear docstring; default-off preserves existing behavior
codecov.yaml Disables carryforward for three dead CI flags that were polluting patch coverage on unrelated PRs with stale line maps

Reviews (21): Last reviewed commit: "fix(jwt): collapse provisional header te..." | Re-trigger Greptile

Comment thread litellm/proxy/auth/handle_jwt.py Outdated
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an opt-in fallback_to_db_teams flag to LiteLLM_JWTAuth that, when enabled, lets users whose JWT carries no team claims be authenticated against their LiteLLM database team memberships instead of receiving an immediate 403. It also adds provisional acceptance of the x-litellm-team-id header validated against DB membership, and a new _resolve_db_team_fallback helper.

  • fallback_to_db_teams field (_types.py): clean, backward-compatible opt-in addition defaulting to False.
  • Auth flow changes (handle_jwt.py): early-403 deferral in find_team_with_model_access, new _resolve_db_team_fallback and _validate_header_team_in_db_membership helpers, and integration in auth_builder; two functional issues affect the feature's correctness when combined with other flags (see inline comments).
  • Tests (test_handle_jwt.py): new mock-only tests cover header deferral, orphaned membership skipping, and an end-to-end parametrized matrix; existing assertions are reformatted but not weakened.

Confidence Score: 3/5

Not safe to merge as-is: the DB fallback feature has two functional gaps that can cause silent auth misattribution and persistent data corruption in deployments that combine it with other existing flags.

The _resolve_db_team_fallback path picks the first resolvable DB team without running the per-team model access check that the claim-based path enforces, so a team's models restriction is silently bypassed. More critically, sync_user_role_and_teams (called before the fallback) computes teams_to_remove = all_existing_db_teams when the JWT has no team claims and jwt_team_ids is empty — permanently stripping the user's team memberships from the database on every request, which also causes the fallback itself to see an empty team list and reject the user.

The interaction between auth_builder's sync_user_role_and_teams call and the new _resolve_db_team_fallback call in handle_jwt.py needs the most attention, along with the missing model-access check inside _resolve_db_team_fallback.

Security Review

  • Team ID enumeration via error response (handle_jwt.py, _validate_header_team_in_db_membership): the HTTP 403 detail string includes the authenticated user's full list of DB team IDs (Your teams: {user_team_ids}). Any caller holding a valid JWT can probe with arbitrary header team IDs to enumerate the team landscape. The fix is to omit the team list from the error message (suggestion comment left inline).

Important Files Changed

Filename Overview
litellm/proxy/auth/handle_jwt.py Core auth change adding DB team fallback; contains two P1 logic bugs: sync_user_role_and_teams can wipe DB memberships before the fallback reads them, and _resolve_db_team_fallback picks the first team without checking per-team model access restrictions.
litellm/proxy/_types.py Adds fallback_to_db_teams: bool = False field to LiteLLM_JWTAuth; clean, opt-in, backward-compatible addition.
tests/test_litellm/proxy/auth/test_handle_jwt.py Adds new mock-only tests covering header deferral, DB fallback resolution, and an end-to-end matrix; existing tests are reformatted (assertion parenthesization) with no logic change. Tests do not cover the sync_user_role_and_teams conflict scenario.

Reviews (2): Last reviewed commit: "style(jwt): use PEP 585/604 annotations ..." | Re-trigger Greptile

Comment thread litellm/proxy/auth/handle_jwt.py
Comment thread litellm/proxy/auth/handle_jwt.py Outdated
Comment thread litellm/proxy/auth/handle_jwt.py Outdated
… stop team-id leak

When fallback_to_db_teams is enabled and a JWT carries no team claims,
sync_user_role_and_teams previously computed teams_to_remove as every existing
DB membership and wiped the user out of all their teams on each request, which
also left the DB fallback nothing to resolve. Skip team removal in that case so
memberships survive and the fallback can attribute usage.

Apply the same per-team model-access check the claim-based path enforces when
selecting a DB fallback team, so a team's models restriction is no longer
bypassed; a team that cannot serve the requested model is skipped in favor of
one that can.

Drop the user's team-id list from the x-litellm-team-id membership 403 detail so
a valid-JWT caller can no longer enumerate team IDs.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Addressed all three findings in c1f6695:

  1. (Critical) sync_user_role_and_teams no longer strips a user's DB team memberships on a no-team-claim JWT when fallback_to_db_teams is enabled. It computed teams_to_remove = existing_teams - jwt_team_ids, which with empty claims equaled every DB membership, so each request wiped the user out of all teams and left the fallback nothing to resolve. It now skips removal in that specific case so memberships survive and the fallback can attribute usage. Regression: test_sync_user_role_and_teams_no_claim_team_preservation (parametrized on the flag)

  2. _resolve_db_team_fallback now applies the same per-team model-access check (can_team_access_model) the claim path enforces, skipping a DB team that cannot serve the requested model in favor of one that can, so a team's models restriction is no longer bypassed. Regression: test_resolve_db_team_fallback_skips_team_without_model_access

  3. The x-litellm-team-id membership 403 no longer echoes the caller's team-id list, closing the enumeration leak. Regression: test_validate_header_team_in_db_membership_does_not_leak_team_ids

@greptileai

Comment thread litellm/proxy/auth/handle_jwt.py Outdated
…provisional teams

The DB-team fallback resolved a team but never loaded its team membership
row, so per-team membership budget limits were silently skipped on that
path. _resolve_db_team_fallback now fetches the resolved team's membership
when a user_id is known and returns it, matching the claim-based path so
downstream LiteLLM_TeamMembership budget enforcement works there too.

The provisional x-litellm-team-id validation also fired on any non-None
team_id, including an RBAC role-derived one, which 403'd RBAC team flows
when the asserted team was not also a DB membership. It now runs only when
team_id actually came from the header (team_id == header_team_id).
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Addressed both remaining findings in 9911998:

The DB team fallback now loads the resolved team's membership row. _resolve_db_team_fallback takes the resolved user_id and calls get_team_membership for the team it selects, returning it as the third tuple element the same way _resolve_single_team_fallback does, so per-team LiteLLM_TeamMembership budget limits are enforced on the fallback path instead of being silently skipped for every request. Regression: test_resolve_db_team_fallback_loads_team_membership

The provisional x-litellm-team-id membership validation no longer fires on a non-header team_id. It previously ran for any non-None team_id whenever db_team_fallback was true, so a JWT carrying an RBAC team role but no group/team claims (which sets team_id from the RBAC object_id, not the header) was rejected with 403 when that asserted team was not also a DB membership. The guard is now team_id == header_team_id, so only a team that actually came from the header is validated against DB memberships. Regression: test_auth_builder_db_fallback_does_not_validate_rbac_team_against_db_membership

@greptileai


Generated by Claude Code

…evel

A transient get_team_membership failure on the DB team fallback path is
recoverable: the team is still resolved and the request proceeds, just
without per-team membership budget enforcement for that request. Logging
that at debug hid a silent budget-enforcement gap from operators, so it now
logs at warning and states that enforcement was skipped. Behavior is
otherwise unchanged: the resolved team is returned with a None membership
rather than failing the request, covered by
test_resolve_db_team_fallback_survives_membership_lookup_error.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Addressed the remaining visibility note in 4a2b0c0: a transient get_team_membership failure on the DB fallback path now logs at warning and states that per-team membership budget enforcement was skipped for that request, instead of hiding it at debug. The request still proceeds with the resolved team and a None membership rather than failing, covered by test_resolve_db_team_fallback_survives_membership_lookup_error.

@greptileai


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Wrong 403 when model denied
    • _resolve_db_team_fallback now tracks whether any DB membership resolved and raises a model-access denial (matching find_team_with_model_access) when memberships exist but none can access the requested model, only falling back to the no-membership message when the user actually has no memberships.
  • ✅ Fixed: Default team disables DB fallback
    • db_team_fallback is now derived from get_all_jwt_team_ids (which ignores team_id_default), computed once near the start of auth_builder, and used to skip both the specific_team_id injection and find_and_validate_specific_team_id so a configured default no longer hides claimless tokens from the DB fallback.
  • ✅ Fixed: Passthrough check skipped after fallback
    • After _resolve_db_team_fallback assigns a team_id, auth_builder re-runs _team_has_passthrough_route_access and raises _raise_team_passthrough_route_denial when the fallback-selected team is not allowed, mirroring the claim-based enforcement.
  • ✅ Fixed: Sync preserve ignores singular team claims
    • sync_user_role_and_teams now reads jwt_team_ids via get_all_jwt_team_ids so singular-only IdP setups are no longer treated as claimless, preventing stale DB memberships from persisting and later being attributed by _resolve_db_team_fallback.

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/auth/handle_jwt.py
Comment thread litellm/proxy/auth/handle_jwt.py Outdated
Comment thread litellm/proxy/auth/handle_jwt.py
Comment thread litellm/proxy/auth/handle_jwt.py Outdated
…ement

Resolves four issues in the fallback_to_db_teams path:

- _resolve_db_team_fallback now surfaces a model-access denial when memberships
  exist but none can access the requested model, instead of always returning
  the no-membership message
- auth_builder gates the fallback on real JWT team claims via
  get_all_jwt_team_ids so a configured team_id_default does not silently route
  claimless tokens to the default team
- A team selected only via _resolve_db_team_fallback is re-validated against
  the team's allowed_passthrough_routes; the earlier gate ran while team_id
  was still None
- sync_user_role_and_teams considers both plural and singular team claim
  shapes when reconciling DB memberships so singular-only tokens
  (Okta/Auth0 defaults) no longer leave stale teams behind
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ mateo-berri
❌ cursoragent
You have signed the CLA already but the status is still pending? Let us recheck it.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

Bugbot's four findings are addressed in e897495, each with a regression test:

A model-denied DB-fallback request under enforce_team_based_model_access now raises the same "No team has access to the requested model" 403 as the claim path instead of the misleading "not a member of any team" message, so the two causes are distinguishable. The fallback gate now keys off get_all_jwt_team_ids (real plural plus singular claims) rather than all_team_ids, so a configured team_id_default no longer suppresses the DB fallback for a claimless token. A team resolved only via the DB fallback is re-checked against the auth-enforced passthrough allowlist, closing the gap where the earlier gate ran while team_id was still None. And sync_user_role_and_teams now reconciles against both claim shapes via get_all_jwt_team_ids, so a singular-only team claim is treated as a real claim and no longer leaves stale DB memberships.

@greptileai


Generated by Claude Code

…hip check

When fallback_to_db_teams is on and the JWT carries no team claims, an
x-litellm-team-id header is accepted provisionally and only validated against
the user's DB memberships later in auth_builder. With team_id_upsert also
enabled, get_team_object ran the upsert on that unvalidated header team first,
so an attacker-supplied header could create an orphaned team row before the
403 membership check. Suppress the upsert whenever the team is provisional
(db_team_fallback), since a genuine membership team already exists and an
invalid one must not be created. Regression:
test_auth_builder_provisional_header_team_is_not_upserted.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Addressed the upsert-before-membership-check note in 08194af. When fallback_to_db_teams is on and the JWT has no team claims, an x-litellm-team-id header is provisional and only validated against DB membership later, so get_team_object no longer runs the team_id_upsert on it; an attacker-supplied header can no longer create an orphaned team row before the 403 membership check, and a genuine membership team already exists so suppressing the upsert there is a no-op. Regression: test_auth_builder_provisional_header_team_is_not_upserted

@greptileai


Generated by Claude Code

…ride

When a JWT carries an RBAC team role but no group claims, auth_builder already
sets team_id from the RBAC object_id. db_team_fallback still evaluated true
there, so the provisional x-litellm-team-id path accepted a header team and
silently overrode the RBAC-asserted team with any team the caller belonged to.
Gate db_team_fallback on team_id being unset, and drive the header's provisional
acceptance off db_team_fallback rather than the raw flag, so an RBAC token plus
a non-claim header team is rejected with 403 instead of substituting the team.
Regression: test_auth_builder_header_cannot_override_rbac_team_under_db_fallback.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Addressed the RBAC-team override note in 3522123. db_team_fallback now also requires team_id is None, so an RBAC team-role token (which already set team_id from its object_id) no longer enters the claimless fallback path, and the provisional x-litellm-team-id acceptance is driven off db_team_fallback rather than the raw flag. An RBAC token plus a non-claim header team is now rejected with 403 instead of letting the header substitute the RBAC-asserted team. Regression: test_auth_builder_header_cannot_override_rbac_team_under_db_fallback

@greptileai


Generated by Claude Code

The membership sync read both plural and singular JWT team claims via
get_all_jwt_team_ids unconditionally, which silently changed reconciliation
for every deployment using sync_user_role_and_teams, not just those opting
into fallback_to_db_teams: a singular-only IdP token that previously stripped
all DB teams would now be recognized. Gate the dual-claim read on
fallback_to_db_teams so flag-off deployments keep the upstream plural-only
behavior, honoring the PR's contract that existing deployments are unchanged.
Regression: test_sync_user_role_and_teams_singular_claim_only_recognized_under_flag.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Scoped the dual-claim membership sync to fallback_to_db_teams in 1f038f8. sync_user_role_and_teams now reads both plural and singular claims via get_all_jwt_team_ids only when the flag is on; with the flag off it keeps the upstream plural-only get_team_ids_from_jwt reconciliation, so deployments that never opted in see no change in how a singular-only token reconciles memberships. Regression: test_sync_user_role_and_teams_singular_claim_only_recognized_under_flag

@greptileai


Generated by Claude Code

The model-access-denied 403 in _resolve_db_team_fallback echoed the user's
full DB team-id list in its detail. It is only the caller's own memberships,
but it is inconsistent with the membership-validation 403 in the same feature
that was deliberately scrubbed of team IDs. Replace the enumerated list with a
generic "no team you are a member of has access" message. Regression extends
test_resolve_db_team_fallback_distinguishes_no_membership_vs_model_denied to
assert the team id is absent from the detail.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Dropped the user's team-id list from the model-access-denied 403 in _resolve_db_team_fallback in 7ff66f5. It now reads "No team you are a member of has access to the requested model: {model}. Check /models...", consistent with the membership-validation 403 in the same feature that already omits team IDs. Regression: test_resolve_db_team_fallback_distinguishes_no_membership_vs_model_denied now also asserts the team id is absent from the detail.

@greptileai


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

On the "Comments Outside Diff" P1 about a claimless non-member header loading the attacker team before membership validation: the exploitable part of this, creating an orphaned team row from an attacker-supplied header, is already closed and stays closed, and the residual read is side-effect-free, so there is no open security issue here

get_team_object for a provisional header runs with team_id_upsert=(jwt_handler.litellm_jwtauth.team_id_upsert and not db_team_fallback) (handle_jwt.py:2271-2273), which is always False for a claimless-token provisional header. Team creation only happens inside _upsert_team_object when response is None and team_id_upsert (auth_checks.py:1890-1903); with that flag False the call is a plain find_unique read (auth_checks.py:1886-1888) that returns the existing row or None and writes nothing. So the attacker header cannot create or mutate any team row, which was the actual finding fixed in 08194af and verified by test_auth_builder_provisional_header_team_is_not_upserted

A read of a team the caller is not a member of grants nothing. The only consumer of that team_object before membership validation is the auth-enforced passthrough gate at handle_jwt.py:2325, which can only deny, never grant. _validate_header_team_in_db_membership then rejects the non-member with 403 (handle_jwt.py:2412-2416) before the request proceeds, and model/route access is re-enforced downstream in common_checks. The 403 detail carries no team information, so nothing is created and nothing is disclosed

Deferring the read until after membership validation would require resolving user_object (loaded by get_objects, which itself takes the resolved team_id) before team resolution runs, inverting the centralized auth ordering for no security benefit given the read mutates nothing. Leaving it as-is

@greptileai review d55aa14


Generated by Claude Code

Comment thread litellm/proxy/auth/handle_jwt.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Both points on the 3/5 are pre-existing or deliberate behavior, not regressions this PR introduces; neither is an open issue

On the P1 "fallback_to_db_teams=False still attributes claimless JWTs to a single DB team": that is the upstream single-team DB fallback, and this PR preserves it byte-for-byte when the flag is off. The diff against base shows the only change to that branch is gating, upstream's if team_id is None: _resolve_single_team_fallback(...) became if team_id is None and db_team_fallback: _resolve_db_team_fallback(...) / elif team_id is None: _resolve_single_team_fallback(...). With fallback_to_db_teams off, db_team_fallback is False, so the elif runs the exact upstream _resolve_single_team_fallback path. The new early-403 deferral in find_team_with_model_access is also gated enforce_team_based_model_access and not fallback_to_db_teams, so a flag-off deployment still raises the strict no-teams 403 precisely as before. The PR's contract is that flag-off equals unchanged upstream behavior; turning flag-off into a 403 where upstream returns 200, as the suggested fix proposes, would be a breaking change to every existing deployment relying on the single-team fallback, which is explicitly out of scope here

On the membership-lookup note in the score rationale: that is the intended behavior added in 4a2b0c0, not a gap. When get_team_membership throws a transient error on the DB-fallback path, _resolve_db_team_fallback logs at warning that per-team membership budget enforcement was skipped for that request and proceeds with the resolved team and a None membership, rather than failing a request because of a transient infra error on an opt-in path. Team-level budget enforcement is unaffected since team_object is still loaded; only the narrower per-member LiteLLM_TeamMembership budget is skipped for that single request, and the skip is observable in logs. This is covered by test_resolve_db_team_fallback_survives_membership_lookup_error. Failing closed on a transient lookup error would be a worse availability tradeoff and is a deliberate product decision rather than a defect

@greptileai review d55aa14


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

The flag-off P1 is a false positive that comes from a mocked find_team_with_model_access, not from production behavior. In production, flag-off plus enforce_team_based_model_access=True plus a claimless JWT raises the strict no-teams 403 before _resolve_single_team_fallback can run, so it does not return team_solo

The gate is in find_team_with_model_access (handle_jwt.py:1450-1458): if not team_ids: if enforce_team_based_model_access and not fallback_to_db_teams: raise HTTPException(403, "No teams found in token..."). For a claimless token team_ids is empty, and with the flag off not fallback_to_db_teams is True, so the 403 fires and the legacy single-team fallback is never reached. That is identical to upstream. The T-Rex run observes team_solo only because the end-to-end test test_auth_builder_db_team_fallback_when_jwt_has_no_team patches find_team_with_model_access to return (None, None) (test file around line 4589) to isolate the branch wiring; that mock simulates the enforce=False path, where upstream also resolves the lone DB team via _resolve_single_team_fallback. The strict-403 production path is itself covered by test_find_team_with_model_access_* and the enforce-on 403 assertion already in the suite

So flag-off behavior is unchanged from upstream in both enforce modes: enforce-on claimless raises 403, enforce-off claimless resolves the single DB team exactly as before. Gating _resolve_single_team_fallback to 403 under enforce-off, as the suggested fix implies, would be a breaking change to existing non-enforced deployments that depend on the single-team fallback, which is out of scope for this opt-in flag. No open actionable concern remains

@greptileai review d55aa14


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Header bypasses route gate
    • Extracted the team_allowed_routes gate from _resolve_db_team_fallback into a shared _is_team_route_allowed helper and now apply it in auth_builder's header-team validation branch under db_team_fallback, so a claimless JWT presenting x-litellm-team-id can no longer reach routes the JWT config narrows for team-role callers.

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/auth/handle_jwt.py
The auto-pick DB-team fallback already gates against team_allowed_routes, but a claimless JWT presenting x-litellm-team-id under fallback_to_db_teams set team_id directly from the header and only re-validated DB membership afterwards, skipping the route gate. A caller could reach management/info routes that the JWT config narrowed for team-role callers by supplying the header even though the auto-pick path on the same route returns no team.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

5d7d41b closes the header-path gap Bugbot flagged: the team_allowed_routes gate is factored into _is_team_route_allowed and now applied on the provisional x-litellm-team-id branch after DB membership validation, so a header-selected team is rejected with 403 on a route the JWT config excludes for team-role callers, matching the auto-pick path. Auth-enforced passthrough routes stay exempt and are gated separately against the team's allowed_passthrough_routes. Regression: test_auth_builder_header_team_enforces_team_allowed_routes

@greptileai review 5d7d41b


Generated by Claude Code

# provisional x-litellm-team-id header could override an RBAC-asserted team.
db_team_fallback = (
jwt_handler.litellm_jwtauth.fallback_to_db_teams
and not jwt_handler.get_all_jwt_team_ids(token=jwt_valid_token)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High: Empty team claims bypass team revocation

get_all_jwt_team_ids() returns [] both when the configured team claim is absent and when it is present but explicitly empty. With fallback_to_db_teams enabled, a user whose IdP now issues teams: [] can still fall back to DB memberships and keep using those teams' model access; sync_user_role_and_teams() also preserves DB teams in the same empty-list case. Please distinguish claim absence from an empty claim value, and only run/preserve the DB fallback when none of the configured team ID or alias claim fields are present.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the intended semantics of fallback_to_db_teams, and distinguishing an absent claim from an empty one the way suggested would break the feature's primary use case rather than close a bypass.

fallback_to_db_teams is opt-in and shifts the source of team truth from the JWT to LiteLLM's own membership table for deployments whose IdP does not carry team membership in the token. In that mode an absent team claim and an empty team claim both mean the same thing: the JWT does not assert teams, so the DB is authoritative. Many IdPs that omit team membership emit the configured claim as an empty array for a user with no groups rather than dropping the key entirely, so treating teams: [] as "claims present, therefore revoked, 403 under enforcement" would reject exactly the no-team-claim tokens this flag exists to support, including the Entra-style case that motivated it. That is why db_team_fallback keys off get_all_jwt_team_ids being empty and sync_user_role_and_teams preserves DB memberships in the no-claim case; preserving is required or the fallback that runs immediately after would have nothing to resolve.

Revocation in this mode is performed by removing the user's DB team membership, not by the IdP emitting an empty array; the JWT team claim is deliberately not the revocation channel once an operator opts into DB-backed teams. An operator who wants JWT-driven team revocation simply does not enable fallback_to_db_teams: with the flag off, the strict claim-based path and the upstream sync reconciliation are preserved exactly, so an empty or shrinking team claim strips DB memberships as before. The flag changes behavior only for tokens that carry no team claims, and only for deployments that opted in, which is the documented contract. Leaving the behavior as-is


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@veria-ai review


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5d7d41b. Configure here.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 9584918. Configure here.

Comment thread litellm/proxy/auth/handle_jwt.py Outdated
…ship denial

A caller holding a valid claimless JWT under fallback_to_db_teams could
distinguish nonexistent teams (404 from get_team_object) from existing
teams they do not belong to (membership 403) by varying x-litellm-team-id,
giving an authenticated team-id existence oracle. The provisional header
path now rewrites the lookup failure into the exact 403 the membership
check raises, while claim-backed header teams keep the upstream 404.

Also drop the unreachable falsy-team guard in _resolve_db_team_fallback
(get_team_object returns a team or raises, never None) and stop codecov
carryforward for three dead flags whose stale sessions were measured
against old file revisions and sank patch coverage with phantom
executable lines
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 12e7547. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a fallback_to_db_teams configuration flag to LiteLLM_JWTAuth, enabling JWT authentication to fall back to database team memberships when no team claims exist, optionally validating an x-litellm-team-id header against DB membership. Implements supporting helpers, wires them into auth_builder, adds extensive tests, and updates codecov.yaml flag carryforward settings.

Changes

JWT DB Team Fallback

Layer / File(s) Summary
Fallback flag declaration
litellm/proxy/_types.py
Adds fallback_to_db_teams: bool field to LiteLLM_JWTAuth requiring user_id_upsert=True.
Team/alias resolution and enforcement
litellm/proxy/auth/handle_jwt.py
Adjusts alias resolution precedence over team_id_default, and skips "no teams found" enforcement error when fallback is enabled.
Header deferral and role/team sync
litellm/proxy/auth/handle_jwt.py
Extends get_team_id_from_header with a fallback_to_db_teams parameter to defer header validation, and updates sync_user_role_and_teams to preserve DB teams for claim-less tokens.
DB fallback helper methods
litellm/proxy/auth/handle_jwt.py
Adds _resolve_db_team_fallback, _is_team_route_allowed, _raise_header_team_membership_denial, and _validate_header_team_in_db_membership; imports NoReturn.
auth_builder wiring
litellm/proxy/auth/handle_jwt.py
Computes db_team_fallback, resolves DB teams when no claim-based team exists, and enforces passthrough/route/membership checks for header teams.
Test coverage
tests/test_litellm/proxy/auth/test_handle_jwt.py
Reformats existing assertions and adds extensive tests for fallback deferral, model-access selection, route enforcement, membership leakage prevention, and precedence semantics.

Codecov Flag Configuration

Layer / File(s) Summary
Individual flag carryforward settings
codecov.yaml
Adds individual_flags for proxy-mgmt-behavior, security, and proxy-db-schema-migration, each with carryforward: false.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AuthBuilder
  participant JWTAuthManager
  participant DB

  Client->>AuthBuilder: Request with JWT (no team claims) + optional x-litellm-team-id header
  AuthBuilder->>AuthBuilder: Compute db_team_fallback flag
  AuthBuilder->>JWTAuthManager: get_team_id_from_header(fallback_to_db_teams=true)
  JWTAuthManager-->>AuthBuilder: Provisionally deferred header team_id
  AuthBuilder->>JWTAuthManager: _resolve_db_team_fallback(user_id)
  JWTAuthManager->>DB: Load user's team memberships
  DB-->>JWTAuthManager: Team membership rows
  JWTAuthManager-->>AuthBuilder: Selected team_id (model/route gated)
  alt Header team matches resolved team
    AuthBuilder->>JWTAuthManager: _validate_header_team_in_db_membership
    JWTAuthManager->>DB: Check membership
    DB-->>JWTAuthManager: Membership result
    JWTAuthManager->>JWTAuthManager: _is_team_route_allowed
  end
  AuthBuilder-->>Client: Authorized (team_id, membership) or 403 denial
Loading

Related PRs: None mentioned.

Suggested labels: enhancement, security, tests

Suggested reviewers: None specified.

🐰 A JWT arrives with claims all bare,
so the rabbit hops to the DB to check who's there,
teams are found, routes are gauged,
and every fallback path is tested and staged!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: optional DB-team fallback for JWTs without team claims.
Description check ✅ Passed The description covers the required template sections with detailed proof, checklist, type, and changes; blank issue/ticket fields are acceptable.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch litellm_jwt_db_team_fallback

Comment @coderabbitai help to get the list of available commands.

@mateo-berri
mateo-berri merged commit 0855fa0 into litellm_internal_staging Jul 7, 2026
130 checks passed
@mateo-berri
mateo-berri deleted the litellm_jwt_db_team_fallback branch July 7, 2026 00:17
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Verified working on the last commit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants