VYPR
Moderate severityOSV Advisory· Published Jan 8, 2026· Updated Mar 30, 2026

Authlib: 1-click Account Takeover

CVE-2025-68158

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.

PackageAffected versionsPatched versions
authlibPyPI
>= 1.0.0, < 1.6.61.6.6

Affected products

2
  • Authlib/AuthlibOSV2 versions
    v0.1, v0.10, v0.11, …+ 1 more
    • (no CPE)range: v0.1, v0.10, v0.11, …
    • (no CPE)range: >=1.0.0, <=1.6.5.6.5

Patches

2
7974f45e4d74

fix: authorization and token endpoints request empty scope parameter management

https://github.com/authlib/authlibÉloi RivardDec 18, 2025via ghsa
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
    
2808378611dd

Merge commit from fork

https://github.com/authlib/authlibHsiaoming YangDec 12, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.