Skip to content

fix: /key/block and /key/unblock return 404 (not 401) for non-existent keys#23977

Merged
yuneng-jiang merged 6 commits intolitellm_yj_march_18_2026from
litellm_key-endpoint-authentication-7c1d
Mar 18, 2026
Merged

fix: /key/block and /key/unblock return 404 (not 401) for non-existent keys#23977
yuneng-jiang merged 6 commits intolitellm_yj_march_18_2026from
litellm_key-endpoint-authentication-7c1d

Conversation

@yuneng-jiang
Copy link
Copy Markdown
Contributor

Relevant issues

Fixes issue where /key/block, /key/unblock return 401 Authentication Error for a non-existent body key even when the Authorization header contains a valid master key.

Pre-Submission checklist

  • 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

Type

🐛 Bug Fix

Changes

Problem

curl -s -X POST "http://localhost:4000/key/block" \
  -H "Authorization: Bearer $MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"sk-does-not-exist"}'
# Returns 401 "Authentication Error, Invalid proxy server token passed..."

Despite the caller authenticating correctly with a valid master key, /key/block and /key/unblock returned 401 Authentication Error when the body key didn't exist in the database.

Root Cause

The bug was NOT in the auth layer. Authentication passed correctly via the Authorization: Bearer header. The 401 came from inside the handler functions:

  1. After auth passed, block_key()/unblock_key() called prisma_client.db.litellm_verificationtoken.update() which silently returns None for non-existent records (Prisma doesn't error on missing rows for update).
  2. Then they called get_key_object() for cache refresh — this function was designed for auth token lookup and raises ProxyException(code=401, message="Authentication Error...") when a key isn't found.
  3. The 401 with "Authentication Error" message propagated to the client, making it look like an auth failure.

Fix

  • Added an explicit existence check (find_unique) before the update operation in both block_key() and unblock_key()
  • Returns 404 Not Found with "Key not found. Passed key=..." when the key doesn't exist
  • Replaced the problematic get_key_object() + _cache_key_object() cache refresh with _delete_cache_key_object() (invalidate cache so next read re-fetches from DB)
  • Reused the find_unique result for audit logs, eliminating duplicate queries

After Fix

curl -s -X POST "http://localhost:4000/key/block" \
  -H "Authorization: Bearer $MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key":"sk-does-not-exist"}'
# Returns 404 "Key not found. Passed key=sk-does-not-exist"

Tests Added

  • test_block_key_nonexistent_key_returns_404 — verifies 404 (not 401) for missing key
  • test_unblock_key_nonexistent_key_returns_404 — same for unblock
  • test_block_key_existing_key_succeeds — verifies success path + cache invalidation
  • Updated test_unblock_key_supports_both_sk_and_hashed_tokens for new cache pattern

All 161 tests in test_key_management_endpoints.py pass.

Open in Web Open in Cursor 

cursoragent and others added 4 commits March 18, 2026 08:41
… for non-existent keys

The block_key() and unblock_key() handlers previously returned a
misleading 401 'Authentication Error' when the body 'key' didn't exist
in the database, even though authentication (via Authorization header)
succeeded correctly.

Root cause: After auth passed, the handlers called get_key_object() for
cache refresh. This function was designed for auth token lookup and
raises ProxyException(code=401) when a token isn't found. Additionally,
Prisma's update() silently returns None for non-existent records instead
of raising an error, so the code reached get_key_object() without
detecting the missing key.

Fix:
- Add an explicit existence check (find_unique) before the update
- Return 404 ProxyException with 'Key not found' if the key doesn't exist
- Replace get_key_object() + manual cache update with _delete_cache_key_object()
  to invalidate the cache (next read will re-fetch from DB)
- Reuse the find_unique result for audit logs, eliminating duplicate queries

Co-authored-by: yuneng-jiang <[email protected]>
- test_block_key_nonexistent_key_returns_404: verifies block_key returns
  404 (not misleading 401) when the key doesn't exist in the DB
- test_unblock_key_nonexistent_key_returns_404: same for unblock_key
- test_block_key_existing_key_succeeds: verifies block_key succeeds and
  invalidates cache for existing keys
- Update test_unblock_key_supports_both_sk_and_hashed_tokens to reflect
  the new cache invalidation pattern (_delete_cache_key_object instead
  of get_key_object + _cache_key_object)

Co-authored-by: yuneng-jiang <[email protected]>
These were only used in block_key/unblock_key for cache refresh,
which now uses _delete_cache_key_object instead.

Co-authored-by: yuneng-jiang <[email protected]>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 18, 2026

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

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 18, 2026 11:24pm

Request Review

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 18, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 18, 2026

Greptile Summary

This PR fixes a misleading 401 Authentication Error returned by /key/block and /key/unblock when the body key doesn't exist in the database, replacing it with a proper 404 Not Found. The root cause was that proxy-admin callers bypassed the existing existence check in _check_key_admin_access and then fell into get_key_object(), a function designed for auth token lookup that raises a 401 on a missing key.

Key changes:

  • Added an explicit find_unique existence check in both block_key() and unblock_key() that raises ProxyException(404) for missing keys, targeted specifically at the proxy-admin code path.
  • Replaced the get_key_object + _cache_key_object cache-update pattern with _delete_cache_key_object (cache invalidation on write), eliminating the erroneous 401 from the cache refresh step.
  • Fixed a pre-existing bug in unblock_key's audit log where the action was hardcoded as "blocked" instead of "unblocked".
  • Added three new unit tests covering the 404 path and the success path; existing block/unblock helper mocks updated to match the new cache pattern.

Notable concerns:

  • The find_uniqueupdate sequence is not atomic: a concurrent deletion between the two calls would cause update to return None, silently producing an HTTP 200 with a null body.
  • Proxy-admin callers receive a ProxyException(404) while non-proxy-admin callers receive an HTTPException(404) from _check_key_admin_access for the same logical error, resulting in inconsistent error shapes for API consumers.
  • For non-proxy-admin callers, the new find_unique is an extra DB round-trip on top of the identical query already issued by _check_key_admin_access (noted in a previous review thread).

Confidence Score: 4/5

  • Safe to merge — the fix correctly resolves the reported 401 regression for proxy-admin callers and is well-covered by new tests; minor non-critical concerns remain around a narrow race condition and inconsistent error shapes.
  • The core bug fix is correct and targeted. Tests cover the primary failure mode (nonexistent key) and the success path. The pre-existing audit-log action bug ("blocked" in unblock_key) is also corrected. The main open issues — a narrow TOCTOU race between find_unique and update returning null, and divergent exception types between proxy-admin and non-admin callers — are edge cases that do not affect the primary fix. The redundant DB query concern was already flagged in a prior review thread.
  • Pay close attention to litellm/proxy/management_endpoints/key_management_endpoints.py lines 4977–4988 (block_key update) and 5086–5097 (unblock_key update) for the potential null-return from the Prisma update call.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/key_management_endpoints.py Fixes 401-for-missing-key bug in block_key/unblock_key by adding an explicit find_unique existence check; replaces get_key_object+_cache_key_object cache update pattern with _delete_cache_key_object; also fixes a pre-existing bug where unblock_key was logging audit action as "blocked" instead of "unblocked". For proxy-admin callers, a new extra DB round-trip is introduced since _check_key_admin_access already queries the same row for non-proxy-admin callers.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Adds three new focused unit tests for the 404 fix (nonexistent key for block, nonexistent key for unblock, existing key success path); updates existing helpers (_setup_block_unblock_mocks and test_unblock_key_supports_both_sk_and_hashed_tokens) to replace get_key_object+_cache_key_object mocks with _delete_cache_key_object mock; cache invalidation is mocked but not asserted in the success-path test.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant E as block_key / unblock_key
    participant A as _check_key_admin_access
    participant DB as Prisma DB
    participant Cache as user_api_key_cache

    C->>E: POST /key/block (Authorization: master key)
    E->>A: check admin access (proxy_admin skips, others query DB)
    alt Non-proxy-admin: key not found
        A->>DB: find_unique(hashed_token)
        DB-->>A: None
        A-->>E: HTTPException(404)
        E-->>C: 404 (HTTPException format)
    end
    Note over A,E: Proxy-admin returns immediately (no DB check)
    E->>DB: find_unique(hashed_token) [new existence check]
    alt Key not found
        DB-->>E: None
        E-->>C: 404 ProxyException "Key not found"
    end
    DB-->>E: existing_record
    E->>DB: update(blocked=True)
    DB-->>E: updated record
    E->>Cache: _delete_cache_key_object(hashed_token)
    Cache-->>E: cache invalidated
    E-->>C: 200 updated record
Loading

Comments Outside Diff (1)

  1. litellm/proxy/management_endpoints/key_management_endpoints.py, line 4977-4988 (link)

    P1 Unhandled None return from update on concurrent deletion

    Between the find_unique check (line 4947) and the update call here, the key could be deleted by a concurrent request. As the PR description notes, Prisma's update returns None when no row is matched, so record would be None and the endpoint would return HTTP 200 with a null body rather than surfacing an error to the caller.

    Adding a post-update guard (if record is None: raise ProxyException(...)) would prevent this silent success-with-null response. The same race applies to unblock_key at line 5086.

Last reviewed commit: "merge: resolve confl..."

if token.startswith("sk-"):
return test_hashed_token
return 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.

P2 Cache invalidation not asserted in success-path tests

test_block_key_existing_key_succeeds (and the updated test_unblock_key_supports_both_sk_and_hashed_tokens) mock _delete_cache_key_object with a no-op but never assert it was actually invoked. If the code path accidentally skips cache invalidation, the tests would still pass undetected.

Consider using an AsyncMock and asserting it was called:

mock_delete_cache = AsyncMock()
monkeypatch.setattr(
    "litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
    mock_delete_cache,
)
# ... after calling block_key / unblock_key ...
mock_delete_cache.assert_called_once()

@yuneng-jiang yuneng-jiang changed the base branch from litellm_yj_march_17_2026 to litellm_yj_march_18_2026 March 18, 2026 22:58
Incorporate new _check_key_admin_access() calls from the base branch
into block_key/unblock_key alongside our existence-check fix.

Update test mocks: replace references to removed get_key_object and
_cache_key_object with _delete_cache_key_object in both the shared
_setup_block_unblock_mocks helper and individual test functions.

Co-authored-by: yuneng-jiang <[email protected]>
Comment on lines +4952 to 4962
# Check if the key exists before trying to block it
existing_record = await prisma_client.db.litellm_verificationtoken.find_unique(
where={"token": hashed_token}
)
if existing_record is None:
raise ProxyException(
message=f"Key not found. Passed key={data.key}",
type=ProxyErrorTypes.not_found_error,
param="key",
code=status.HTTP_404_NOT_FOUND,
)
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 Redundant find_unique DB call for non-proxy-admin users

_check_key_admin_access (called just above) already issues an identical find_unique query for any caller who is not a proxy admin (see lines 4852–4858). This means team admins and org admins that pass the access-control check will now hit the database twice with the same query — once in _check_key_admin_access, and again with the new existing_record check here — before reaching the update call.

The new existence guard is only necessary for proxy admins, who return early at line 4848 and skip that lookup entirely. For all other callers the key's existence (and the HTTPException(404)) is already handled inside _check_key_admin_access.

The simplest targeted fix is to gate the new check on the caller being a proxy admin, or refactor _check_key_admin_access to return the fetched key row so it can be reused for the audit log below.

The same redundancy applies to unblock_key at line 5062.

Accept theirs for UI test file conflicts (not related to our changes).
Our key_management_endpoints.py merged cleanly.

Co-authored-by: yuneng-jiang <[email protected]>
Comment on lines +5055 to 5065
# Check if the key exists before trying to unblock it
existing_record = await prisma_client.db.litellm_verificationtoken.find_unique(
where={"token": hashed_token}
)
if existing_record is None:
raise ProxyException(
message=f"Key not found. Passed key={data.key}",
type=ProxyErrorTypes.not_found_error,
param="key",
code=status.HTTP_404_NOT_FOUND,
)
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 Inconsistent error format between proxy-admin and non-proxy-admin callers

Non-proxy-admin callers with a missing key hit _check_key_admin_access, which raises HTTPException(404, {"error": "Key not found: {hashed_token}"}) — exposing the hashed token in the error detail.

Proxy-admin callers skip that check and now reach this new guard, which raises ProxyException(404) — exposing the original key value from the request body.

The two paths surface different exception types (HTTPException vs ProxyException), different message formats, and different key representations for the same logical error. Aligning both to the same exception type and message template would make client-side error handling consistent. The same inconsistency exists in block_key at lines 4946–4956.

@yuneng-jiang yuneng-jiang merged commit 7cbabd9 into litellm_yj_march_18_2026 Mar 18, 2026
30 of 50 checks passed
yuneng-jiang added a commit that referenced this pull request Mar 18, 2026
The /key/update endpoint's get_data() call raises a 401 when the body
`key` field doesn't exist in the DB, because get_data() treats the
token as an auth credential. This caused the auth layer to resolve the
body key instead of the Authorization header bearer token.

Replace prisma_client.get_data() with direct Prisma find_unique() in
both _get_and_validate_existing_key() and update_key_fn(), matching
the pattern used in the /key/block and /key/unblock fix (PR #23977).
Also fix the incorrect "Team not found" error message in update_key_fn.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@ishaan-berri ishaan-berri deleted the litellm_key-endpoint-authentication-7c1d 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.

3 participants