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

  1. Omit user_id to read your own info, or pass exactly the user_id bound to the key
  2. Use a key whose role is proxy_admin or proxy_admin_viewer to read any user
  3. 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

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


AI-assisted analysis of BerriAI/litellm@77b7c6c40c (2026-08-18). Data as JSON: /api/errors/141e307382ca4741. Report an issue: GitHub.