BerriAI/litellm · error · HTTPException
f"key not allowed to access this user's info. user_id={user_
Error message
f"key not allowed to access this user's info. user_id={user_id}, key's user_id={user_api_key_dict.user_id}" What it means
GET /user/info enforces ownership: _enforce_user_info_access returns early for PROXY_ADMIN / PROXY_ADMIN_VIEW_ONLY roles and when user_id equals the key's bound user_id; any other combination raises 403 with both ids echoed in the detail. The comparison is exact string equality, so a user_id containing '+' that was URL-decoded to a space will not match the stored id and trips this error - which is why the route re-reads and normalizes the id from the raw query string first.
Source
Thrown at litellm/proxy/management_endpoints/internal_user_endpoints.py:737
The route-level check in ``RouteChecks.non_proxy_admin_allowed_routes_check``
runs against ``request.query_params``, which decodes a literal ``+`` to a
space. ``_normalize_user_info_user_id`` then re-parses the raw query with
``unquote`` so the endpoint can return rows for user_ids that contain ``+``
(e.g. plus-addressed emails). That asymmetry let an attacker who registered
a username with a literal space pass the route check and then read another
user's row by sending the encoded ``+`` form. Re-checking ownership here
closes the gap without changing the supported user_id grammar.
"""
if user_id is None:
return
# Admin-view roles (PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY) bypass
# ownership, mirroring the `/user/info` carve-out that
# `RouteChecks.non_proxy_admin_allowed_routes_check` applies upstream.
if _user_has_admin_view(user_api_key_dict):
return
if user_id == user_api_key_dict.user_id:
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"key not allowed to access this user's info. user_id={user_id}, key's user_id={user_api_key_dict.user_id}"
),
)
async def _get_user_info_teams(
prisma_client: Any,
user_id: str | None,
user_info: Any | None,
user_api_key_dict: UserAPIKeyAuth,
) -> tuple[list[TeamListResponseObject], list[TeamListResponseObject] | None]:
"""Fetch and merge teams from membership + user.teams field."""
from litellm.proxy.management_endpoints.team_endpoints import list_team
team_list: list[TeamListResponseObject] = []
team_id_list: list[str] = []View on GitHub (pinned to 77b7c6c40c)
Solutions
- Omit user_id to read your own info, or pass exactly the user_id bound to the key
- Use a key whose role is proxy_admin or proxy_admin_viewer to read any user
- Percent-encode special characters in the id: replace '+' with %2B (quote(user_id, safe='')) in the query string
Example fix
# before GET /user/info?user_id=krrish7+@berri.ai # '+' decoded to a space -> 403 # after GET /user/info?user_id=krrish7%2B%40berri.ai # exact match -> 200
Defensive patterns
Strategy: validation
Validate before calling
from urllib.parse import quote
import requests
def get_user_info(base_url, headers, target_user_id=None, key_user_id=None):
if target_user_id is None:
target_user_id = key_user_id # self-lookup is always allowed
params = {"user_id": quote(target_user_id, safe="")} if target_user_id else {}
return requests.get(f"{base_url}/user/info", params=params, headers=headers, timeout=10) Type guard
def can_read_user_info(key_user_id: str | None, key_role: str, target_user_id: str | None) -> bool:
"""True when /user/info will not 403 for this key/target pair."""
if key_role in ("proxy_admin", "proxy_admin_viewer"):
return True
return target_user_id is None or target_user_id == key_user_id Try / catch
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 403 and "key not allowed" in e.response.text:
# fall back to self-lookup, or re-issue with an admin key
... Prevention
- Always URL-encode user ids that may contain '+' or '@' (quote(id, safe=''))
- Derive user_id from /key/info instead of hand-typing emails
- Use a proxy_admin_viewer key for read-only auditing of all users
When it happens
Trigger: GET /user/info?user_id=<someone-else> with a non-admin key; passing an email-style id containing '+' unencoded so it arrives as a space after decoding and fails the equality check with the stored id.
Common situations: Scripts or dashboards enumerating all users with a regular user key; ids like 'krrish7+tag@berri.ai' decoded to 'krrish7 tag@berri.ai'; upgrading to a version where /user/info ownership was tightened.
Related errors
- f"User not found: {user_id}"
- User {user_api_key_dict.user_id} does not have access to vec
- Unable to record skill ownership: caller has no identity sco
- Admin-only endpoint. Your user role={user_api_key_dict.user_
- user_id is required. Either pass it as a query parameter or
AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18).
Data as JSON: /api/errors/141e307382ca4741.
Report an issue: GitHub.