Skip to content

fix(jwt): implement unregistered_jwt_client_behavior for JWT→virtual key mapping - #25570

Merged
ishaan-berri merged 4 commits into
litellm_ishaan_april13from
litellm_jwt_mapping
Apr 13, 2026
Merged

fix(jwt): implement unregistered_jwt_client_behavior for JWT→virtual key mapping #25570
ishaan-berri merged 4 commits into
litellm_ishaan_april13from
litellm_jwt_mapping

Conversation

@shivamrawat1

Copy link
Copy Markdown
Collaborator

Relevant issues

unregistered_jwt_client_behavior (reject / fallback_team_mapping / auto_register) was documented but never implemented.
_resolve_jwt_to_virtual_key had a single unconditional return None on no-match, so there was no way to enforce access control on
unmapped JWT clients — they always fell through to team auth. Separately, the doc still referenced the renamed field
jwt_client_id_field which LiteLLM_JWTAuth.init now rejects with a ValueError.

Pre-Submission checklist

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

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • 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

Type

🐛 Bug Fix
📖 Documentation
✅ Test

Changes

  • Added UnregisteredJWTClientBehavior enum and unregistered_jwt_client_behavior field to LiteLLM_JWTAuth (default:
    fallback_team_mapping — fully backward-compatible)
  • reject: raises HTTP 403 on both fresh DB miss and cached NO_MAPPING sentinel
  • auto_register: creates a new virtual key + mapping row on first encounter, proceeds as mapped
  • Added backward-compat alias: jwt_client_id_field silently maps to virtual_key_claim_field so existing configs don't break
  • Fixed doc to use virtual_key_claim_field with a deprecation note
  • 5 new unit tests covering all three branches + the alias

@vercel

vercel Bot commented Apr 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 11, 2026 8:31pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing litellm_jwt_mapping (86d8147) with main (da75012)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements the previously documented but unimplemented unregistered_jwt_client_behavior field on LiteLLM_JWTAuth, adding REJECT, AUTO_REGISTER, and FALLBACK_TEAM_MAPPING dispatch logic to _resolve_jwt_to_virtual_key, along with a backward-compat alias for the renamed jwt_client_id_field. The stale-sentinel + AUTO_REGISTER path (a previously flagged gap) is now correctly handled by evicting the sentinel and re-running auto-registration.

Confidence Score: 4/5

Safe to merge with minor cleanup; remaining findings are style/P2 only.

The primary P1 gaps from the previous review round (AUTO_REGISTER silently ignoring a stale sentinel, and missing behavior enforcement) are now fixed. All remaining findings are P2: a direct Prisma write that bypasses the helper convention, and a non-optional type annotation that should be Optional. Neither blocks correct operation.

litellm/proxy/auth/user_api_key_auth.py — the new _auto_register_jwt_mapping helper contains the direct DB write and the type annotation gap.

Important Files Changed

Filename Overview
litellm/proxy/auth/user_api_key_auth.py Adds _auto_register_jwt_mapping helper and updates _resolve_jwt_to_virtual_key to dispatch to REJECT/AUTO_REGISTER/FALLBACK_TEAM_MAPPING; contains a direct Prisma write in the auth critical path and a type annotation inconsistency.
litellm/proxy/_types.py Adds UnregisteredJWTClientBehavior enum and wires it into LiteLLM_JWTAuth; backward-compat alias for jwt_client_id_field is correctly handled in init before the invalid-key check.
tests/proxy_unit_tests/test_jwt_key_mapping.py Adds thorough unit tests for all three behaviors, the stale sentinel + AUTO_REGISTER path, race-condition fallback, and prisma_client=None edge cases; all tests use mocks only (no real network calls).
docs/my-website/docs/proxy/jwt_key_mapping.md Updates doc to use virtual_key_claim_field, adds deprecation note for jwt_client_id_field alias, and expands unregistered_jwt_client_behavior option descriptions; example config shows reject which differs from the code default (fallback_team_mapping) but is acceptable as an illustrative config.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[JWT auth request] --> B{virtual_key_claim_field set?}
    B -- No --> Z[return None → team auth]
    B -- Yes --> C{claim value in JWT?}
    C -- No --> Z
    C -- Yes --> D{Cache hit?}
    D -- token hash --> E[get_key_object → return UserAPIKeyAuth]
    D -- __NO_MAPPING__ sentinel --> F{unregistered_jwt_client_behavior}
    F -- REJECT --> G[raise HTTP 403]
    F -- AUTO_REGISTER + prisma ≠ None --> H[evict sentinel → _auto_register_jwt_mapping]
    F -- FALLBACK_TEAM_MAPPING or no prisma --> Z
    D -- miss --> I{DB lookup via get_jwt_key_mapping_object}
    I -- found --> J[cache token hash → get_key_object]
    I -- not found --> K{unregistered_jwt_client_behavior}
    K -- REJECT --> L[cache sentinel → raise HTTP 403]
    K -- AUTO_REGISTER + prisma ≠ None --> M[generate_key_helper_fn + jwtkeymapping.create]
    K -- AUTO_REGISTER + prisma = None --> N[raise HTTP 500]
    K -- FALLBACK_TEAM_MAPPING --> O[cache sentinel → return None]
    M -- success --> P[cache token hash → return UserAPIKeyAuth]
    M -- unique conflict --> Q[fetch winner mapping → cache + return]
    H --> M
Loading

Reviews (4): Last reviewed commit: "fix(jwt): enforce no-match policy when p..." | Re-trigger Greptile

Comment on lines 587 to 594
if cached_mapping == "__NO_MAPPING__":
behavior = jwt_handler.litellm_jwtauth.unregistered_jwt_client_behavior
if behavior == UnregisteredJWTClientBehavior.REJECT:
raise HTTPException(
status_code=403,
detail=f"JWT Key Mapping: No registered mapping for {virtual_key_claim_field}='{claim_value}'. Access denied.",
)
return None

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.

P1 AUTO_REGISTER silently falls through when cached __NO_MAPPING__ is present

When behavior == AUTO_REGISTER and the cache contains the __NO_MAPPING__ sentinel (written by a prior run under FALLBACK_TEAM_MAPPING), the function returns None — falling through to standard team-based JWT auth — instead of triggering auto-registration. Only REJECT is checked; AUTO_REGISTER is missed entirely in this branch.

if cached_mapping == "__NO_MAPPING__":
    behavior = jwt_handler.litellm_jwtauth.unregistered_jwt_client_behavior
    if behavior == UnregisteredJWTClientBehavior.REJECT:
        raise HTTPException(status_code=403, ...)
    # AUTO_REGISTER lands here → returns None instead of registering
    return None

The fix is to also handle AUTO_REGISTER here (e.g., delete the stale sentinel and fall through to the DB path, or call _auto_register_jwt_mapping directly). Without this, switching a deployment from the default fallback_team_mapping to auto_register silently misbehaves until the cache TTL expires for every previously-seen client.

Comment thread litellm/proxy/auth/user_api_key_auth.py Outdated
Comment on lines +520 to +537
key_data = await generate_key_helper_fn(
request_type="key",
metadata={
"auto_registered": True,
"jwt_claim_field": virtual_key_claim_field,
"jwt_claim_value": claim_value,
},
)
token_hash = key_data["token"]

await prisma_client.db.litellm_jwtkeymapping.create(
data={
"jwt_claim_name": virtual_key_claim_field,
"jwt_claim_value": claim_value,
"token": token_hash,
"created_by": "auto_register",
"updated_by": "auto_register",
}

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.

P1 Race condition creates orphaned keys under concurrent requests

Two simultaneous requests for the same previously-unseen JWT claim both pass the DB lookup (both see no mapping), both call _auto_register_jwt_mapping, and both reach generate_key_helper_fn. Because generate_key_helper_fn runs first and is not guarded, two virtual keys are created before either litellm_jwtkeymapping.create runs. The second create then fails on the @@unique([jwt_claim_name, jwt_claim_value]) constraint, leaving one key permanently orphaned in litellm_verificationtoken with no mapping row.

The fix is to catch the unique-constraint violation in _auto_register_jwt_mapping and, on conflict, fall back to fetching the row the winning request already inserted:

try:
    await prisma_client.db.litellm_jwtkeymapping.create(data={...})
except Exception as e:
    if "unique constraint" in str(e).lower():
        # Another request won the race — fetch their mapping and discard our key
        token_hash = await get_jwt_key_mapping_object(
            jwt_claim_name=virtual_key_claim_field,
            jwt_claim_value=claim_value,
            prisma_client=prisma_client,
        )
        # (optionally delete our orphaned key here)
    else:
        raise

Alternatively, use create_or_update/upsert logic if Prisma supports it for this table.

Comment on lines +516 to +518
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
)

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.

P2 Inline import violates project style guide

CLAUDE.md states "Avoid imports within methods — place all imports at the top of the file (module-level). The only exception is avoiding circular imports where absolutely necessary." If this import is genuinely required here to break a circular dependency, a brief comment explaining the circular import would make the exception clear to future reviewers.

Suggested change
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
)
from litellm.proxy.management_endpoints.key_management_endpoints import (
generate_key_helper_fn,
) # local import to avoid circular dependency: user_api_key_auth ← proxy_server → key_management_endpoints

…ne import comment

- AUTO_REGISTER now evicts stale __NO_MAPPING__ sentinel instead of silently
  returning None when cached under a prior fallback_team_mapping config
- Race condition in _auto_register_jwt_mapping: catch P2002 unique-constraint
  violation on concurrent creates, fetch the winning mapping, proceed cleanly
- Added comment on inline generate_key_helper_fn import explaining the circular
  dependency (key_management_endpoints imports user_api_key_auth at line 51)
- 3 new tests: stale sentinel eviction, race condition winner fallback, and the
  existing auto_register happy path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@shivamrawat1
shivamrawat1 temporarily deployed to integration-postgres April 11, 2026 19:53 — with GitHub Actions Inactive
@shivamrawat1

Copy link
Copy Markdown
Collaborator Author

@greptile re-review now that we have resolved the raised issues

…mode

REJECT mode was raising HTTPException immediately on a DB miss without writing
the __NO_MAPPING__ sentinel, causing every subsequent rejected request to
re-query the DB. Write the sentinel first so repeated rejections are served
from cache within virtual_key_mapping_cache_ttl.

Adds test asserting DB is not hit on the second reject after a cache-warm miss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@shivamrawat1
shivamrawat1 temporarily deployed to integration-postgres April 11, 2026 20:15 — with GitHub Actions Inactive
@shivamrawat1
shivamrawat1 temporarily deployed to integration-postgres April 11, 2026 20:15 — with GitHub Actions Inactive
@shivamrawat1
shivamrawat1 temporarily deployed to integration-postgres April 11, 2026 20:15 — with GitHub Actions Inactive
@shivamrawat1

Copy link
Copy Markdown
Collaborator Author

@greptile re-review with the new commit.

The early `if prisma_client is None: return None` guard ran before the
no-match policy check, silently bypassing REJECT and AUTO_REGISTER — every
JWT client fell through to team auth regardless of configuration.

Fix: treat prisma_client=None as a definitive DB miss and fall through to the
same policy block as a real miss. REJECT now raises 403, AUTO_REGISTER raises
500 with a clear message (can't create keys without a DB), FALLBACK_TEAM_MAPPING
returns None unchanged.

Adds three tests: REJECT/403 with no DB, FALLBACK returns None with no DB,
AUTO_REGISTER/500 with no DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@shivamrawat1
shivamrawat1 temporarily deployed to integration-postgres April 11, 2026 20:29 — with GitHub Actions Inactive
@shivamrawat1

Copy link
Copy Markdown
Collaborator Author

@review again with new commit

@shivamrawat1
shivamrawat1 temporarily deployed to integration-postgres April 11, 2026 20:30 — with GitHub Actions Inactive
@shivamrawat1
shivamrawat1 temporarily deployed to integration-postgres April 11, 2026 20:30 — with GitHub Actions Inactive
@avianion avianion mentioned this pull request Apr 11, 2026
5 tasks
@ishaan-berri
ishaan-berri changed the base branch from main to litellm_ishaan_april13 April 13, 2026 21:25
@ishaan-berri
ishaan-berri merged commit c01c36a into litellm_ishaan_april13 Apr 13, 2026
102 of 108 checks passed
@ishaan-berri
ishaan-berri deleted the litellm_jwt_mapping branch April 13, 2026 21:25
shivamrawat1 added a commit that referenced this pull request May 21, 2026
…orphans

Addresses Greptile review on PR #25570 cherry-pick.

1. Inconsistent AUTO_REGISTER when __NO_MAPPING__ sentinel is cached:
   The cached-sentinel branch silently returned None when prisma_client was
   None, while the fresh path raised HTTP 500 under the same config. Same
   request, different access-control outcome depending on cache state. Both
   paths now raise the same 500.

2. Orphaned virtual keys from race-condition losers:
   On unique-constraint conflict, generate_key_helper_fn had already persisted
   an unrestricted virtual key in LiteLLM_VerificationToken with the cleartext
   in request memory. Under sustained concurrency these accumulated
   indefinitely. The loser now deletes its orphan before falling back to the
   winner's mapping; failure to delete is logged but does not fail the request.

Also corrects a latent FK bug surfaced while fixing #2: the mapping row was
storing the plaintext key in LiteLLM_JWTKeyMapping.token, but that column FKs
to the hashed LiteLLM_VerificationToken.token — now hashed at the call site.

Tests:
- updated test_auto_register_creates_key_and_mapping to assert the hashed
  token is stored, not the plaintext
- updated test_auto_register_race_condition_unique_conflict to assert the
  orphan is deleted with the correct hashed token
- added test_auto_register_raises_500_when_sentinel_cached_and_no_db
- added test_auto_register_race_conflict_tolerates_delete_failure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mateo-berri pushed a commit that referenced this pull request Jun 5, 2026
* restore an explicit no-match policy

* fix(jwt): fix AUTO_REGISTER sentinel bypass, race condition, and inline import comment

- AUTO_REGISTER now evicts stale __NO_MAPPING__ sentinel instead of silently
  returning None when cached under a prior fallback_team_mapping config
- Race condition in _auto_register_jwt_mapping: catch P2002 unique-constraint
  violation on concurrent creates, fetch the winning mapping, proceed cleanly
- Added comment on inline generate_key_helper_fn import explaining the circular
  dependency (key_management_endpoints imports user_api_key_auth at line 51)
- 3 new tests: stale sentinel eviction, race condition winner fallback, and the
  existing auto_register happy path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(jwt): cache __NO_MAPPING__ sentinel before raising 403 in REJECT mode

REJECT mode was raising HTTPException immediately on a DB miss without writing
the __NO_MAPPING__ sentinel, causing every subsequent rejected request to
re-query the DB. Write the sentinel first so repeated rejections are served
from cache within virtual_key_mapping_cache_ttl.

Adds test asserting DB is not hit on the second reject after a cache-warm miss.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(jwt): enforce no-match policy when prisma_client is None

The early `if prisma_client is None: return None` guard ran before the
no-match policy check, silently bypassing REJECT and AUTO_REGISTER — every
JWT client fell through to team auth regardless of configuration.

Fix: treat prisma_client=None as a definitive DB miss and fall through to the
same policy block as a real miss. REJECT now raises 403, AUTO_REGISTER raises
500 with a clear message (can't create keys without a DB), FALLBACK_TEAM_MAPPING
returns None unchanged.

Adds three tests: REJECT/403 with no DB, FALLBACK returns None with no DB,
AUTO_REGISTER/500 with no DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(jwt): consistent AUTO_REGISTER on cached sentinel; clean up race orphans

Addresses Greptile review on PR #25570 cherry-pick.

1. Inconsistent AUTO_REGISTER when __NO_MAPPING__ sentinel is cached:
   The cached-sentinel branch silently returned None when prisma_client was
   None, while the fresh path raised HTTP 500 under the same config. Same
   request, different access-control outcome depending on cache state. Both
   paths now raise the same 500.

2. Orphaned virtual keys from race-condition losers:
   On unique-constraint conflict, generate_key_helper_fn had already persisted
   an unrestricted virtual key in LiteLLM_VerificationToken with the cleartext
   in request memory. Under sustained concurrency these accumulated
   indefinitely. The loser now deletes its orphan before falling back to the
   winner's mapping; failure to delete is logged but does not fail the request.

Also corrects a latent FK bug surfaced while fixing #2: the mapping row was
storing the plaintext key in LiteLLM_JWTKeyMapping.token, but that column FKs
to the hashed LiteLLM_VerificationToken.token — now hashed at the call site.

Tests:
- updated test_auto_register_creates_key_and_mapping to assert the hashed
  token is stored, not the plaintext
- updated test_auto_register_race_condition_unique_conflict to assert the
  orphan is deleted with the correct hashed token
- added test_auto_register_raises_500_when_sentinel_cached_and_no_db
- added test_auto_register_race_conflict_tolerates_delete_failure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(jwt): close REJECT bypass when JWT omits the configured claim field

A JWT presented without the configured `virtual_key_claim_field` previously
returned None at the `claim_value is None` guard before the
`unregistered_jwt_client_behavior` check ran. A caller who knows the configured
claim-field name could bypass REJECT by simply omitting that field and falling
through to team-based JWT auth.

Apply the no-match policy on a missing claim:
  - REJECT          → 403
  - AUTO_REGISTER   → 403 (no stable identity to map; refuse rather than
                     create a sentinel-keyed record)
  - FALLBACK_TEAM_MAPPING → return None (unchanged, backward-compatible)

Adds three tests covering each branch of the missing-claim path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(jwt): AUTO_REGISTER inherits team_id so keys are bounded by team limits

Auto-registered virtual keys were created with no team, model, route, rate, or
budget constraints — broader access than the standard team-based JWT auth path
the same client would have taken. Under AUTO_REGISTER, resolve the team_id
from the JWT (via the operator-configured team_id_jwt_field / team_id_default)
and stamp it on the new key. Downstream auth then applies the team's
budget/models/tpm/rpm/allowed_routes via the existing virtual-key flow.

Policy when team_id_jwt_field is configured:
  - JWT carries team claim → stamp resolved team_id
  - JWT lacks claim + team_id_default set → stamp default
  - JWT lacks claim + no default → 403 (refuse to create an unbounded key)

When neither team_id_jwt_field nor team_id_default is configured, the
operator has explicitly opted out of team-based limits — the auto-created
key has no team_id (matches what team-auth would do in the same config).

Adds 4 tests covering each branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(jwt): make AUTO_REGISTER functional in prod; raise on missing winner

Two correctness fixes flagged by Greptile on the AUTO_REGISTER path:

1. generate_key_helper_fn was called without table_name="key". Without that,
   the helper falls into the user-upsert branch (table_name in (None, "user"))
   and tries to insert into LiteLLM_UserTable with user_id=None, which hits
   the NOT NULL @id constraint. AUTO_REGISTER would never have succeeded in
   production. Now passes table_name="key" explicitly, matching the
   /key/generate caller.

2. When the race loser refetches the winner's mapping and gets None (winner
   row concurrently deleted), the previous code returned None — and the
   caller in _resolve_jwt_to_virtual_key then fell through to less-
   restrictive team-based JWT auth, silently bypassing the configured
   AUTO_REGISTER policy. Now raises HTTP 503 so the caller retries against
   a stable state rather than getting unintended fallback access.

Adds one test for the 503 winner-vanishes path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(jwt): defer AUTO_REGISTER until JWT policy is enforced by auth_builder

Closes the JWT policy bypass on the AUTO_REGISTER path flagged by veria-ai.

Before: when unregistered_jwt_client_behavior=auto_register and the JWT's
claim was unmapped, _resolve_jwt_to_virtual_key validated the JWT signature
and then immediately created a virtual key + mapping. JWTAuthManager.auth_builder
never ran for the first request (the new key short-circuited the team-auth
path), and every subsequent request hit the cached mapping — so custom_validate,
RBAC, scope_mappings, and user_allowed_email_domain were never enforced for
auto-registered clients.

After: _resolve_jwt_to_virtual_key returns a _PendingAutoRegister signal
instead of creating the key. The caller in _user_api_key_auth_builder runs
JWTAuthManager.auth_builder, then — only on a validated, policy-passing
result — calls _auto_register_jwt_mapping with the team_id / user_id from
that result. The created key inherits team + user limits from the validated
identity, and future cache hits load that already-policy-checked key.

Also drops the interim _resolve_inherited_team_id helper that pulled team_id
from raw JWT claims — same bypass risk; team_id now comes exclusively from
auth_builder.

Tests:
  - Rewrote two existing tests to assert _resolve_jwt_to_virtual_key returns
    _PendingAutoRegister (no key created yet) for both the fresh-DB-miss
    and stale-sentinel branches
  - Added a contract test that _auto_register_jwt_mapping stamps the
    validated team_id/user_id onto generate_key_helper_fn
  - Removed four stale team-binding tests that exercised the prior
    raw-claim helper

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update user_api_key_auth.py

* fix(jwt): cache proxy-admin AUTO_REGISTER path to avoid repeated DB lookups

Cache-miss regression introduced by the deferred-auto-register refactor:
when a JWT under AUTO_REGISTER resolved to a proxy admin, the is_proxy_admin
early-return in _user_api_key_auth_builder ran *before* the pending
auto-register cache-write block. Result: no cache entry, so every
subsequent proxy-admin request re-queried get_jwt_key_mapping_object
indefinitely.

Fix: write a __JWT_PROXY_ADMIN__ sentinel to user_api_key_cache before the
early return when a pending auto-register existed. _resolve_jwt_to_virtual_key
treats that sentinel as "skip mapping, fall through to auth_builder", so
future requests from the same JWT identity hit the cache instead of the DB.
auth_builder still runs full JWT policy on every request — only the
mapping DB lookup is short-circuited.

Adds one test asserting the sentinel cache-hit returns None without
hitting prisma_client.db.litellm_jwtkeymapping.find_first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(proxy): stamp org context on JWT auto-registered keys

AUTO_REGISTER keys were created with team_id and user_id only, so org budget checks were skipped after switching to the key-scoped path.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
* restore an explicit no-match policy

* fix(jwt): fix AUTO_REGISTER sentinel bypass, race condition, and inline import comment

- AUTO_REGISTER now evicts stale __NO_MAPPING__ sentinel instead of silently
  returning None when cached under a prior fallback_team_mapping config
- Race condition in _auto_register_jwt_mapping: catch P2002 unique-constraint
  violation on concurrent creates, fetch the winning mapping, proceed cleanly
- Added comment on inline generate_key_helper_fn import explaining the circular
  dependency (key_management_endpoints imports user_api_key_auth at line 51)
- 3 new tests: stale sentinel eviction, race condition winner fallback, and the
  existing auto_register happy path


* fix(jwt): cache __NO_MAPPING__ sentinel before raising 403 in REJECT mode

REJECT mode was raising HTTPException immediately on a DB miss without writing
the __NO_MAPPING__ sentinel, causing every subsequent rejected request to
re-query the DB. Write the sentinel first so repeated rejections are served
from cache within virtual_key_mapping_cache_ttl.

Adds test asserting DB is not hit on the second reject after a cache-warm miss.


* fix(jwt): enforce no-match policy when prisma_client is None

The early `if prisma_client is None: return None` guard ran before the
no-match policy check, silently bypassing REJECT and AUTO_REGISTER — every
JWT client fell through to team auth regardless of configuration.

Fix: treat prisma_client=None as a definitive DB miss and fall through to the
same policy block as a real miss. REJECT now raises 403, AUTO_REGISTER raises
500 with a clear message (can't create keys without a DB), FALLBACK_TEAM_MAPPING
returns None unchanged.

Adds three tests: REJECT/403 with no DB, FALLBACK returns None with no DB,
AUTO_REGISTER/500 with no DB.


* fix(jwt): consistent AUTO_REGISTER on cached sentinel; clean up race orphans

Addresses Greptile review on PR BerriAI#25570 cherry-pick.

1. Inconsistent AUTO_REGISTER when __NO_MAPPING__ sentinel is cached:
   The cached-sentinel branch silently returned None when prisma_client was
   None, while the fresh path raised HTTP 500 under the same config. Same
   request, different access-control outcome depending on cache state. Both
   paths now raise the same 500.

2. Orphaned virtual keys from race-condition losers:
   On unique-constraint conflict, generate_key_helper_fn had already persisted
   an unrestricted virtual key in LiteLLM_VerificationToken with the cleartext
   in request memory. Under sustained concurrency these accumulated
   indefinitely. The loser now deletes its orphan before falling back to the
   winner's mapping; failure to delete is logged but does not fail the request.

Also corrects a latent FK bug surfaced while fixing #2: the mapping row was
storing the plaintext key in LiteLLM_JWTKeyMapping.token, but that column FKs
to the hashed LiteLLM_VerificationToken.token — now hashed at the call site.

Tests:
- updated test_auto_register_creates_key_and_mapping to assert the hashed
  token is stored, not the plaintext
- updated test_auto_register_race_condition_unique_conflict to assert the
  orphan is deleted with the correct hashed token
- added test_auto_register_raises_500_when_sentinel_cached_and_no_db
- added test_auto_register_race_conflict_tolerates_delete_failure


* fix(jwt): close REJECT bypass when JWT omits the configured claim field

A JWT presented without the configured `virtual_key_claim_field` previously
returned None at the `claim_value is None` guard before the
`unregistered_jwt_client_behavior` check ran. A caller who knows the configured
claim-field name could bypass REJECT by simply omitting that field and falling
through to team-based JWT auth.

Apply the no-match policy on a missing claim:
  - REJECT          → 403
  - AUTO_REGISTER   → 403 (no stable identity to map; refuse rather than
                     create a sentinel-keyed record)
  - FALLBACK_TEAM_MAPPING → return None (unchanged, backward-compatible)

Adds three tests covering each branch of the missing-claim path.


* fix(jwt): AUTO_REGISTER inherits team_id so keys are bounded by team limits

Auto-registered virtual keys were created with no team, model, route, rate, or
budget constraints — broader access than the standard team-based JWT auth path
the same client would have taken. Under AUTO_REGISTER, resolve the team_id
from the JWT (via the operator-configured team_id_jwt_field / team_id_default)
and stamp it on the new key. Downstream auth then applies the team's
budget/models/tpm/rpm/allowed_routes via the existing virtual-key flow.

Policy when team_id_jwt_field is configured:
  - JWT carries team claim → stamp resolved team_id
  - JWT lacks claim + team_id_default set → stamp default
  - JWT lacks claim + no default → 403 (refuse to create an unbounded key)

When neither team_id_jwt_field nor team_id_default is configured, the
operator has explicitly opted out of team-based limits — the auto-created
key has no team_id (matches what team-auth would do in the same config).

Adds 4 tests covering each branch.


* fix(jwt): make AUTO_REGISTER functional in prod; raise on missing winner

Two correctness fixes flagged by Greptile on the AUTO_REGISTER path:

1. generate_key_helper_fn was called without table_name="key". Without that,
   the helper falls into the user-upsert branch (table_name in (None, "user"))
   and tries to insert into LiteLLM_UserTable with user_id=None, which hits
   the NOT NULL @id constraint. AUTO_REGISTER would never have succeeded in
   production. Now passes table_name="key" explicitly, matching the
   /key/generate caller.

2. When the race loser refetches the winner's mapping and gets None (winner
   row concurrently deleted), the previous code returned None — and the
   caller in _resolve_jwt_to_virtual_key then fell through to less-
   restrictive team-based JWT auth, silently bypassing the configured
   AUTO_REGISTER policy. Now raises HTTP 503 so the caller retries against
   a stable state rather than getting unintended fallback access.

Adds one test for the 503 winner-vanishes path.


* fix(jwt): defer AUTO_REGISTER until JWT policy is enforced by auth_builder

Closes the JWT policy bypass on the AUTO_REGISTER path flagged by veria-ai.

Before: when unregistered_jwt_client_behavior=auto_register and the JWT's
claim was unmapped, _resolve_jwt_to_virtual_key validated the JWT signature
and then immediately created a virtual key + mapping. JWTAuthManager.auth_builder
never ran for the first request (the new key short-circuited the team-auth
path), and every subsequent request hit the cached mapping — so custom_validate,
RBAC, scope_mappings, and user_allowed_email_domain were never enforced for
auto-registered clients.

After: _resolve_jwt_to_virtual_key returns a _PendingAutoRegister signal
instead of creating the key. The caller in _user_api_key_auth_builder runs
JWTAuthManager.auth_builder, then — only on a validated, policy-passing
result — calls _auto_register_jwt_mapping with the team_id / user_id from
that result. The created key inherits team + user limits from the validated
identity, and future cache hits load that already-policy-checked key.

Also drops the interim _resolve_inherited_team_id helper that pulled team_id
from raw JWT claims — same bypass risk; team_id now comes exclusively from
auth_builder.

Tests:
  - Rewrote two existing tests to assert _resolve_jwt_to_virtual_key returns
    _PendingAutoRegister (no key created yet) for both the fresh-DB-miss
    and stale-sentinel branches
  - Added a contract test that _auto_register_jwt_mapping stamps the
    validated team_id/user_id onto generate_key_helper_fn
  - Removed four stale team-binding tests that exercised the prior
    raw-claim helper


* Update user_api_key_auth.py

* fix(jwt): cache proxy-admin AUTO_REGISTER path to avoid repeated DB lookups

Cache-miss regression introduced by the deferred-auto-register refactor:
when a JWT under AUTO_REGISTER resolved to a proxy admin, the is_proxy_admin
early-return in _user_api_key_auth_builder ran *before* the pending
auto-register cache-write block. Result: no cache entry, so every
subsequent proxy-admin request re-queried get_jwt_key_mapping_object
indefinitely.

Fix: write a __JWT_PROXY_ADMIN__ sentinel to user_api_key_cache before the
early return when a pending auto-register existed. _resolve_jwt_to_virtual_key
treats that sentinel as "skip mapping, fall through to auth_builder", so
future requests from the same JWT identity hit the cache instead of the DB.
auth_builder still runs full JWT policy on every request — only the
mapping DB lookup is short-circuited.

Adds one test asserting the sentinel cache-hit returns None without
hitting prisma_client.db.litellm_jwtkeymapping.find_first.


* fix(proxy): stamp org context on JWT auto-registered keys

AUTO_REGISTER keys were created with team_id and user_id only, so org budget checks were skipped after switching to the key-scoped path.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

2 participants