{"record":{"id":"141e307382ca4741","repo":"BerriAI/litellm","slug":"f-key-not-allowed-to-access-this-user-s-info-user","errorCode":null,"errorMessage":"f\"key not allowed to access this user's info. user_id={user_id}, key's user_id={user_api_key_dict.user_id}\"","messagePattern":"f\"key not allowed to access this user's info\\. user_id=(.+?), key's user_id=(.+?)\"","errorType":"http","errorClass":"HTTPException","httpStatus":403,"severity":"error","filePath":"litellm/proxy/management_endpoints/internal_user_endpoints.py","lineNumber":737,"sourceCode":"    The route-level check in ``RouteChecks.non_proxy_admin_allowed_routes_check``\n    runs against ``request.query_params``, which decodes a literal ``+`` to a\n    space. ``_normalize_user_info_user_id`` then re-parses the raw query with\n    ``unquote`` so the endpoint can return rows for user_ids that contain ``+``\n    (e.g. plus-addressed emails). That asymmetry let an attacker who registered\n    a username with a literal space pass the route check and then read another\n    user's row by sending the encoded ``+`` form. Re-checking ownership here\n    closes the gap without changing the supported user_id grammar.\n    \"\"\"\n    if user_id is None:\n        return\n    # Admin-view roles (PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY) bypass\n    # ownership, mirroring the `/user/info` carve-out that\n    # `RouteChecks.non_proxy_admin_allowed_routes_check` applies upstream.\n    if _user_has_admin_view(user_api_key_dict):\n        return\n    if user_id == user_api_key_dict.user_id:\n        return\n    raise HTTPException(\n        status_code=status.HTTP_403_FORBIDDEN,\n        detail=(\n            f\"key not allowed to access this user's info. user_id={user_id}, key's user_id={user_api_key_dict.user_id}\"\n        ),\n    )\n\n\nasync def _get_user_info_teams(\n    prisma_client: Any,\n    user_id: str | None,\n    user_info: Any | None,\n    user_api_key_dict: UserAPIKeyAuth,\n) -> tuple[list[TeamListResponseObject], list[TeamListResponseObject] | None]:\n    \"\"\"Fetch and merge teams from membership + user.teams field.\"\"\"\n    from litellm.proxy.management_endpoints.team_endpoints import list_team\n\n    team_list: list[TeamListResponseObject] = []\n    team_id_list: list[str] = []","sourceCodeStart":719,"sourceCodeEnd":755,"githubUrl":"https://github.com/BerriAI/litellm/blob/77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8/litellm/proxy/management_endpoints/internal_user_endpoints.py#L719-L755","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before\nGET /user/info?user_id=krrish7+@berri.ai   # '+' decoded to a space -> 403\n\n# after\nGET /user/info?user_id=krrish7%2B%40berri.ai  # exact match -> 200","handlingStrategy":"validation","validationCode":"from urllib.parse import quote\nimport requests\n\ndef get_user_info(base_url, headers, target_user_id=None, key_user_id=None):\n    if target_user_id is None:\n        target_user_id = key_user_id  # self-lookup is always allowed\n    params = {\"user_id\": quote(target_user_id, safe=\"\")} if target_user_id else {}\n    return requests.get(f\"{base_url}/user/info\", params=params, headers=headers, timeout=10)","typeGuard":"def can_read_user_info(key_user_id: str | None, key_role: str, target_user_id: str | None) -> bool:\n    \"\"\"True when /user/info will not 403 for this key/target pair.\"\"\"\n    if key_role in (\"proxy_admin\", \"proxy_admin_viewer\"):\n        return True\n    return target_user_id is None or target_user_id == key_user_id","tryCatchPattern":"except requests.HTTPError as e:\n    if e.response is not None and e.response.status_code == 403 and \"key not allowed\" in e.response.text:\n        # fall back to self-lookup, or re-issue with an admin key\n        ...","preventionTips":["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"],"tags":["litellm","user-info","authorization","ownership","url-encoding"],"backgroundTag":"cross-user-access-forbidden","analyzedSha":"77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8","analyzedAt":"2026-08-18T11:44:31.656Z","schemaVersion":2},"datasetVersion":"2026-08-27T19:17:21.184Z"}