{"id":"CVE-2026-114619","aliases":["GHSA-q686-2qmg-hqc7"],"title":"Authlib: OpenID implicit grant checks raw scope instead of allowed scope before issuing ID tokens","summary":"Authlib: OpenID implicit grant checks raw scope instead of allowed scope before issuing ID tokens","severity":"medium","cvss":4.6,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:N","vendor":"authlib","product":"authlib","ecosystem":"pip","affected":["authlib < 1.9.0"],"patched":["authlib 1.9.0"],"published":"2026-10-27","updated":"2026-10-27","sourceUpdated":"2026-10-27T15:47:24.789132573Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-q686-2qmg-hqc7","references":[{"url":"https://github.com/authlib/authlib/security/advisories/GHSA-q686-2qmg-hqc7"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-114619"},{"url":"https://github.com/authlib/authlib/pull/945"},{"url":"https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf"},{"url":"https://github.com/authlib/authlib"},{"url":"https://github.com/authlib/authlib/releases/tag/v1.9.0"},{"url":"https://github.com/advisories/GHSA-q686-2qmg-hqc7"},{"url":"https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-114619.json"},{"url":"https://access.redhat.com/security/cve/CVE-2026-114619"},{"url":"https://bugzilla.redhat.com/show_bug.cgi?id=2559396"},{"url":"https://www.cve.org/CVERecord?id=CVE-2026-114619"}],"tags":["osv","pip","ghsa","csaf","vex","red-hat","cve.org"],"epss":0.00157,"epssPercentile":0.04797,"cwe":["CWE-863","CWE-807"],"ingestedAt":"2026-10-22T22:00:50.448Z","slug":"CVE-2026-114619","body":"## Overview\n\n## Report metadata\n\n| Field | Value |\n| --- | --- |\n| Package | `authlib` (PyPI, `pkg:pypi/authlib`) |\n| Repository | https://github.com/authlib/authlib |\n| Component | `authlib.oidc.core.grants.implicit.OpenIDImplicitGrant.validate_authorization_request` |\n| Vulnerability class | Authorization bypass: a client's scope restriction is bypassed for OpenID Connect ID token issuance |\n| Verified against | `c529a61af40e3efe05286140e00fbdced07d7987` (v1.8.0). Earlier releases were not checked. |\n| API surface | Public API (`OpenIDImplicitGrant`, registered with `server.register_grant(...)`) |\n| Affected in default configuration | Yes. No non-default option is needed once `OpenIDImplicitGrant` is registered. Client metadata decides exposure: Authlib's standard `ClientMixin` checks response type and scope independently, so a client can be permitted `id_token` responses without being allowed the `openid` scope. |\n\n## Summary\n\n`OpenIDImplicitGrant.validate_authorization_request()` first runs the parent `ImplicitGrant` validation, which filters the requested scope through `client.get_allowed_scope()` and stores the result in `request.scope`. It then decides whether this is an OpenID request by checking the raw, client-supplied `request.payload.scope` instead of that filtered value. A client that is allowed `profile` but not `openid` can therefore request `scope=openid profile`, pass the check, and receive a signed ID token (with `sub` and profile claims) once the user consents. This happens even when Authlib's built-in, scope-filtering `BearerTokenGenerator` is in use.\n\nThe tainted input is the authorization request's `scope` parameter. The OAuth client fully controls it, and the client is the very party the scope restriction is meant to constrain. Any registered client can add `openid` to its request if it is permitted an `id_token` or `id_token token` response type and its allowed scopes exclude `openid` but include a claim-bearing scope such as `profile`.\n\nSibling paths treat the same client correctly, which suggests the raw-scope check is a bug rather than intended behaviour:\n\n- `ImplicitGrant.validate_authorization_request()` -> filters the requested scope through `client.get_allowed_scope()` into `request.scope` (safe, [`authlib/oauth2/rfc6749/grants/implicit.py` lines 141-147](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oauth2/rfc6749/grants/implicit.py#L141-L147))\n- `OpenIDImplicitGrant.validate_authorization_request()` -> **checks the raw `request.payload.scope` (unsafe, [`authlib/oidc/core/grants/implicit.py` line 72](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L72))**\n- `UserInfoEndpoint` -> rejects the same client's access token with 403 `insufficient_scope` (safe; observed during verification, line not recorded)\n- OIDC authorization code flow -> issues no `id_token` for the same client (safe; observed during verification, line not recorded)\n\n## Impact\n\nA registered OAuth client that the operator has excluded from the `openid` scope can get an OpenID Connect ID token for any user who logs in and approves its authorization request. During verification the provider returned an RS256-signed ID token carrying `iss`, `aud` (the requesting client), `nonce`, `auth_time`, `at_hash`, `sub` and `name`, while the granted scope was only `profile`. A consent page built the way the Authlib docs show lists only `profile`, so the user is not shown that an identity token will be issued.\n\nWithout this bug, the same request fails with `InvalidScopeError` (\"Missing 'openid' scope\") because the client's resolved scope does not include `openid`. The new capability is the ID token itself: a signed assertion of the user's identity (`sub`) plus claims from the client's other allowed scopes. Gating that token is exactly what the `openid` scope is for. The access token stays restricted when the built-in token generator is used: Authlib's `UserInfoEndpoint` rejects it with 403 `insufficient_scope`.\n\n**Scoring note:** we did not compute a CVSS score. An initial triage rated this High. After dynamic verification we assess practical severity as closer to Medium, because:\n\n1. The attacker must be a registered client that is permitted `id_token` response types.\n2. The user must log in and consent.\n3. The ID token's `aud` is the attacking client.\n\nTwo kinds of deployment are not affected:\n\n- Deployments that use only the OIDC authorization code flow. During verification, that flow issued no ID token for the same client.\n- Deployments where every client permitted an `id_token` or `id_token token` response type is also allowed the `openid` scope.\n\n## Preconditions\n\n1. The authorization server registers `OpenIDImplicitGrant` (or a subclass), e.g. `server.register_grant(MyOpenIDImplicitGrant)`. The vulnerable method runs on every OIDC implicit authorization request.\n2. A client's metadata permits an OIDC implicit response type (`id_token` or `id_token token`). Nothing in Authlib ties this to the `openid` scope. The standard SQLAlchemy `ClientMixin` checks response type and scope independently, and it filters requested scopes against the client's allowed scopes at [`authlib/integrations/sqla_oauth2/client_mixin.py:124-129`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/integrations/sqla_oauth2/client_mixin.py#L124-L129).\n3. That client's allowed scopes exclude `openid` but include another claim-bearing scope, such as `profile`.\n4. The attacker controls that registered client and sends an authorization request that adds `openid` to otherwise allowed scopes (e.g. `scope=openid profile`), with a `nonce`.\n5. A user logs in and approves the request.\n6. Branch condition: the raw `request.payload.scope` contains `openid` while the filtered `request.scope` does not. So `is_openid_scope(self.request.payload.scope)` is true at `implicit.py:72` and no `InvalidScopeError` is raised.\n\nNo non-standard usage, monkey-patching or unusual configuration is needed: this is the public grant used as intended. Keeping a client's response types and allowed scopes in sync is not documented as the integrator's responsibility. The parent grant stores the filtered `request.scope` specifically so that authorization decisions use it. We did not capture a link to the relevant Authlib docs page, but the verification built its consent page the way those docs show.\n\n## Data flow\n\n1. **Source**: the OAuth client controls the authorization request's `scope` parameter, e.g. `GET /authorize?response_type=id_token%20token&client_id=c&redirect_uri=https%3A%2F%2Fc%2Fcb&scope=openid%20profile&nonce=n`. The raw value remains available as `request.payload.scope`.\n2. **Propagation**: `ImplicitGrant.validate_authorization_request()` filters the requested scope through `client.get_allowed_scope()` and stores the result in `request.scope` (here `'profile'`). `request.payload.scope` still holds the raw `openid profile` ([`authlib/oauth2/rfc6749/grants/implicit.py:141-147`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oauth2/rfc6749/grants/implicit.py#L141-L147)).\n3. **Sink**: `OpenIDImplicitGrant.validate_authorization_request()` uses the raw value to decide whether this is an OpenID request: `if not is_openid_scope(self.request.payload.scope):` ([`authlib/oidc/core/grants/implicit.py:72`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L72)). The injected `openid` satisfies the check, so the method returns the redirect URI instead of raising `InvalidScopeError` ([`authlib/oidc/core/grants/implicit.py:70-84`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L70-L84)).\n4. **Reachability**: `OpenIDImplicitGrant` is the public grant that serves OIDC implicit authorization requests. After the user consents, `create_granted_params()` and `process_implicit_token()` issue the ID token and user claims ([`authlib/oidc/core/grants/implicit.py:112-129`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L112-L129), [`authlib/oidc/core/grants/implicit.py:132-169`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L132-L169)).\n\n## Vulnerable code\n\n`authlib/oidc/core/grants/implicit.py`, lines 70-84 at `c529a61af40e3efe05286140e00fbdced07d7987` (an excerpt; `...` marks code left out of the captured snippet):\n\n```python\nredirect_uri = super().validate_authorization_request()  # safe: parent grant sets the filtered request.scope\nif not is_openid_scope(self.request.payload.scope):  # <-- sink (line 72): raw, client-supplied scope\n    raise InvalidScopeError(\"Missing 'openid' scope\", ...)\n...\nreturn redirect_uri\n```\n\n## Reproduction\n\nVerified two ways on Authlib `c529a61af40e3efe05286140e00fbdced07d7987` (v1.8.0):\n\n- a pytest PoC using the Flask test client\n- a standalone Flask OpenID Provider on localhost, driven with `curl`\n\nThe Python and Flask versions were not recorded.\n\nSetup: a client registered with `scope='profile'` only (no `openid`) and response types `id_token` / `id_token token`. It uses Authlib's built-in SQLAlchemy `ClientMixin` and the built-in scope-filtering `BearerTokenGenerator`.\n\n1. The client sends an authorization request with `scope=openid profile` and a `nonce`.\n2. `ImplicitGrant` sets `request.scope='profile'`. A consent page built the way the docs show displays only `profile`.\n3. `OpenIDImplicitGrant.validate_authorization_request()` checks the raw `request.payload.scope` at line 72, so the request passes.\n4. After the user approves, the provider returns an RS256-signed ID token with `iss`, `aud` (the client), `nonce`, `auth_time`, `at_hash`, `sub` and `name`, while the granted scope is `profile`. The ID token's signature verified against the provider's JWKS.\n\nControls run during verification:\n\n- The same request without `openid` returns `invalid_scope` (\"Missing 'openid' scope\").\n- Authlib's `UserInfoEndpoint` rejects this client's access token with 403 `insufficient_scope`.\n- The OIDC authorization code flow for the same client issues no `id_token`.\n\nThe sketch below shows the request shape against Authlib's Flask OIDC test app. The verification's own test script is not included here.\n\n```python\n# Client metadata: scope='profile' (no openid), response_types=['id_token token'].\nrv = client.get('/authorize?response_type=id_token%20token&client_id=client-id&redirect_uri=https%3A%2F%2Fclient.test%2Fauthorized&scope=openid%20profile&nonce=n')\n# after the user grants consent:\nassert 'id_token=' in rv.headers['Location']  # should have been rejected: openid is not an allowed scope\n```\n\n- **Expected:** `InvalidScopeError` / `invalid_scope` (\"Missing 'openid' scope\"), because the client's resolved allowed scope (`request.scope == 'profile'`) does not include `openid`.\n- **Actual:** after consent, the redirect carries a signed ID token (including `sub` and `name`) while the granted scope is `profile`.\n\n## Suggested remediation\n\nDecide whether to issue OpenID tokens from the scope the grant actually authorized, never from the raw requested scope. The authorized scope is `self.request.scope`, which the parent grant has already filtered through `client.get_allowed_scope()`.\n\n```diff\n redirect_uri = super().validate_authorization_request()\n-if not is_openid_scope(self.request.payload.scope):\n+if not is_openid_scope(self.request.scope):\n     raise InvalidScopeError(\"Missing 'openid' scope\", ...)\n```\n\nDuring verification, this one-line change at line 72 blocked the exploit, and the existing OIDC implicit/hybrid tests still passed (23 passed).\n\nThe same rule should hold for the rest of OIDC issuance. `create_granted_params()` ([`authlib/oidc/core/grants/implicit.py:112-129`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L112-L129)) passes the raw `payload.scope` to `generate_token`. It should pass `self.request.scope` instead.\n\nNotes:\n\n- **Adjacent raw-scope use, worth fixing in the same change:** `create_granted_params()` hands the raw `payload.scope` to `generate_token`. So a deployment whose custom token generator does not filter scope also grants the client access-token scopes it is not allowed; verification noted `openid` and `email`. Authlib's built-in `BearerTokenGenerator` re-filters the access-token scope, so this only shows up with a non-filtering generator. That re-filtering does not stop the ID token.\n- **Other paths through the same raw value:** beyond `create_granted_params()`, we did not audit every scope read in `process_implicit_token()` ([`authlib/oidc/core/grants/implicit.py:132-169`](https://github.com/authlib/authlib/blob/c529a61af40e3efe05286140e00fbdced07d7987/authlib/oidc/core/grants/implicit.py#L132-L169)), or in other OIDC grants such as the hybrid grant. Whether they share the pattern is not verified.\n- **Already safe, no change needed:**\n  - the scope filtering in the parent `ImplicitGrant` and in `ClientMixin`\n  - `UserInfoEndpoint`, which rejects this client's access token with 403 `insufficient_scope`\n  - the OIDC authorization code flow, which issues no ID token for this client\n\nSuggested regression test, alongside the existing Flask OIDC implicit-grant tests:\n\n1. Register a client with `scope='profile'` and `response_types=['id_token token']`.\n2. Send an authorization request with `scope=openid profile` and a `nonce`, and approve it.\n3. Assert that the response is an `invalid_scope` error (\"Missing 'openid' scope\") and that the redirect contains no `id_token`.\n\nA second case should use a token generator that does not filter scope, and assert that the issued access token carries only the client's allowed scopes.\n\n## Affected packages\n\n- `authlib < 1.9.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `authlib 1.9.0`\n\n## Vendor advisories\n\n- **Red Hat VEX** · Moderate · affected: Exploit Intelligence, Lightspeed Core, Migration Toolkit for Applications 8, OpenShift Lightspeed, Red Hat Ansible Automation Platform 2, Red Hat Enterprise Linux command line assistant, … · no fix planned: Exploit Intelligence, Red Hat Quay 3, Lightspeed Core, Migration Toolkit for Applications 8, … · updated 2026-10-23 · [vex](https://security.access.redhat.com/data/csaf/v2/vex/2026/cve-2026-114619.json)","depth":"sunlit","depthScore":25,"depthScoreParts":{"impact":25.3,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}