fix: redact API keys from error responses

API keys from LLM providers (OpenAI, Anthropic, Azure, etc.) can leak
into error messages when provider APIs return errors that include the
request's authentication details. This is a security concern for
enterprise customers.

Changes:
- Added litellm/litellm_core_utils/redact_api_keys.py with regex-based
  redaction for common API key formats (sk-*, Bearer tokens, URL key
  params, Azure api-key headers, Authorization headers)
- Applied redaction in exception_mapping_utils.py where provider error
  messages are extracted and mapped to litellm exceptions
- Applied redaction in ProxyException.__init__ as a safety net for all
  error responses sent to clients
- Applied redaction in MaskedHTTPStatusError for HTTP-level errors
- Added comprehensive test suite with 16 test cases
This commit is contained in:
shin-bot-litellm 2026-02-02 19:17:51 +00:00
parent 0a1b98895b
commit 606436c37c
5 changed files with 190 additions and 6 deletions

View file

@ -25,6 +25,7 @@ from ..exceptions import (
Timeout,
UnprocessableEntityError,
)
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
class ExceptionCheckers:
@ -251,10 +252,10 @@ def exception_type( # type: ignore # noqa: PLR0915
original_exception=original_exception
)
try:
error_str = str(original_exception)
error_str = redact_api_keys(str(original_exception))
if model:
if hasattr(original_exception, "message"):
error_str = str(original_exception.message)
error_str = redact_api_keys(str(original_exception.message))
if isinstance(original_exception, BaseException):
exception_type = type(original_exception).__name__
else:
@ -360,6 +361,9 @@ def exception_type( # type: ignore # noqa: PLR0915
else:
message = str(original_exception)
# Redact any API keys that may have leaked into the error message
message = redact_api_keys(message)
if message is not None and isinstance(
message, str
): # done to prevent user-confusion. Relevant issue - https://github.com/BerriAI/litellm/issues/1414
@ -2051,6 +2055,9 @@ def exception_type( # type: ignore # noqa: PLR0915
else:
message = str(original_exception)
# Redact any API keys that may have leaked into the error message
message = redact_api_keys(message)
# Azure OpenAI (especially Images) often nests error details under
# body["error"]. Detect content policy violations using the structured
# payload in addition to string matching.

View file

@ -0,0 +1,68 @@
# +-----------------------------------------------+
# | |
# | Give Feedback / Get Help |
# | https://github.com/BerriAI/litellm/issues/new |
# | |
# +-----------------------------------------------+
#
# Thank you users! We ❤️ you! - Krrish & Ishaan
"""
Utility to redact API keys from error messages.
API keys from various providers can leak into error messages when provider
APIs return errors that include the request's authentication details.
This module provides functions to detect and mask such keys before they
reach end users.
"""
import re
from typing import Optional
# Pattern to match common API key formats in error messages
# Covers: sk-xxx, Bearer xxx, key=xxx in URLs, and common provider key patterns
_API_KEY_PATTERNS = [
# Anthropic keys: sk-ant-<key> (must be before generic sk- pattern)
(re.compile(r"(sk-ant-)[A-Za-z0-9_-]{20,}"), r"\g<1>****"),
# OpenAI-style keys: sk-<anything that looks like a key>
# Match sk- followed by at least 20 chars of key material
(re.compile(r"(sk-)[A-Za-z0-9_-]{20,}"), r"\g<1>****"),
# Bearer token in messages: Bearer <token>
(re.compile(r"(Bearer\s+)[A-Za-z0-9_.+/=-]{10,}"), r"\g<1>[REDACTED]"),
# Authorization header value patterns
(re.compile(r"(Authorization['\"]?\s*[:=]\s*['\"]?)[A-Za-z0-9_.+/=-]{10,}"), r"\g<1>[REDACTED]"),
# URL query param: key=<value> or api_key=<value> or apikey=<value>
(re.compile(r"((?:api[_-]?key|key|token|secret|password|credential|auth)=)[A-Za-z0-9_.+/=-]{8,}(?=&|$|\s|['\"])"), r"\g<1>[REDACTED]"),
# Azure API keys (32-char hex strings preceded by api-key header context)
(re.compile(r"(api-key['\"]?\s*[:=]\s*['\"]?)[a-f0-9]{32,}"), r"\g<1>[REDACTED]"),
# Generic long hex/base64 strings that look like keys when preceded by key-related words
(re.compile(r"((?:api[_-]?key|secret|token|credential|auth[_-]?token)['\"]?\s*[:=]\s*['\"]?)[A-Za-z0-9_.+/=-]{16,}"), r"\g<1>[REDACTED]"),
]
def redact_api_keys(message: Optional[str]) -> Optional[str]:
"""
Redact API keys from an error message string.
Scans the message for patterns that look like API keys and replaces them
with redacted placeholders. This prevents leaking sensitive credentials
in error responses returned to users.
Args:
message: The error message string to redact.
Returns:
The message with API keys redacted, or the original message if no keys found.
Returns None if input is None.
"""
if message is None:
return None
if not isinstance(message, str):
return message
redacted = message
for pattern, replacement in _API_KEY_PATTERNS:
redacted = pattern.sub(replacement, redacted)
return redacted

View file

@ -317,12 +317,14 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError):
def __init__(
self, original_error, message: Optional[str] = None, text: Optional[str] = None
):
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
# Create a new error with the masked URL
masked_url = mask_sensitive_info(str(original_error.request.url))
# Create a new error that looks like the original, but with a masked URL
super().__init__(
message=original_error.message,
message=redact_api_keys(original_error.message) or original_error.message,
request=httpx.Request(
method=original_error.request.method,
url=masked_url,
@ -335,8 +337,9 @@ class MaskedHTTPStatusError(httpx.HTTPStatusError):
headers=original_error.response.headers,
),
)
self.message = message
self.text = text
# Redact API keys from message and text fields
self.message = redact_api_keys(message) if message else message
self.text = redact_api_keys(text) if text else text
class AsyncHTTPHandler:

View file

@ -2958,7 +2958,9 @@ class ProxyException(Exception):
openai_code: Optional[str] = None, # maps to 'code' in openai
provider_specific_fields: Optional[dict] = None,
):
self.message = str(message)
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
self.message = redact_api_keys(str(message)) or str(message)
self.type = type
self.param = param
self.openai_code = openai_code or code

View file

@ -0,0 +1,104 @@
"""
Tests for the API key redaction utility.
Ensures that various API key formats are properly masked in error messages
to prevent credential leakage to end users.
"""
import pytest
from litellm.litellm_core_utils.redact_api_keys import redact_api_keys
class TestRedactApiKeys:
"""Tests for the redact_api_keys function."""
def test_returns_none_for_none_input(self):
assert redact_api_keys(None) is None
def test_returns_non_string_as_is(self):
assert redact_api_keys(123) == 123
def test_no_keys_returns_unchanged(self):
msg = "Connection timeout after 30 seconds"
assert redact_api_keys(msg) == msg
def test_redacts_openai_sk_key(self):
msg = "Error: Invalid API key: sk-1234567890abcdefghijklmnopqrstuv"
result = redact_api_keys(msg)
assert "sk-1234567890abcdefghijklmnopqrstuv" not in result
assert "sk-****" in result
def test_redacts_anthropic_sk_ant_key(self):
msg = "AuthenticationError: Invalid API key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890"
result = redact_api_keys(msg)
assert "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890" not in result
assert "sk-ant-****" in result
def test_redacts_bearer_token(self):
msg = "Authorization failed with Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0"
result = redact_api_keys(msg)
assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result
assert "Bearer [REDACTED]" in result
def test_redacts_key_in_url_param(self):
msg = "Request to https://api.example.com/v1/chat?key=abcdef1234567890ghij failed"
result = redact_api_keys(msg)
assert "abcdef1234567890ghij" not in result
assert "key=[REDACTED]" in result
def test_redacts_api_key_url_param(self):
msg = "Error calling https://api.example.com?api_key=sk_test_abc123def456ghi789 - 401"
result = redact_api_keys(msg)
assert "sk_test_abc123def456ghi789" not in result
assert "api_key=[REDACTED]" in result
def test_redacts_azure_api_key_header(self):
msg = "api-key: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4 was rejected"
result = redact_api_keys(msg)
assert "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" not in result
assert "api-key: [REDACTED]" in result
def test_redacts_authorization_header_value(self):
msg = "Authorization: sk-proj-abcdefghijklmnopqrstuvwxyz123456"
result = redact_api_keys(msg)
assert "sk-proj-abcdefghijklmnopqrstuvwxyz123456" not in result
def test_preserves_error_context(self):
msg = "OpenAIException - Error code: 401 - Invalid API key provided: sk-abcdefghijklmnopqrstuvwxyz12345678. You can find your API key at https://platform.openai.com/account/api-keys."
result = redact_api_keys(msg)
assert "sk-abcdefghijklmnopqrstuvwxyz12345678" not in result
assert "Error code: 401" in result
assert "Invalid API key provided" in result
def test_redacts_multiple_keys_in_same_message(self):
msg = "Tried key sk-aaaabbbbccccddddeeeeffffgggg1234 then sk-1111222233334444555566667777abcd"
result = redact_api_keys(msg)
assert "sk-aaaabbbbccccddddeeeeffffgggg1234" not in result
assert "sk-1111222233334444555566667777abcd" not in result
def test_real_world_openai_error_with_key(self):
"""Simulate a real OpenAI error that includes the API key in the message."""
msg = (
"Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ123456. "
"You can find your API key at https://platform.openai.com/account/api-keys.', "
"'type': 'invalid_request_error', 'param': None, 'code': 'invalid_api_key'}}"
)
result = redact_api_keys(msg)
assert "sk-proj-aBcDeFgHiJkLmNoPqRsTuVwXyZ123456" not in result
assert "sk-" in result # prefix should still be visible
assert "invalid_api_key" in result # error context preserved
def test_real_world_azure_error_with_key(self):
"""Simulate an Azure error that includes the API key."""
msg = "Access denied with api-key=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6. Check your credentials."
result = redact_api_keys(msg)
assert "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" not in result
def test_empty_string(self):
assert redact_api_keys("") == ""
def test_short_values_not_redacted(self):
"""Short values that don't look like keys should not be redacted."""
msg = "key=abc is too short"
assert redact_api_keys(msg) == msg