GHSA-q686-2qmg-hqc7
Authlib: OpenID implicit grant checks raw scope instead of allowed scope before issuing ID tokens
Packages
Details
As written in the advisory (Markdown source, shown as plain text).
## Report metadata
| Field | Value |
| --- | --- |
| Package | `authlib` (PyPI, `pkg:pypi/authlib`) |
| Repository | https://github.com/authlib/authlib |
| Component | `authlib.oidc.core.grants.implicit.OpenIDImplicitGrant.validate_authorization_request` |
| Vulnerability class | Authorization bypass: a client's scope restriction is bypassed for OpenID Connect ID token issuance |
| Verified against | `c529a61af40e3efe05286140e00fbdced07d7987` (v1.8.0). Earlier releases were not checked. |
| API surface | Public API (`OpenIDImplicitGrant`, registered with `server.register_grant(...)`) |
| 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. |
## Summary
`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.
The 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`.
Sibling paths treat the same client correctly, which suggests the raw-scope check is a bug rather than intended behaviour:
- `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))
- `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))**
- `UserInfoEndpoint` -> rejects the same client's access token with 403 `insufficient_scope` (safe; observed during verification, line not recorded)
- OIDC authorization code flow -> issues no `id_token` for the same client (safe; observed during verification, line not recorded)
## Impact
A 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.
Without 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`.
**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:
1. The attacker must be a registered client that is permitted `id_token` response types.
2. The user must log in and consent.
3. The ID token's `aud` is the attacking client.
Two kinds of deployment are not affected:
- Deployments that use only the OIDC authorization code flow. During verification, that flow issued no ID token for the same client.
- Deployments where every client permitted an `id_token` or `id_token token` response type is also allowed the `openid` scope.
## Preconditions
1. The authorization server registers `OpenIDImplicitGrant` (or a subclass), e.g. `server.register_grant(MyOpenIDImplicitGrant)`. The vulnerable method runs on every OIDC implicit authorization request.
2. 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).
3. That client's allowed scopes exclude `openid` but include another claim-bearing scope, such as `profile`.
4. 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`.
5. A user logs in and approves the request.
6. 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.
No 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.
## Data flow
1. **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`.
2. **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)).
3. **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)).
4. **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)).
## Vulnerable code
`authlib/oidc/core/grants/implicit.py`, lines 70-84 at `c529a61af40e3efe05286140e00fbdced07d7987` (an excerpt; `...` marks code left out of the captured snippet):
```python
redirect_uri = super().validate_authorization_request() # safe: parent grant sets the filtered request.scope
if not is_openid_scope(self.request.payload.scope): # <-- sink (line 72): raw, client-supplied scope
raise InvalidScopeError("Missing 'openid' scope", ...)
...
return redirect_uri
```
## Reproduction
Verified two ways on Authlib `c529a61af40e3efe05286140e00fbdced07d7987` (v1.8.0):
- a pytest PoC using the Flask test client
- a standalone Flask OpenID Provider on localhost, driven with `curl`
The Python and Flask versions were not recorded.
Setup: 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`.
1. The client sends an authorization request with `scope=openid profile` and a `nonce`.
2. `ImplicitGrant` sets `request.scope='profile'`. A consent page built the way the docs show displays only `profile`.
3. `OpenIDImplicitGrant.validate_authorization_request()` checks the raw `request.payload.scope` at line 72, so the request passes.
4. 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.
Controls run during verification:
- The same request without `openid` returns `invalid_scope` ("Missing 'openid' scope").
- Authlib's `UserInfoEndpoint` rejects this client's access token with 403 `insufficient_scope`.
- The OIDC authorization code flow for the same client issues no `id_token`.
The sketch below shows the request shape against Authlib's Flask OIDC test app. The verification's own test script is not included here.
```python
# Client metadata: scope='profile' (no openid), response_types=['id_token token'].
rv = 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')
# after the user grants consent:
assert 'id_token=' in rv.headers['Location'] # should have been rejected: openid is not an allowed scope
```
- **Expected:** `InvalidScopeError` / `invalid_scope` ("Missing 'openid' scope"), because the client's resolved allowed scope (`request.scope == 'profile'`) does not include `openid`.
- **Actual:** after consent, the redirect carries a signed ID token (including `sub` and `name`) while the granted scope is `profile`.
## Suggested remediation
Decide 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()`.
```diff
redirect_uri = super().validate_authorization_request()
-if not is_openid_scope(self.request.payload.scope):
+if not is_openid_scope(self.request.scope):
raise InvalidScopeError("Missing 'openid' scope", ...)
```
During verification, this one-line change at line 72 blocked the exploit, and the existing OIDC implicit/hybrid tests still passed (23 passed).
The 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.
Notes:
- **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.
- **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.
- **Already safe, no change needed:**
- the scope filtering in the parent `ImplicitGrant` and in `ClientMixin`
- `UserInfoEndpoint`, which rejects this client's access token with 403 `insufficient_scope`
- the OIDC authorization code flow, which issues no ID token for this client
Suggested regression test, alongside the existing Flask OIDC implicit-grant tests:
1. Register a client with `scope='profile'` and `response_types=['id_token token']`.
2. Send an authorization request with `scope=openid profile` and a `nonce`, and approve it.
3. Assert that the response is an `invalid_scope` error ("Missing 'openid' scope") and that the redirect contains no `id_token`.
A 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.
References
External links from the advisory. They lead to third-party sites.
- https://github.com/authlib/authlib/security/advisories/GHSA-q686-2qmg-hqc7
- https://nvd.nist.gov/vuln/detail/CVE-2026-114619
- https://github.com/authlib/authlib/pull/945
- https://github.com/authlib/authlib/commit/e3bd41cb29dcfee434453b135ffb487884baeedf
- https://github.com/authlib/authlib
- https://github.com/authlib/authlib/releases/tag/v1.9.0