Guide · Backend
OAuthLib's October 2026 Vulnerability Explained: Scopes for the Wrong Client
19 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The OAuthLib vulnerability CVE-2026-118669 let a token server check one client's secret, its password, then decide permissions using a different client name sent in the same request. OAuthLib 4.0.0 fixes it.
- OAuthLib is the Python library under django-oauth-toolkit and many hand-built login servers. A token endpoint is the web address where programs swap their credentials for an access token.
- Only servers whose request validator, the class you write to answer OAuthLib's questions, uses the client_id argument to decide grant types or scopes are exposed. Scopes are permission labels inside a token.
- Upgrade to oauthlib 4.0.1, which also fixes a separate device-flow bug. Then read your validator for the risky pattern, and plan for the 4.0 breaking changes listed below.
- Beginners can stop after the checklist of what to do now. Engineers get the source diff, a runnable check that shows the fix on your machine, and every breaking change we tested on 4.0.1.
What goes wrong when a token endpoint checks the wrong client?
The server can give one program the permissions of another. That is the heart of the OAuthLib vulnerability published on 24 October 2026. Its GitHub advisory ID is GHSA-7984-qgvg-3j9c, and its CVE, the shared public ID for the bug, is CVE-2026-118669.
Picture a common setup. A payments business runs its own login server for internal programs, built on OAuthLib with a hand-written validator. The setup is made up, but the bug is real.
Two programs use it. A nightly reports job may only read reports. A payouts service may send money. Each has its own client ID, a public name, and a client secret, a password meant for programs.
The reports job proves who it is with its own secret. Before 4.0.0, OAuthLib could then pass the validator a client ID taken from the request body. If the validator trusted that ID, the reports job could receive payouts permissions.
This is not a break-in from outside. The caller needs valid credentials for one client. The danger is a low-privilege program, or someone who stole its secret, climbing to a higher-privilege program's permissions.
Which words do you need before reading the OAuthLib advisory?
You need seven terms. SevyDevy's guide on OAuth vs OIDC vs SAML covers OAuth itself; here is only what this bug needs.
- Client: a program that asks for tokens, such as the reports job. A confidential client can keep a secret, usually because it runs on a server.
- Token endpoint: the URL on the login server where a client sends its credentials and gets an access token back.
- Grant type: the way a client asks for a token. client_credentials means a program acting for itself. password means the program sends a user's username and password too.
- Scope: a permission label inside a token, such as reports.read or payouts.write. The login server decides which scopes each client may get.
- Authentication: proving who you are. Authorisation: deciding what you may do. This bug sits between the two.
- Request validator: a Python class you write. OAuthLib calls its methods, such as validate_scopes, and your answers decide what is allowed.
- Confused deputy: a trusted program tricked into using its own authority for someone else. Here the deputy is OAuthLib's grant code.
What is the OAuthLib vulnerability fixed in 4.0.0?
It is an authorisation bug in two token grants: client_credentials and password. OAuthLib authenticated the client correctly, then let a client_id in the request body decide which client's rules were checked.
| Question | Answer |
|---|---|
| Identifiers | GHSA-7984-qgvg-3j9c and CVE-2026-118669 |
| Severity | High, CVSS v4 score 8.4 out of 10, weakness CWE-863 (incorrect authorisation) |
| Grants named | client_credentials and password (resource owner password credentials) |
| Affected versions | Before 4.0.0 in the GitHub Advisory Database; the repository advisory lists 3.3.1 |
| Fixed in | 4.0.0; we recommend 4.0.1 because it also fixes a separate device-flow advisory |
| Who can trigger it | A caller holding valid credentials for one confidential client; not an anonymous attacker |
| Who is exposed | Servers whose validator uses the client_id argument in validate_grant_type or validate_scopes |
The fixed versions are older than the advisory. The 4.0.0 changelog lists the change as a client authentication reorganisation (#919, #920), not as a security fix. The pull request the GitHub Advisory Database links, #920, is titled as a device-flow fix.
So a team that read only the 4.0.0 release notes would not have known it closed a High-severity bug. That is why version numbers, not release-note labels, decide whether you are covered.
How did OAuthLib authorise the wrong client before 4.0.0?
It kept two client identities on the same request and never checked that they matched. One came from authentication, the other from the request body, and the validator was handed the second.
In OAuthLib 3.3.1 the client_credentials grant runs in this order. Your authenticate_client method checks the secret and stores the real client on request.client. Then OAuthLib calls validate_grant_type, then validate_scopes.
The client ID it passes to those two methods is request.client_id. That value comes from the request body. OAuthLib filled it from the authenticated client only when the body had no client_id at all.
Before 4.0.0: two identities, never compared
Drag to exploreNotice the two arrows into the validator. The secret proves one client, but the client_id argument can name another, and nothing in 3.3.1 compares them.
Diagram connections:
- Token request to authenticate_client (credentials)
- Token request to client_id from body (form field)
- authenticate_client to validate_grant_type (request.client)
- client_id from body to validate_grant_type (client_id argument)
- validate_grant_type to validate_scopes
- validate_scopes to Token issued (if allowed)
- Token request: Client → token endpoint
- authenticate_client: Secret checked, request.client set
- client_id from body: Taken as sent, not compared
- validate_grant_type: OAuthLib → your validator
- validate_scopes: OAuthLib → your validator
- Token issued: Saved with request.client_id
Whether this hurt depended entirely on your validator. Both validate_grant_type and validate_scopes receive the client_id argument and the client object, so the two can disagree.
The password grant had the same flaw. It authenticated the client first, then validated the user, then checked grant type and scopes with the body's client_id.
Think of an office visitor desk. The guard checks your ID card at the gate. Then the receptionist decides which floors you may visit using the name you wrote on the visitor form, not the name on the card.
In that picture, the ID card is the client secret and the guard is authenticate_client. The visitor form is the request body. The receptionist is your validator, and the floors are scopes.
Where the picture breaks: a real receptionist might notice the card and form disagree. Code notices nothing unless someone writes the comparison, and before 4.0.0 nobody had.
Is your app exposed to the OAuthLib client_id bug?
Only if three things are all true. You run an OAuthLib token server older than 4.0.0. You enable the client_credentials or password grant. Your validator decides using the client_id argument.
Version 3.3.1 also passes an unchecked body client_id to validate_grant_type in the authorization code and refresh token grants. The advisory does not name those grants, but 4.0 adds the same check there, so upgrade anyway.
Exposure check for GHSA-7984-qgvg-3j9c
Drag to exploreFollow the questions in order. Most apps end at Not exposed or Outside named grants; only custom validators that trust the client_id argument reach the red box.
Diagram connections:
- Do you issue tokens? to oauthlib before 4.0.0? (Yes)
- Do you issue tokens? to Not exposed to this bug (No)
- oauthlib before 4.0.0? to Client creds or password? (Yes)
- oauthlib before 4.0.0? to Not exposed to this bug (4.0.0 or later)
- Client creds or password? to Validator uses client_id? (Yes)
- Client creds or password? to Outside named grants (No)
- Validator uses client_id? to Exposed (Yes)
- Validator uses client_id? to Not exposed to this bug (Uses request.client only)
- Do you issue tokens?: Server side, not client only
- oauthlib before 4.0.0?: python -m pip show oauthlib
- Client creds or password?: Grant types you enable
- Validator uses client_id?: validate_grant_type or validate_scopes
- Not exposed to this bug: Still upgrade for other fixes
- Outside named grants: Same pattern elsewhere; upgrade anyway
- Exposed: Upgrade now and review tokens
The risky pattern is easy to spot. Inside validate_grant_type or validate_scopes, the code looks up rules with the client_id argument. The safe pattern looks them up with client.client_id or request.client.client_id.
Also check save_bearer_token. The advisory says tokens could be stored under the spoofed client_id. If you save request.client_id there, your token records may name the wrong client.
| Package | Role | What we found |
|---|---|---|
| Your own OAuthLib server | Token server | Exposed only if your validator uses the client_id argument as above |
| django-oauth-toolkit 3.4.1 | Token server for Django | Stock validate_grant_type and validate_scopes decide from the client object; custom overrides need a read |
| Flask-OAuthlib 0.9.6 | Token server for Flask; its own notice points users to Authlib | Stock methods use the client object, but it pins oauthlib below 3.0.0, so it cannot install the fix |
| requests-oauthlib 2.0.0 | OAuth client for the requests library | Client side only, so this server bug does not apply; it still pulls in oauthlib |
These are our readings of the code, not statements from those projects. A django-oauth-toolkit app with its own validator subclass or scopes backend should read that code too.
How do you check your OAuthLib version and dependency tree?
Ask pip, inside the same virtual environment or container image your server runs in. OAuthLib usually arrives as a dependency, so you may never have installed it by name.
Show the installed version
Run python -m pip show oauthlib. Read the Version line, and the Required-by line to see which packages pulled it in.
Find it in lock files
Search requirements.txt, poetry.lock or uv.lock for oauthlib. A pin such as oauthlib==3.3.1 blocks the upgrade until you change it.
Upgrade
Run python -m pip install --upgrade "oauthlib>=4.0.1", or bump the pin and regenerate your lock file.
Check nothing broke
Run python -m pip check. Then print the version from inside your app's environment.
In our test environment with django-oauth-toolkit 3.4.1 and requests-oauthlib 2.0.0, pip show listed both under Required-by. After the upgrade, pip check printed No broken requirements found, and the version printed 4.0.1.
If python3-oauthlib came from your Linux distribution, pip show will list it. Where the distribution marks its Python as externally managed, pip refuses the upgrade with error: externally-managed-environment.
In that case the fix must come from your distribution. Check its security tracker for a fixed package, or run your server from a virtual environment.
What should you do now about the OAuthLib advisory?
Upgrade, read your validator, and look at tokens already issued. The steps below are in the order to do them.
Upgrade to oauthlib 4.0.1
Version 4.0.0 fixes this advisory. Version 4.0.1 adds the device-flow fix, so go straight to it. Both need Python 3.9 or later.
Read your request validator
Search for def validate_grant_type and def validate_scopes. Make every decision from client.client_id, the client that authenticated.
Read your token saving code
In save_bearer_token, store the token against request.client.client_id. If other code reads the stored client_id, also record the authenticated client in an audit field.
Review issued tokens
If you were exposed, compare each token's scopes with the client that authenticated, from request logs or an audit field. A token saved under the spoofed client_id looks normal against that client's policy.
Revoke when you cannot tell
Without a record of the authenticated client, revoke and reissue access and refresh tokens that high-privilege clients got through client_credentials or password grants on an affected version.
Run your tests on 4.0.1
Error codes and validator calls changed in 4.0. The next sections list what we saw change.
How does the OAuthLib 4.0 fix work in the source code?
It adds one shared check that ties the two identities together before any authorisation runs. We diffed the released 3.3.1 and 4.0.1 packages from PyPI to confirm it.
Version 4.0 adds three helper methods on the base grant class: validate_client_confidential, validate_client_public and validate_client_authentication. The client_credentials grant calls the confidential helper directly.
The authorization code, refresh token, password and device code grants call validate_client_authentication. It picks the confidential or public helper using your client_authentication_required method.
Both helpers do the same thing after authentication. If the body had no client_id, they copy it from request.client. If the body's client_id differs, they raise ServerError with the message Discrepency found between client identifier.
From 4.0.0: one identity, checked before authorisation
Drag to exploreNotice the new comparison box sits before validate_grant_type. By the time your validator runs, the client_id argument always equals the authenticated client's ID.
Diagram connections:
- Token request to authenticate_client
- authenticate_client to Body client_id matches?
- Body client_id matches? to server_error response (No)
- Body client_id matches? to validate_grant_type (Yes, or body had none)
- validate_grant_type to validate_scopes
- Token request: Client → token endpoint
- authenticate_client: Secret checked, request.client set
- Body client_id matches?: New shared check in 4.0
- server_error response: Request stops here
- validate_grant_type: Gets the authenticated ID
- validate_scopes: Gets the authenticated ID
The error code is server_error. In the 4.0.1 source, ServerError does not set its own HTTP status, so it uses the library default of 400. We read this in the source and did not send a mismatched request.
The message keeps the misspelling Discrepency, which is handy for searching logs. The wording is undocumented as of 1 November 2026; pin your version if alerts depend on it.
How do you verify the fix on your own token endpoint?
Run a small script that builds an in-process token endpoint with a safe validator and sends two normal requests. We ran it with Python 3.13 on 1 November 2026, on oauthlib 3.3.1 and 4.0.1.
import base64
import json
import oauthlib
from oauthlib.oauth2 import BackendApplicationServer, RequestValidator
# client_id: (secret, allowed scopes)
CLIENTS = {"reports-job": ("reports-secret", {"reports.read"})}
class Client:
def __init__(self, client_id):
self.client_id = client_id
class Validator(RequestValidator):
def authenticate_client(self, request, *args, **kwargs):
header = request.headers.get("Authorization", "")
if not header.startswith("Basic "):
return False
cid, _, secret = base64.b64decode(header[6:]).decode().partition(":")
if CLIENTS.get(cid, (None,))[0] != secret:
return False
request.client = Client(cid)
return True
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
print(" validate_grant_type got client_id:", client_id)
return grant_type == "client_credentials"
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
# Safe: decide from the client that authenticated, not the argument.
return set(scopes).issubset(CLIENTS[client.client_id][1])
def save_bearer_token(self, token, request, *args, **kwargs):
pass
print("oauthlib", oauthlib.__version__)
server = BackendApplicationServer(Validator())
auth = base64.b64encode(b"reports-job:reports-secret").decode()
headers = {"Authorization": "Basic " + auth,
"Content-Type": "application/x-www-form-urlencoded"}
for scope in ("reports.read", "payouts.write"):
_, body, status = server.create_token_response(
"https://auth.local/token", "POST",
"grant_type=client_credentials&scope=" + scope, headers)
print(scope, status, json.loads(body).get("error", "token issued"))Save it as check_oauthlib.py and run python check_oauthlib.py. It sends nothing over the network; the URL is only a label.
| Line | oauthlib 3.3.1 | oauthlib 4.0.1 |
|---|---|---|
| validate_grant_type got client_id | None | reports-job |
| reports.read | 200 token issued | 200 token issued |
| payouts.write | 400 invalid_scope | 400 invalid_scope |
Normal requests behave the same on both versions, and the safe validator refuses a scope the client does not own. The visible difference is the client_id argument.
On 3.3.1, validate_grant_type received None for a request with no body client_id, because OAuthLib filled the ID only later. On 4.0.1 it received the authenticated ID. This ordering is undocumented as of 1 November 2026.
That gives you a quick fingerprint. Log the client_id argument in your own validate_grant_type during a test. If it is ever None or differs from client.client_id, you are not running the fixed code path.
We deliberately did not send a request with a mismatched client_id. The source shows what 4.0 does with one, and that is enough for a check.
What breaking changes come with the OAuthLib 4.0 upgrade?
The 4.0.0 notes mark two changes as breaking, but more can stop a working app. We ran each row below on 3.3.1 and 4.0.1 unless the row says source.
| Change | 3.3.1 | 4.0.1 |
|---|---|---|
| RevocationEndpoint with enable_jsonp | Accepted | TypeError: unexpected keyword argument enable_jsonp |
| Client prepare_token_revocation_request with callback | Token and callback put in the URL query | TypeError: unexpected keyword argument callback |
| Password grant missing username, wrong client secret | 401 invalid_client | 400 invalid_request, missing username |
| Body client_id differs from authenticated client | Not compared; body ID passed to your validator (source) | server_error (from source, not sent) |
| Device flow without validate_device_code | Hook does not exist yet | Required hook; base class raises NotImplementedError (source) |
| Python version (PyPI metadata) | 3.8 or later | 3.9 or later |
JSONP is gone from token revocation
JSONP was an old trick for cross-site requests that wrapped JSON in a function call. Version 4.0.0 removed it; the changelog says CORS, the browser standard for allowing cross-site requests, replaces it. Code that passes enable_jsonp now fails at startup, which is the easy kind of failure.
Missing parameters now fail before client authentication
The changelog says 4.0 validates grant_type before client authentication. In our password grant test, a missing username was also reported before the client check, so a wrong secret now gets 400 invalid_request instead of 401 invalid_client.
Tests that assert 401 for malformed requests will fail. Monitoring that counts 401s as failed logins will see fewer of them.
Validators must set request.client on every path
The new public-client helper raises NotImplementedError if authenticate_client_id does not set request.client with a client_id. In 3.3.1 the refresh token grant did not demand this, so older validators can break there.
Also check any client that sends both HTTP Basic credentials, the ID and secret in a header, and a body client_id. If the two ever differ, for example through a config mix-up between two apps, 4.0 now rejects the request.
What if you cannot upgrade to OAuthLib 4.0 yet?
Then fix the validator, which is where the exposure lives. The advisory lists no separate workaround, but its suggested fix tells you exactly what to compare.
- In validate_grant_type and validate_scopes, ignore the client_id argument and use client.client_id. This removes the exposure the advisory describes.
- In authenticate_client, after the secret checks out, return False if request.client_id is set and differs from the authenticated ID. In 3.3.1 this method runs before both checks.
- Store tokens against request.client.client_id, never the body value.
We read the 3.3.1 source to confirm the order of those calls. We did not send a mismatched request to test the guard, so test it in your own suite.
Flask-OAuthlib users are stuck below oauthlib 3.0.0 because of its pin. Its latest release, 0.9.6, tells users to switch to Authlib, so plan that move.
What else did OAuthLib 4.0.0 and 4.0.1 fix?
Two more advisories, which is another reason to land on 4.0.1 rather than 4.0.0.
- GHSA-xpv3-w29h-x7cv (CVE-2026-49265, Moderate): the PKCE check compared the code verifier with ordinary equality, which can leak timing. PKCE is a check that stops a stolen login code being swapped for a token. Fixed in 4.0.0 with a constant-time comparison.
- GHSA-3jp2-3mrg-2vxc (CVE-2026-49272, High): the device flow token endpoint did not validate device_code. The device flow lets TVs and command-line tools log in through a code typed on another screen. Fixed in 4.0.1.
The GitHub Advisory Database lists 4.0.0 as patched for the device-flow advisory. The repository advisory says 4.0.1, and the 4.0.1 source diff is where validate_device_code first appears, so treat 4.0.1 as the fix.
Device flow users must implement validate_device_code in their validator. It returns a status constant such as DeviceCodeGrant.DEVICE_CODE_AUTHORIZED or DEVICE_CODE_PENDING, and must set request.user and request.scopes for an authorised code.
How do you explain the OAuthLib vulnerability in a system design interview?
Frame it as a confused-deputy bug at the token endpoint, where authentication and authorisation used different identities. Use these steps as your outline.
Name the two identities
The client proves itself with a secret, and the request body also names a client. Only the first is trustworthy.
Show where they split
OAuthLib authenticated with one and passed the other to grant and scope checks.
Scope the blast radius
A caller needs one valid client. Exposure depends on whether the validator read the argument or the authenticated object.
Give the fix
Compare the identities right after authentication, reject a mismatch, and derive the ID from the authenticated client.
Add defence in depth
Make policy lookups take the authenticated object, test that the argument is never None, and review issued tokens.
Questions people ask
- Is the OAuthLib vulnerability exploitable without any credentials?
- No. The advisory says the caller must hold valid credentials for one confidential client. The risk is a low-privilege client, or a stolen low-privilege secret, gaining a higher-privilege client's grant types or scopes. That still matters, because many systems give small internal jobs their own secrets and store them less carefully.
- Does upgrading requests-oauthlib fix CVE-2026-118669?
- No, because requests-oauthlib is an OAuth client and this is a token server bug. Upgrading oauthlib itself is what matters. Upgrading requests-oauthlib alone leaves an installed oauthlib 3.x in place, because requests-oauthlib 2.0.0 only asks for oauthlib 3.0.0 or later. Upgrade oauthlib directly to 4.0.1.
- Is django-oauth-toolkit affected by the OAuthLib client_id bug?
- Its stock validator in version 3.4.1 decides grant types and scopes from the client object, so by the advisory's own test it should not be exposed. That is our reading of its code as of 1 November 2026, not a statement from the project. Custom validator subclasses or scopes backends need their own read, and you should still upgrade oauthlib.
- Why did the fix ship before the advisory?
- Version 4.0.0 reached PyPI on 28 September 2026, and the advisory was published on 24 October 2026. The 4.0.0 changelog described the change as a client authentication reorganisation (#919, #920). A quiet fix followed by a later advisory can happen in any project, which is why you track versions, not release-note wording.
- Will OAuthLib 4.0.1 break my existing token server?
- It can, in specific places. Passing enable_jsonp to RevocationEndpoint now raises TypeError, malformed requests with a wrong client secret return 400 invalid_request instead of 401 invalid_client, and device flow servers must add validate_device_code. Normal valid client_credentials and password requests worked unchanged in our tests with a correct validator.
- How can I tell from logs whether anyone used the client_id bug?
- Only if you logged both identities. Look for token requests where the body client_id differed from the client that authenticated. Checking stored tokens against their saved client's policy is not enough, because a token saved under the spoofed client_id looks normal. If you lack that record, revoke and reissue high-privilege tokens issued while you were exposed.
Sources and further reading
- GitHub Advisory Database: GHSA-7984-qgvg-3j9c (CVE-2026-118669), severity, dates and affected range
- OAuthLib repository advisory GHSA-7984-qgvg-3j9c: details, who is affected and suggested fix
- OAuthLib pull request #920: shared client authentication helper
- OAuthLib releases page: 4.0.0 notes and breaking changes
- PyPI: oauthlib 4.0.1 release page and release history
- OAuthLib repository advisory GHSA-3jp2-3mrg-2vxc: device_code not validated, patched in 4.0.1
- GitHub Advisory Database: GHSA-xpv3-w29h-x7cv, PKCE timing comparison fixed in 4.0.0
- OSV: DEBIAN-CVE-2026-118669, Debian python-oauthlib status