Authlib: 1-click Account Takeover
Description
Authlib is a Python library which builds OAuth and OpenID Connect servers. In versions 1.0.0 through 1.6.5, cache-backed state/request-token storage is not tied to the initiating user session, so CSRF is possible for any attacker that has a valid state (easily obtainable via an attacker-initiated authentication flow). When a cache is supplied to the OAuth client registry, FrameworkIntegration.set_state_data writes the entire state blob under _state_{app}_{state}, and get_state_data ignores the caller’s session altogether. This issue has been patched in version 1.6.6.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Authlib 1.0.0–1.6.5 cache-backed state storage is not tied to the user session, enabling CSRF via attacker-initiated state reuse from an attacker-initiated flow.
Vulnerability
Overview
Authlib versions 1.0.0 through 1.6.5 contain a cross-site request forgery (CSRF) vulnerability in the OAuth client registry when a cache backend is used. The FrameworkIntegration.set_state_data method writes the state blob under a key _state_{app}_{state} in the cache, while get_state_data retrieves it` retrieves the data without verifying that the caller's session matches the session that originally stored the state [2][4]. This means the state token is not bound to the initiating user session.
Exploitation
An attacker can initiate their own OAuth authentication flow with the vulnerable application, obtaining a valid state value. Because the cache lookup ignores the session, the attacker can then trick a victim into visiting a crafted URL that includes that state. When the victim's browser follows the OAuth redirect and presents the state, the server retrieves the corresponding cached data and proceeds with the token exchange using the attacker's authorization code [1][4]. The attack requires no special privileges beyond normal network access and can be triggered with a single click from the victim.
Impact
Successful exploitation allows an attacker to an attacker can achieve a 1-click account takeover on the target application. The attacker's authorization code is exchanged for tokens that are then associated with the victim's session, effectively linking the attacker's OAuth authorization to the victim's account [4]. The CVSS v3 base score is 5. is 5.7 (AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N) [4].
Mitigation
Mitigation
The issue has been patched in Authlib version 1.6.1.6.6 [2]. The fix ensures that state data is stored in both the cache and the user session, and that retrieval checks the session before falling back to the cache [3]. Users should upgrade to 1.6.6 or later. No workaround is available for earlier versions.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
authlibPyPI | >= 1.0.0, < 1.6.6 | 1.6.6 |
Affected products
2Patches
27974f45e4d74fix: authorization and token endpoints request empty scope parameter management
9 files changed · +247 −9
authlib/oauth2/rfc6749/grants/authorization_code.py+11 −1 modified@@ -7,6 +7,7 @@ from ..errors import InvalidClientError from ..errors import InvalidGrantError from ..errors import InvalidRequestError +from ..errors import InvalidScopeError from ..errors import OAuth2Error from ..errors import UnauthorizedClientError from ..hooks import hooked @@ -308,10 +309,15 @@ def save_authorization_code(self, code, request): code=code, client_id=client.client_id, redirect_uri=request.payload.redirect_uri, - scope=request.payload.scope, + scope=request.scope, user_id=request.user.id, ) item.save() + + .. note:: Use ``request.scope`` instead of ``request.payload.scope`` to get + the resolved scope. Per RFC 6749 Section 3.3, if the client omits the + scope parameter, the server uses a default value from + ``client.get_allowed_scope()``. """ raise NotImplementedError() @@ -381,6 +387,10 @@ def validate_code_authorization_request(grant): @hooked def validate_authorization_request_payload(grant, redirect_uri): grant.validate_requested_scope() + scope = client.get_allowed_scope(request.payload.scope) + if scope is None: + raise InvalidScopeError() + request.scope = scope try: validate_authorization_request_payload(grant, redirect_uri)
authlib/oauth2/rfc6749/grants/implicit.py+6 −1 modified@@ -3,6 +3,7 @@ from authlib.common.urls import add_params_to_uri from ..errors import AccessDeniedError +from ..errors import InvalidScopeError from ..errors import OAuth2Error from ..errors import UnauthorizedClientError from ..hooks import hooked @@ -140,6 +141,10 @@ def validate_authorization_request(self): try: self.request.client = client self.validate_requested_scope() + scope = client.get_allowed_scope(self.request.payload.scope) + if scope is None: + raise InvalidScopeError() + self.request.scope = scope except OAuth2Error as error: error.redirect_uri = redirect_uri error.redirect_fragment = True @@ -208,7 +213,7 @@ def create_authorization_response(self, redirect_uri, grant_user): self.request.user = grant_user token = self.generate_token( user=grant_user, - scope=self.request.payload.scope, + scope=self.request.scope, include_refresh_token=False, ) log.debug("Grant token %r to %r", token, self.request.client)
authlib/oauth2/rfc6749/requests.py+7 −4 modified@@ -90,6 +90,7 @@ def __init__(self, method: str, uri: str, body=None, headers=None): self.authorization_code = None self.refresh_token = None self.credential = None + self._scope = None @property def args(self): @@ -151,12 +152,14 @@ def redirect_uri(self): @property def scope(self) -> str: - deprecate( - "'request.scope' is deprecated in favor of 'request.payload.scope'", - version="1.8", - ) + if self._scope is not None: + return self._scope return self.payload.scope + @scope.setter + def scope(self, value: str): + self._scope = value + @property def state(self): deprecate(
authlib/oauth2/rfc6750/token.py+17 −2 modified@@ -1,3 +1,6 @@ +from ..rfc6749.errors import InvalidScopeError + + class BearerTokenGenerator: """Bearer token generator which can create the payload for token response by OAuth 2 server. A typical token response would be: @@ -52,8 +55,20 @@ def _get_expires_in(self, client, grant_type): @staticmethod def get_allowed_scope(client, scope): - if scope: - scope = client.get_allowed_scope(scope) + """Get the allowed scope for token generation. + + Per RFC 6749 Section 3.3, if the client omits the scope parameter, + the authorization server MUST either process the request using a + pre-defined default value or fail the request indicating an invalid scope. + + :param client: the client making the request + :param scope: the requested scope (may be None if omitted) + :return: the allowed scope string + :raises InvalidScopeError: if client.get_allowed_scope returns None + """ + scope = client.get_allowed_scope(scope) + if scope is None: + raise InvalidScopeError() return scope def generate(
docs/changelog.rst+9 −0 modified@@ -6,6 +6,15 @@ Changelog Here you can see the full list of changes between each Authlib release. +Version 1.6.7 +------------- + +**Unreleased** + +- Per RFC 6749 Section 3.3, the ``scope`` parameter is now optional at both + authorization and token endpoints. ``client.get_allowed_scope()`` is called + to determine the default scope when omitted. :issue:`845` + Version 1.6.6 -------------
tests/flask/test_oauth2/models.py+1 −1 modified@@ -103,7 +103,7 @@ def save_authorization_code(code, request): code=code, client_id=client.client_id, redirect_uri=request.payload.redirect_uri, - scope=request.payload.scope, + scope=request.scope, nonce=request.payload.data.get("nonce"), user_id=request.user.id, code_challenge=request.payload.data.get("code_challenge"),
tests/flask/test_oauth2/test_authorization_code_grant.py+78 −0 modified@@ -352,3 +352,81 @@ def test_token_generator(app, test_client, client, server): resp = json.loads(rv.data) assert "access_token" in resp assert "c-authorization_code.1." in resp["access_token"] + + +def test_missing_scope_uses_default(test_client, client, monkeypatch): + """Per RFC 6749 Section 3.3, when scope is omitted at authorization endpoint, + the server should use a pre-defined default value from client.get_allowed_scope(). + """ + + def get_allowed_scope_with_default(scope): + if scope is None: + return "default_scope" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_with_default) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "code=" in rv.location + + params = dict(url_decode(urlparse.urlparse(rv.location).query)) + code = params["code"] + headers = create_basic_header("client-id", "client-secret") + rv = test_client.post( + "/oauth/token", + data={ + "grant_type": "authorization_code", + "code": code, + }, + headers=headers, + ) + resp = json.loads(rv.data) + assert "access_token" in resp + assert resp.get("scope") == "default_scope" + + +def test_missing_scope_empty_default(test_client, client, monkeypatch): + """When client.get_allowed_scope() returns empty string for missing scope, + the authorization should proceed without a scope. + """ + + def get_allowed_scope_empty(scope): + if scope is None: + return "" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_empty) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "code=" in rv.location + + params = dict(url_decode(urlparse.urlparse(rv.location).query)) + code = params["code"] + headers = create_basic_header("client-id", "client-secret") + rv = test_client.post( + "/oauth/token", + data={ + "grant_type": "authorization_code", + "code": code, + }, + headers=headers, + ) + resp = json.loads(rv.data) + assert "access_token" in resp + assert resp.get("scope", "") == "" + + +def test_missing_scope_rejected(test_client, client, monkeypatch): + """Per RFC 6749 Section 3.3, when scope is omitted and client.get_allowed_scope() + returns None, the authorization should fail with invalid_scope. + """ + + def get_allowed_scope_reject(scope): + if scope is None: + return None + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_reject) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "error=invalid_scope" in rv.location
tests/flask/test_oauth2/test_client_credentials_grant.py+68 −0 modified@@ -114,3 +114,71 @@ def test_token_generator(test_client, app, server): resp = json.loads(rv.data) assert "access_token" in resp assert "c-client_credentials." in resp["access_token"] + + +def test_missing_scope_uses_default(test_client, client, monkeypatch): + """Per RFC 6749 Section 3.3, when scope is omitted, the server should use + a pre-defined default value from client.get_allowed_scope(). + """ + + def get_allowed_scope_with_default(scope): + if scope is None: + return "default_scope" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_with_default) + + headers = create_basic_header("client-id", "client-secret") + rv = test_client.post( + "/oauth/token", + data={"grant_type": "client_credentials"}, + headers=headers, + ) + resp = json.loads(rv.data) + assert "access_token" in resp + assert resp.get("scope") == "default_scope" + + +def test_missing_scope_empty_default(test_client, client, monkeypatch): + """When client.get_allowed_scope() returns empty string for missing scope, + the token should be issued without a scope. + """ + + def get_allowed_scope_empty(scope): + if scope is None: + return "" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_empty) + + headers = create_basic_header("client-id", "client-secret") + rv = test_client.post( + "/oauth/token", + data={"grant_type": "client_credentials"}, + headers=headers, + ) + resp = json.loads(rv.data) + assert "access_token" in resp + assert resp.get("scope", "") == "" + + +def test_missing_scope_rejected(test_client, client, monkeypatch): + """Per RFC 6749 Section 3.3, when scope is omitted and client.get_allowed_scope() + returns None, the server should fail with invalid_scope. + """ + + def get_allowed_scope_reject(scope): + if scope is None: + return None + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_reject) + + headers = create_basic_header("client-id", "client-secret") + rv = test_client.post( + "/oauth/token", + data={"grant_type": "client_credentials"}, + headers=headers, + ) + resp = json.loads(rv.data) + assert resp["error"] == "invalid_scope"
tests/flask/test_oauth2/test_implicit_grant.py+50 −0 modified@@ -92,3 +92,53 @@ def test_token_generator(test_client, app, server): server.load_config(app.config) rv = test_client.post(authorize_url, data={"user_id": "1"}) assert "access_token=c-implicit.1." in rv.location + + +def test_missing_scope_uses_default(test_client, client, monkeypatch): + """Per RFC 6749 Section 3.3, when scope is omitted, the server should use + a pre-defined default value from client.get_allowed_scope(). + """ + + def get_allowed_scope_with_default(scope): + if scope is None: + return "default_scope" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_with_default) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "access_token=" in rv.location + assert "scope=default_scope" in rv.location + + +def test_missing_scope_empty_default(test_client, client, monkeypatch): + """When client.get_allowed_scope() returns empty string for missing scope, + the token should be issued without a scope. + """ + + def get_allowed_scope_empty(scope): + if scope is None: + return "" + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_empty) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "access_token=" in rv.location + assert "scope=" not in rv.location + + +def test_missing_scope_rejected(test_client, client, monkeypatch): + """Per RFC 6749 Section 3.3, when scope is omitted and client.get_allowed_scope() + returns None, the authorization should fail with invalid_scope. + """ + + def get_allowed_scope_reject(scope): + if scope is None: + return None + return scope + + monkeypatch.setattr(client, "get_allowed_scope", get_allowed_scope_reject) + + rv = test_client.post(authorize_url, data={"user_id": "1"}) + assert "#error=invalid_scope" in rv.location
2808378611ddMerge commit from fork
2 files changed · +59 −15
authlib/integrations/base_client/framework_integration.py+13 −12 modified@@ -20,41 +20,42 @@ def _get_cache_data(self, key): def _clear_session_state(self, session): now = time.time() + prefix = f"_state_{self.name}" for key in dict(session): - if "_authlib_" in key: - # TODO: remove in future - session.pop(key) - elif key.startswith("_state_"): + if key.startswith(prefix): value = session[key] exp = value.get("exp") if not exp or exp < now: session.pop(key) def get_state_data(self, session, state): key = f"_state_{self.name}_{state}" + session_data = session.get(key) + if not session_data: + return None if self.cache: - value = self._get_cache_data(key) + cached_value = self._get_cache_data(key) else: - value = session.get(key) - if value: - return value.get("data") + cached_value = session_data + if cached_value: + return cached_value.get("data") return None def set_state_data(self, session, state, data): key = f"_state_{self.name}_{state}" + now = time.time() if self.cache: self.cache.set(key, json.dumps({"data": data}), self.expires_in) + session[key] = {"exp": now + self.expires_in} else: - now = time.time() session[key] = {"data": data, "exp": now + self.expires_in} def clear_state_data(self, session, state): key = f"_state_{self.name}_{state}" if self.cache: self.cache.delete(key) - else: - session.pop(key, None) - self._clear_session_state(session) + session.pop(key, None) + self._clear_session_state(session) def update_token(self, token, refresh_token=None, access_token=None): raise NotImplementedError()
tests/clients/test_flask/test_oauth_client.py+46 −3 modified@@ -150,9 +150,13 @@ def test_oauth1_authorize_cache(): assert resp.status_code == 302 url = resp.headers.get("Location") assert "oauth_token=foo" in url + session_data = session["_state_dev_foo"] + assert "exp" in session_data + assert "data" not in session_data with app.test_request_context("/?oauth_token=foo"): with mock.patch("requests.sessions.Session.send") as send: + session["_state_dev_foo"] = session_data send.return_value = mock_send_value("oauth_token=a&oauth_token_secret=b") token = client.authorize_access_token() assert token["oauth_token"] == "a" @@ -207,7 +211,44 @@ def test_register_oauth2_remote_app(): assert session.update_token is not None -def test_oauth2_authorize(): +def test_oauth2_authorize_cache(): + app = Flask(__name__) + app.secret_key = "!" + cache = SimpleCache() + oauth = OAuth(app, cache=cache) + client = oauth.register( + "dev", + client_id="dev", + client_secret="dev", + api_base_url="https://resource.test/api", + access_token_url="https://provider.test/token", + authorize_url="https://provider.test/authorize", + ) + with app.test_request_context(): + resp = client.authorize_redirect("https://client.test/callback") + assert resp.status_code == 302 + url = resp.headers.get("Location") + assert "state=" in url + state = dict(url_decode(urlparse.urlparse(url).query))["state"] + assert state is not None + session_data = session[f"_state_dev_{state}"] + assert "exp" in session_data + assert "data" not in session_data + + with app.test_request_context(path=f"/?code=a&state={state}"): + # session is cleared in tests + session[f"_state_dev_{state}"] = session_data + + with mock.patch("requests.sessions.Session.send") as send: + send.return_value = mock_send_value(get_bearer_token()) + token = client.authorize_access_token() + assert token["access_token"] == "a" + + with app.test_request_context(): + assert client.token is None + + +def test_oauth2_authorize_session(): app = Flask(__name__) app.secret_key = "!" oauth = OAuth(app) @@ -227,11 +268,13 @@ def test_oauth2_authorize(): assert "state=" in url state = dict(url_decode(urlparse.urlparse(url).query))["state"] assert state is not None - data = session[f"_state_dev_{state}"] + session_data = session[f"_state_dev_{state}"] + assert "exp" in session_data + assert "data" in session_data with app.test_request_context(path=f"/?code=a&state={state}"): # session is cleared in tests - session[f"_state_dev_{state}"] = data + session[f"_state_dev_{state}"] = session_data with mock.patch("requests.sessions.Session.send") as send: send.return_value = mock_send_value(get_bearer_token())
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-fg6f-75jq-6523ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-68158ghsaADVISORY
- github.com/authlib/authlib/commit/2808378611dd6fb2532b189a9087877d8f0c0489ghsax_refsource_MISCWEB
- github.com/authlib/authlib/commit/7974f45e4d7492ab5f527577677f2770ce423228ghsax_refsource_MISCWEB
- github.com/authlib/authlib/security/advisories/GHSA-fg6f-75jq-6523ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.