fix(auth): log routine 401 rejections as one warning without a traceback (#48613)

A missing or malformed Authorization header, a blocked key, a non-admin key on an
admin-only or master-key-only route and a SCIM-deactivated user were raised as bare
exceptions, so the auth failure handler could not see the 401 and logged each one at
ERROR with a full traceback, which flooded the log under scanner traffic

They now raise a typed 401 carrying the quiet-log marker that the non-sk bearer
rejection already used, and the unknown sk- key rejection joins the same path, so
each lands as one WARNING line on stdout with no traceback. The client response is
unchanged
This commit is contained in:
ryan-crabbe-berri 2026-10-27 12:46:22 -07:00 • committed by GitHub
parent 618d5aec71
commit 34134d7ae5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 88 additions and 33 deletions

View file

@ -24,6 +24,7 @@ from litellm.proxy.auth.auth_utils import ( # noqa: F401 # legacy module expor
_get_request_ip_address, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
get_request_ip_address,
is_invalid_virtual_key_error,
is_unauthorized_error,
mark_invalid_virtual_key_error,
normalize_request_route,
)
@ -200,11 +201,11 @@ class UserAPIKeyAuthExceptionHandler:
user_agent: Final = _get_user_agent(request)
# Log authentication failures before identity seeding and callbacks, so the log
# survives a raising callback pipeline. Classify and route malformed virtual-key
# rejections to WARNING on stdout (suppressible via LITELLM_LOG=ERROR).
# survives a raising callback pipeline. Route 401 rejections to WARNING on stdout
# (suppressible via LITELLM_LOG=ERROR); anything else is unexpected and logs at ERROR.
log_extra: Final = {"requester_ip": requester_ip}
is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e)
is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks
is_quiet_log: Final = is_unauthorized_error(e) and not litellm.log_client_error_tracebacks
logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger
logger.log(
logging.WARNING if is_quiet_log else logging.ERROR,

View file

@ -48,6 +48,16 @@ from litellm.types.utils import CustomPricingLiteLLMParams, oauth_token_exchange
from litellm.types.videos.main import DecodedVideoId
def is_unauthorized_error(exception: BaseException | None) -> bool:
"""True when the proxy itself rejected the caller's credentials with a 401."""
if not isinstance(exception, (HTTPException, ProxyException)):
return False
code: Final[object] = getattr(exception, "code", None)
status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None)
return str(status_code) == str(status.HTTP_401_UNAUTHORIZED)
def is_invalid_virtual_key_error(exception: BaseException | None) -> bool:
"""True when an authentication error rejects a malformed virtual key.
@ -57,15 +67,7 @@ def is_invalid_virtual_key_error(exception: BaseException | None) -> bool:
match would let a request body demote an authorization failure to the
quiet log path.
"""
if not isinstance(exception, (HTTPException, ProxyException)):
return False
code: Final[object] = getattr(exception, "code", None)
status_code: Final[object] = code if code is not None else getattr(exception, "status_code", None)
if str(status_code) != str(status.HTTP_401_UNAUTHORIZED):
return False
return getattr(exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, False) is True
return is_unauthorized_error(exception) and getattr(exception, INVALID_VIRTUAL_KEY_ERROR_MARKER, False) is True
def log_model_access_denial(exc: BaseException) -> None:

View file

@ -14,6 +14,8 @@ from litellm.proxy._types import (
LiteLLM_UserTable,
LiteLLMRoutes,
LitellmUserRoles,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
)
@ -284,7 +286,7 @@ class RouteChecks:
route (str): The route being accessed
Raises:
Exception: With user role and masked user_id information
ProxyException: 401 with user role and masked user_id information
"""
user_role = "unknown"
user_id = "unknown"
@ -293,8 +295,14 @@ class RouteChecks:
user_id = user_obj.user_id or "unknown"
masked_user_id: Final = RouteChecks._mask_user_id(user_id)
raise Exception(
f"Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}"
raise ProxyException(
message=(
"Authentication Error, Only proxy admin can be used to generate, delete, update info for new "
f"keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}"
),
type=ProxyErrorTypes.auth_error,
param="None",
code=status.HTTP_401_UNAUTHORIZED,
)
@staticmethod

View file

@ -2513,10 +2513,20 @@ async def user_api_key_auth_builder(
parent_otel_span=parent_otel_span,
)
elif api_key is None: # only require api key if master key is set
raise Exception("No api key passed in.")
raise ProxyException(
message="Authentication Error, No api key passed in.",
type=ProxyErrorTypes.auth_error,
param="None",
code=status.HTTP_401_UNAUTHORIZED,
)
elif api_key == "":
# missing 'Bearer ' prefix
raise Exception("Malformed API Key passed in. Ensure Key has `Bearer ` prefix.")
raise ProxyException(
message="Authentication Error, Malformed API Key passed in. Ensure Key has `Bearer ` prefix.",
type=ProxyErrorTypes.auth_error,
param="None",
code=status.HTTP_401_UNAUTHORIZED,
)
if route == "/user/auth":
if general_settings.get("allow_user_auth", False) is True:
@ -2752,7 +2762,12 @@ async def user_api_key_auth_builder(
## IF it's not a master key
## Route should not be in master_key_only_routes
if route in LiteLLMRoutes.master_key_only_routes.value:
raise Exception(f"Tried to access route={route}, which is only for MASTER KEY")
raise ProxyException(
message=f"Authentication Error, Tried to access route={route}, which is only for MASTER KEY",
type=ProxyErrorTypes.auth_error,
param="None",
code=status.HTTP_401_UNAUTHORIZED,
)
## Check DB
@ -2880,7 +2895,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
## base case ## key is disabled
if valid_token.blocked is True:
raise Exception("Key is blocked. Update via `/key/unblock` if you're an admin.")
raise ProxyException(
message="Authentication Error, Key is blocked. Update via `/key/unblock` if you're an admin.",
type=ProxyErrorTypes.auth_error,
param="None",
code=status.HTTP_401_UNAUTHORIZED,
)
await enforce_key_and_fallback_model_access(
valid_token=valid_token,
request_data=request_data,
@ -2923,8 +2943,14 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
and isinstance(user_obj.metadata, dict)
and user_obj.metadata.get("scim_active") is False
):
raise Exception(
f"User={valid_token.user_id} has been deactivated via SCIM. Keys owned by this user cannot be used."
raise ProxyException(
message=(
f"Authentication Error, User={valid_token.user_id} has been deactivated via SCIM. "
"Keys owned by this user cannot be used."
),
type=ProxyErrorTypes.auth_error,
param="None",
code=status.HTTP_401_UNAUTHORIZED,
)
# Check 2a. Check if model has zero cost - if so, skip all budget checks
@ -3241,7 +3267,12 @@ async def validate_resolved_virtual_key( # noqa: C901 # Preserve ordering of e
if valid_token is None:
# No token was found when looking up in the DB
raise Exception("Invalid proxy server token passed")
raise ProxyException(
message="Authentication Error, Invalid proxy server token passed",
type=ProxyErrorTypes.auth_error,
param="None",
code=status.HTTP_401_UNAUTHORIZED,
)
if valid_token_dict is not None:
virtual_key_auth_obj: Final = await _return_user_api_key_auth_obj(
user_obj=user_obj,

View file

@ -668,7 +668,7 @@ async def test_expired_key_error_log_names_the_key_owner(
):
verbose_proxy_logger.propagate = True
try:
with caplog.at_level("ERROR", logger="LiteLLM Proxy"), pytest.raises(ProxyException):
with caplog.at_level("WARNING", logger="LiteLLM Proxy"), pytest.raises(ProxyException):
await handler.handle_authentication_error(
expired_key_error,
MagicMock(),
@ -714,8 +714,8 @@ async def test_expired_key_error_log_names_the_key_owner(
async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides(
looked_up, logged_fragment, absent_fragment, caplog
):
"""A wrong key gets a generic 401, while the one auth failure log line keeps the
operator wording and the hash that was looked up, next to the requester IP."""
"""A wrong key gets a generic 401, while the one auth failure log line, a WARNING with no
traceback, keeps the operator wording and the hash that was looked up, next to the requester IP."""
from litellm.constants import INVALID_API_KEY_ERROR_MESSAGE
from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError
@ -733,7 +733,7 @@ async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides(
):
verbose_proxy_logger.propagate = True
try:
with caplog.at_level("ERROR", logger="LiteLLM Proxy"), pytest.raises(ProxyException) as exc_info:
with caplog.at_level("WARNING", logger="LiteLLM Proxy"), pytest.raises(ProxyException) as exc_info:
await UserAPIKeyAuthExceptionHandler().handle_authentication_error(
KeyNotFoundError(looked_up),
MagicMock(),
@ -748,6 +748,7 @@ async def test_unknown_key_error_log_keeps_the_operator_wording_the_401_hides(
assert exc_info.value.message == INVALID_API_KEY_ERROR_MESSAGE
records = [r for r in caplog.records if "user_api_key_auth(): Exception occured" in r.getMessage()]
assert len(records) == 1, [r.getMessage() for r in caplog.records]
assert records[0].levelname == "WARNING" and records[0].exc_info is None
logged = records[0].getMessage()
assert "Requester IP Address:" in logged and logged_fragment in logged, logged
if absent_fragment is not None:
@ -1148,10 +1149,16 @@ def _marked_malformed_key_error() -> HTTPException:
message="Authentication Error", type=ProxyErrorTypes.auth_error, param=None, code=401
),
False,
"ERROR",
id="expected_401_no_traceback",
"WARNING",
id="proxy_exception_401_warning_no_traceback",
),
pytest.param(ValueError("unexpected internal error"), True, "ERROR", id="unexpected_error_keeps_traceback"),
pytest.param(
Exception("No api key passed in."),
True,
"ERROR",
id="bare_exception_coerced_to_401_still_keeps_traceback",
),
pytest.param(
_marked_malformed_key_error(),
False,
@ -1161,8 +1168,14 @@ def _marked_malformed_key_error() -> HTTPException:
pytest.param(
HTTPException(status_code=401, detail="LiteLLM Virtual Key expected. Received=test"),
False,
"WARNING",
id="http_exception_401_warning_no_traceback",
),
pytest.param(
ProxyException(message="Forbidden", type=ProxyErrorTypes.auth_error, param=None, code=403),
False,
"ERROR",
id="phrase_without_marker_stays_loud",
id="403_stays_error_without_traceback",
),
],
)
@ -1189,7 +1202,7 @@ async def test_handle_authentication_error_traceback_only_for_unexpected_errors(
try:
try:
raise auth_error
except (ProxyException, ValueError, HTTPException) as caught:
except Exception as caught:
with caplog.at_level(expect_level, logger="LiteLLM Proxy"), pytest.raises((ProxyException, HTTPException)):
await handler.handle_authentication_error(
caught,

View file

@ -3742,7 +3742,7 @@ def test_team_spend_by_user_reachable_by_non_admin(user_role):
return "allowed"
assert outcome("/team/spend/by_user") == "allowed"
assert outcome("/team/spend/by_key").startswith("denied: Only proxy admin")
assert outcome("/team/spend/by_key").startswith("denied: Authentication Error, Only proxy admin")
def test_user_daily_activity_aggregated_not_covered_by_prefix_match():

View file

@ -2031,9 +2031,9 @@ async def test_user_api_key_auth_websocket_rejection_adds_no_traceback_for_other
assert rejection.value.status_code == 403
assert "expired key" in str(rejection.value.detail).lower()
assert sent == [{"type": "websocket.close", "code": status.WS_1008_POLICY_VIOLATION, "reason": ""}]
proxy_records: Final = [record for record in caplog.records if record.name == verbose_proxy_logger.name]
proxy_records: Final = [record for record in caplog.records if record.name.startswith(verbose_proxy_logger.name)]
assert [record.getMessage() for record in proxy_records if record.exc_info is not None] == []
assert [record.levelno for record in proxy_records if record.levelno >= logging.WARNING] == [logging.ERROR]
assert [record.levelno for record in proxy_records if record.levelno >= logging.WARNING] == [logging.WARNING]
@pytest.mark.asyncio