Skip to content

[Feature] Disable Custom Virtual Key Values via UI Setting#23812

Merged
yuneng-jiang merged 5 commits intolitellm_yj_march_16_2026from
litellm_disable_custom_api_keys
Mar 17, 2026
Merged

[Feature] Disable Custom Virtual Key Values via UI Setting#23812
yuneng-jiang merged 5 commits intolitellm_yj_march_16_2026from
litellm_disable_custom_api_keys

Conversation

@yuneng-jiang
Copy link
Copy Markdown
Contributor

@yuneng-jiang yuneng-jiang commented Mar 17, 2026

Relevant issues

Closes LIT-1710

Summary

Problem

Users can override their Virtual key value in Advanced Settings, which introduces a security risk — if two users set the same custom key, they generate identical key hashes, potentially causing key collision or unauthorized access in multi-tenant environments.

Fix

  • Adds a new disable_custom_api_keys UI setting that admins can toggle from the UI Settings page or via PATCH /update/ui_settings
  • When enabled, the backend rejects any custom key value on /key/generate (403) and any custom new_key on /key/regenerate (403)
  • The UI hides the key field from Advanced Settings in the Create Key form when the setting is on
  • Adds a "Disable custom Virtual key values" toggle to the admin UI Settings page

Testing

  • 6 new unit tests for _check_custom_key_allowed and get_new_token integration
  • 2 existing get_new_token tests updated to match async signature
  • E2E tested all paths against running proxy (generate, regenerate, toggle on/off)

Type

🆕 New Feature
✅ Test

Add disable_custom_api_keys UI setting that prevents users from specifying
custom key values during key generation and regeneration. When enabled, all
keys must be auto-generated, eliminating the risk of key hash collisions
in multi-tenant environments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 17, 2026

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

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 17, 2026 5:37am

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR introduces a disable_custom_api_keys admin toggle that prevents users from specifying custom virtual key values on both the /key/generate and /key/regenerate endpoints, addressing a multi-tenant key-collision security risk. The backend enforces the restriction via a new _check_custom_key_allowed helper that queries the cached UI settings, and the Create Key UI hides the key field when the setting is enabled.

Key changes:

  • New _check_custom_key_allowed async guard raises HTTP 403 when disable_custom_api_keys is enabled and a custom key value is supplied
  • get_new_token converted from sync to async to allow the settings check on the regenerate path
  • disable_custom_api_keys added to UISettings Pydantic model, ALLOWED_UI_SETTINGS_FIELDS, and the admin UISettings page toggle list
  • Create Key form conditionally excludes the key field via excludedFields when the setting is on
  • 6 new unit tests and 2 updated async tests provide good unit-level coverage

Outstanding concerns (from prior review threads, still unresolved in current code):

  • ui_settings.get("disable_custom_api_keys", False) is True uses strict identity comparison — a truthy non-True value (e.g. integer 1) would silently bypass the guard
  • _check_custom_key_allowed is called after _enforce_unique_key_alias in _common_key_generation_helper, violating the fail-fast pattern for access controls
  • When disableCustomApiKeys transitions to true mid-session, Ant Design's Form retains the typed key value — the excluded field's value is never explicitly cleared, so the stale value is still submitted with the payload

Confidence Score: 3/5

  • Mergeable with caution — the security intent is sound, but three issues from prior review threads remain unaddressed and could cause the guard to be silently bypassed or to fire too late in the request pipeline.
  • Score reflects that the core logic is correct and cache invalidation is properly handled, but the strict is True identity check could silently pass for truthy non-boolean values, the fail-fast violation means an unnecessary DB lookup occurs before the 403 fires, and the stale Ant Design form value can still submit a key field the user can no longer see — all three flagged previously and still present in the current diff.
  • litellm/proxy/management_endpoints/key_management_endpoints.py (strict is True check and check ordering) and ui/litellm-dashboard/src/components/organisms/create_key_button.tsx (stale form field value on toggle)

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/key_management_endpoints.py Adds _check_custom_key_allowed guard called in _common_key_generation_helper and get_new_token; get_new_token was converted from sync to async; the security check fires after the _enforce_unique_key_alias DB lookup rather than before it (fail-fast violation), and uses a strict is True identity check instead of a truthiness test — both flagged in prior review threads and still unresolved.
litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py Adds disable_custom_api_keys field to UISettings model and its allowlist; cache is correctly refreshed on update; no issues found.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Six new unit tests added; two existing tests updated to async signature; test_get_new_token_auto_generates_when_custom_keys_disabled sets up a mock that is never invoked (unreachable code path), giving a false sense of cache-bypass verification.
ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx Adds a toggle for disable_custom_api_keys following the same pattern as existing toggles; no issues found.
ui/litellm-dashboard/src/components/organisms/create_key_button.tsx Reads disable_custom_api_keys and conditionally excludes the key field from the Advanced Settings SchemaFormFields; however, the stale form value issue (field removed from render but not cleared from AntD Form state) is flagged in a prior review thread and remains unresolved.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[POST /key/generate or /key/regenerate] --> B{custom key value\nprovided?}
    B -- No --> C[Auto-generate sk- token]
    C --> D[Return new key]
    B -- Yes --> E[_check_custom_key_allowed]
    E --> F[get_ui_settings_cached\ncache TTL=10min]
    F --> G{disable_custom_api_keys\n== True?}
    G -- No --> H[Proceed with custom key]
    H --> I[Validate sk- prefix]
    I --> J[Hash and store key]
    J --> D
    G -- Yes --> K[Raise HTTP 403\nCustom keys disabled]
    K --> L[Return 403 to caller]

    style K fill:#ff6b6b,color:#fff
    style G fill:#ffd93d
    style F fill:#a8e6cf
Loading

Last reviewed commit: 471e0f1

return

ui_settings = await get_ui_settings_cached()
if ui_settings.get("disable_custom_api_keys", False) is True:
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 Fragile strict is True equality check

ui_settings.get("disable_custom_api_keys", False) is True will only match the Python boolean True. If the value is stored/deserialized as the string "true" or the integer 1 (both of which are truthy), the guard silently passes and the setting is ignored. The standard Python idiom is a simple truthiness check:

Suggested change
if ui_settings.get("disable_custom_api_keys", False) is True:
if ui_settings.get("disable_custom_api_keys", False):

Comment on lines +102 to +117
async def _check_custom_key_allowed(custom_key_value: Optional[str]) -> None:
"""Raise 403 if custom API keys are disabled and a custom key was provided."""
if custom_key_value is None:
return

ui_settings = await get_ui_settings_cached()
if ui_settings.get("disable_custom_api_keys", False) is True:
verbose_proxy_logger.warning(
"Custom API key rejected: disable_custom_api_keys is enabled"
)
raise HTTPException(
status_code=403,
detail={
"error": "Custom API key values are disabled by your administrator. Keys must be auto-generated."
},
)
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 DB call on every key-generation request (critical path)

_check_custom_key_allowed calls get_ui_settings_cached() unconditionally — even when custom_key_value is None (i.e., the common case where the user does not supply a custom key). The early-return only fires before get_ui_settings_cached() is invoked, so an unnecessary cache/DB lookup still occurs for every auto-generated key request. Per project rules, critical-path code must avoid unnecessary DB/cache requests.

Move the None guard so that the settings lookup is skipped entirely when no custom key is being requested:

async def _check_custom_key_allowed(custom_key_value: Optional[str]) -> None:
    """Raise 403 if custom API keys are disabled and a custom key was provided."""
    if custom_key_value is None:
        return  # no custom key – nothing to check

    ui_settings = await get_ui_settings_cached()
    if ui_settings.get("disable_custom_api_keys", False):
        ...

Wait — that IS the current code. The issue is that the None check at line 104 returns before the get_ui_settings_cached() call, which is correct. However, _common_key_generation_helper (line 696) and get_new_token (line 3509) call this function inside the request path on every key-generation and key-regeneration call. While the cache mitigates the DB overhead, the async cache lookup still adds latency on every request even when custom_key_value is None would have bailed out first — specifically in get_new_token, the call happens even for the auto-generate branch (data.new_key is None) since _check_custom_key_allowed is called after the if data and data.new_key is not None guard.

Actually, let me re-read:

async def get_new_token(data: Optional[RegenerateKeyRequest]) -> str:
    if data and data.new_key is not None:
        # Reject custom key values if disabled by admin
        await _check_custom_key_allowed(data.new_key)

OK so in get_new_token, the call is inside the if data and data.new_key is not None: guard, so the None check inside _check_custom_key_allowed is redundant but not harmful for that path.

For _common_key_generation_helper (line 696), data.key may be None, and _check_custom_key_allowed is called unconditionally — but then it returns early at the None check on line 104. So actually there is no extra cache hit when data.key is None.

Wait, but that means every call to _common_key_generation_helper — even those where no custom key is provided — invokes an async function that starts executing (just to check None). The function itself does not make a cache call when custom_key_value is None, due to the early return. So actually the implementation is correct on performance grounds.

Let me remove this comment and only keep the stricter logic issue (is True vs truthy).

Actually, let me reconsider the is True issue. This is still a valid concern. Let me check what value JSON deserialization would give - JSON booleans are parsed as Python booleans, so True/False. If the value was stored as an integer by accident it would fail. But for the standard JSON boolean, is True would work correctly. Let me think about whether this is a real issue...

Actually, looking at the code in get_ui_settings_cached:

ui_settings = json.loads(raw) if isinstance(raw, str) else dict(raw)

JSON parsing will give proper Python booleans for JSON booleans. So is True would work correctly for properly stored values. However, using is True instead of a truthiness check is still a style concern - it's non-idiomatic and could fail in edge cases (e.g., if someone stores 1 or "true" directly).

I'll keep this as a style/logic note.

Now let me think about other issues:

  1. UI bypass: The UI hides the key field, but this is purely a client-side mitigation. A malicious user could still send the key field in the JSON body directly (bypassing the UI). The backend check in _check_custom_key_allowed is the actual enforcement mechanism, and it does work at the backend level. This is the correct design.

  2. No exemption for proxy admins: The PR description says "No exemptions — proxy admins are also blocked when the setting is enabled." This could be a design concern - maybe proxy admins should be able to bypass this restriction? But it's stated as an explicit design decision, so I'll mention it as a design note rather than a bug.

  3. Cache invalidation: The 10-minute TTL means that if an admin disables disable_custom_api_keys, there's a window where users can still create custom keys. This is acceptable for a UI setting.

  4. Test mock patching: In the tests, the monkeypatch targets litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached. This is correct as the function is imported directly.

  5. Let me think about whether there's anything in the UI changes that has issues...

Looking at the UI:

const disableCustomApiKeys = Boolean(uiSettingsData?.values?.disable_custom_api_keys);

This just hides the field. The backend does the actual enforcement.

  1. Missing UI feedback: When disable_custom_api_keys is enabled, the key field is simply hidden from the "Advanced Settings" accordion. There's no visible message to users explaining why the field is missing. This is a UX improvement suggestion, not a bug.

  2. The is True check: As I discussed, this is using strict identity comparison instead of truthiness. This is a potential logic issue if values are stored in different formats.

Let me focus on the most meaningful issues:

  1. The is True strict comparison (could silently fail to enforce the restriction if value isn't Python True)
  2. The PR description says "No exemptions — proxy admins are also blocked" - this might be a design concern worth flagging, as it deviates from usual patterns where proxy admins get elevated permissions

Let me also check if there's any issue with the UI change - does the form still submit the key field even when hidden? Looking at the code, excludedFields should prevent it. Let me verify by reading more context...

Actually, let me check what excludedFields does in this component - does it prevent the field from being submitted, or just hide it visually? If it only hides the field visually but still submits the value (e.g., if the form had a pre-filled value), there could be an issue where the UI sends the field and the backend rejects it.

Actually I think the excludedFields likely excludes the field from the form schema/display entirely, so it wouldn't be submitted. The backend check is still the authoritative guard.

  1. Stale cache on toggle: If an admin enables disable_custom_api_keys, existing users with the UI still showing the key field (before their browser refreshes) could still submit a custom key. The backend would reject it (after the cache propagates), but the UX would be jarring. This is a minor UX issue.

Let me look at the is True check more carefully. This is the main logic issue worth flagging.

Let me also think about race conditions - get_ui_settings_cached reads from cache or DB. If the admin has just changed the setting, there's a 10-minute window where custom keys might still be accepted. But this is the expected behavior with TTL caching and is not a bug per se.

Let me finalize the review now.

Without this field on the model, GET /get/ui_settings omits the setting
from the response and field_schema, preventing the UI from reading it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
"budget_duration",
"tpm_limit",
"rpm_limit",
...(disableCustomApiKeys ? ["key"] : []),
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 Stale form value may survive field exclusion

When disableCustomApiKeys transitions from false to true mid-session (e.g. the admin toggles the setting while a user has the "Create Key" modal open), Ant Design's Form instance retains any value the user had already typed into the key field even after it is removed from the rendered fields. The SchemaFormFields useEffect re-runs because excludedFields changed, but it only calls form.setFieldsValue with default values for non-excluded fields — it never clears the now-excluded key field. As a result form.validateFields() will still include key: "sk-..." in the submitted payload, causing a confusing 403 from the backend for a field the user can no longer even see.

The fix is to explicitly clear the field when the flag becomes active:

useEffect(() => {
  if (disableCustomApiKeys) {
    form.setFieldValue("key", undefined);
  }
}, [disableCustomApiKeys, form]);

Add this useEffect alongside the existing form / disableCustomApiKeys declarations.

Comment on lines +1060 to +1080
from litellm.proxy.management_endpoints.key_management_endpoints import (
_check_custom_key_allowed,
)

monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={}),
)

# Should not raise
await _check_custom_key_allowed("sk-custom-key-123")


@pytest.mark.asyncio
async def test_check_custom_key_allowed_none_key_always_passes(monkeypatch):
"""_check_custom_key_allowed does nothing when key is None, even if setting is on."""
from unittest.mock import AsyncMock

from litellm.proxy.management_endpoints.key_management_endpoints import (
_check_custom_key_allowed,
)
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 Unnecessary mock in None-key test

_check_custom_key_allowed(None) returns early at line 104 (if custom_key_value is None: return) before ever calling get_ui_settings_cached. The AsyncMock set up in test_check_custom_key_allowed_none_key_always_passes is therefore never invoked, which means the test doesn't actually verify that the cache is skipped on a None key — and the mock gives a false sense of security about the DB-bypass guarantee.

Consider either removing the mock entirely (which makes the contract explicit: no DB call needed at all), or asserting that the mock was not called to document the expected fast-path behaviour:

mock_get_settings = AsyncMock(return_value={"disable_custom_api_keys": True})
monkeypatch.setattr(
    "litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
    mock_get_settings,
)
await _check_custom_key_allowed(None)
mock_get_settings.assert_not_awaited()  # None key must not trigger a cache/DB lookup

yuneng-jiang and others added 2 commits March 16, 2026 22:26
Adds a toggle switch to the admin UI Settings page so administrators can
enable/disable custom API key values without making direct API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yuneng-jiang yuneng-jiang changed the title [Feature] Disable Custom API Key Values via UI Setting [Feature] Disable Custom Virtual Key Values via UI Setting Mar 17, 2026
Comment on lines +695 to +696
# Reject custom key values if disabled by admin
await _check_custom_key_allowed(data.key)
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 Fail-fast: move check before alias-uniqueness lookup

The _check_custom_key_allowed call is positioned after _enforce_unique_key_alias, which performs a database lookup whenever key_alias is set. When the disable_custom_api_keys flag is enabled, a request that provides both a custom key and a key_alias will trigger the alias-uniqueness lookup unnecessarily before receiving the 403 response.

Moving _check_custom_key_allowed to before _validate_key_alias_format and _enforce_unique_key_alias ensures the security guard fires before any expensive lookups, which is the standard fail-fast pattern for access controls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yuneng-jiang yuneng-jiang merged commit a087c44 into litellm_yj_march_16_2026 Mar 17, 2026
5 checks passed
Comment on lines +1117 to +1134
async def test_get_new_token_auto_generates_when_custom_keys_disabled(monkeypatch):
"""get_new_token auto-generates a key when new_key is None, even if setting is on."""
from unittest.mock import AsyncMock

from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
get_new_token,
)

monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={"disable_custom_api_keys": True}),
)

data = RegenerateKeyRequest() # no new_key
result = await get_new_token(data)

assert result.startswith("sk-")
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 Unreachable mock in auto-generate test

The AsyncMock for get_ui_settings_cached set up in this test is never invoked. When data.new_key is None, get_new_token enters the else branch immediately:

async def get_new_token(data):
    if data and data.new_key is not None:   # ← False when new_key is None
        await _check_custom_key_allowed(...)  # ← never reached
    else:
        new_token = f"sk-{secrets.token_urlsafe(...)}"  # ← always taken here

_check_custom_key_allowed (and therefore get_ui_settings_cached) is never called, so the mock has no effect on the test outcome. The mock creates a false impression that this test verifies cache-bypass behaviour, similar to the pattern already identified in test_check_custom_key_allowed_none_key_always_passes. Consider removing the mock to make the test contract explicit, or asserting mock_get_settings.assert_not_awaited() to document the expected fast-path.

Suggested change
async def test_get_new_token_auto_generates_when_custom_keys_disabled(monkeypatch):
"""get_new_token auto-generates a key when new_key is None, even if setting is on."""
from unittest.mock import AsyncMock
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
get_new_token,
)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
AsyncMock(return_value={"disable_custom_api_keys": True}),
)
data = RegenerateKeyRequest() # no new_key
result = await get_new_token(data)
assert result.startswith("sk-")
@pytest.mark.asyncio
async def test_get_new_token_auto_generates_when_custom_keys_disabled(monkeypatch):
"""get_new_token auto-generates a key when new_key is None, even if setting is on."""
from unittest.mock import AsyncMock
from litellm.proxy._types import RegenerateKeyRequest
from litellm.proxy.management_endpoints.key_management_endpoints import (
get_new_token,
)
mock_get_settings = AsyncMock(return_value={"disable_custom_api_keys": True})
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.get_ui_settings_cached",
mock_get_settings,
)
data = RegenerateKeyRequest() # no new_key
result = await get_new_token(data)
assert result.startswith("sk-")
mock_get_settings.assert_not_awaited() # None new_key must not trigger cache/DB

@ishaan-berri ishaan-berri deleted the litellm_disable_custom_api_keys branch March 26, 2026 22:29
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.

1 participant