diff --git a/CHANGELOG.md b/CHANGELOG.md
index 186083e..e1fe12f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Improvements:
 Bugfixes:
 
   - Fix Resource Owner Grant responding with HTTP status code '500' in case an owner could not be authorized ([@wndhydrnt][])
+  - Fix "scope" parameter not being urlencoded ([@wndhydrnt][])
 
 ## 0.7.0
 
diff --git a/oauth2/grant.py b/oauth2/grant.py
index ff9d65b..5dd586f 100644
--- a/oauth2/grant.py
+++ b/oauth2/grant.py
@@ -466,7 +466,7 @@ class AuthorizationCodeAuthHandler(AuthorizeMixin, AuthRequestMixin,
         query = "code=" + code
 
         if self.state is not None:
-            query += "&state=" + self.state
+            query += "&state=" + quote(self.state)
 
         return "%s?%s" % (self.client.redirect_uri, query)
 
@@ -705,7 +705,7 @@ class ImplicitGrantHandler(AuthorizeMixin, AuthRequestMixin, GrantHandler):
             format(self.client.redirect_uri, token)
 
         if self.state is not None:
-            uri_with_fragment += "&state=" + self.state
+            uri_with_fragment += "&state=" + quote(self.state)
 
         if self.scope_handler.send_back is True:
             scopes_as_string = encode_scopes(self.scope_handler.scopes,

Returned state token not url-encoded
Current implementation reads state from request query parameter with request.get_param(), which handles url-decoding automatically. When state is written back to a redirect url it is not url-encoded. This causes state mismatch in client. Only applies to clients that use state strings that yield are url-decodable.

Reproducable with /examples/authorization_code_grant.py, modifying ClientApplication.

Generate a state token in client:
```
def _request_auth_token(self):
        print("Requesting authorization token...")
        auth_endpoint = self.api_server_url + "/authorize"
        self.state = "123123%25"
        query = urllib.urlencode({"client_id": "abc",
                                  "redirect_uri": self.callback_url,
                                  "response_type": "code",
                                  "state":self.state,
                                  })

        location = "%s?%s" % (auth_endpoint, query)
```

And check state when receiving token:
```
def _read_auth_token(self, env):
    print("Receiving authorization token...")

    query_params = urlparse.parse_qs(env["QUERY_STRING"])
    self.auth_token = query_params["code"][0]
    
    if self.state and self.state != query_params["state"][0]:
        raise Exception("Received auth token with invalid state. If this was a browser, CSRF")
```   

Fix by url-encoding the state when applying it to redirect urls.
/oauth2/grant.py
```
469      query += "&state=" + quote(self.state)

708      uri_with_fragment += "&state=" + quote(self.state)
diff --git a/oauth2/test/test_grant.py b/oauth2/test/test_grant.py
index 1cb7cb2..231c3ca 100644
--- a/oauth2/test/test_grant.py
+++ b/oauth2/test/test_grant.py
@@ -1,6 +1,7 @@
 from mock import Mock, call, patch
 import json
 from oauth2.client_authenticator import ClientAuthenticator
+from oauth2.compatibility import quote
 from oauth2.test import unittest
 from oauth2.web import Request, Response, ResourceOwnerGrantSiteAdapter, \
     ImplicitGrantSiteAdapter, AuthorizationCodeGrantSiteAdapter
@@ -196,11 +197,11 @@ class AuthorizationCodeAuthHandlerTestCase(unittest.TestCase):
         code = "abcd"
         environ = {"session": "data"}
         scopes = ["scope"]
-        state = "mystate"
+        state = "my%state"
         redirect_uri = "https://callback"
         user_data = {"user_id": 789}
 
-        location_uri = "%s?code=%s&state=%s" % (redirect_uri, code, state)
+        location_uri = "%s?code=%s&state=%s" % (redirect_uri, code, quote(state))
 
         auth_code_store_mock = Mock(spec=AuthCodeStore)
 
@@ -793,6 +794,7 @@ class ImplicitGrantTestCase(unittest.TestCase):
         request_mock.get_param.assert_called_with("response_type")
         self.assertEqual(result_class, None)
 
+
 class ImplicitGrantHandlerTestCase(unittest.TestCase):
     def test_process_redirect_with_token(self):
         client_id = "abc"
@@ -848,11 +850,11 @@ class ImplicitGrantHandlerTestCase(unittest.TestCase):
         ImplicitGrantHandler should include the value of the "state" query parameter from request in redirect
         """
         redirect_uri = "http://callback"
-        state = "XHGFI"
+        state = "XH%GFI"
         token = "tokencode"
         user_data = ({}, 1)
 
-        expected_redirect_uri = "%s#access_token=%s&token_type=bearer&state=%s" % (redirect_uri, token, state)
+        expected_redirect_uri = "%s#access_token=%s&token_type=bearer&state=%s" % (redirect_uri, token, quote(state))
 
         response_mock = Mock(spec=Response)
 

pip install -e .
parse_log_pytest
requirements.txt
pytest
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
async-timeout==5.0.1
dnspython==2.7.0
exceptiongroup==1.2.2
iniconfig==2.1.0
mock==5.2.0
mysql-connector-python @ http://dev.mysql.com/get/Downloads/Connector-Python/mysql-connector-python-1.1.7.tar.gz#sha256=66f9aeadf2b908be0e31bf683cfa199c1c13401eb7c0acce7cec56d75d76e24a
nose==1.3.7
packaging==24.2
pluggy==1.5.0
pymongo==4.11.3
pytest==8.3.5
-e git+https://github.com/wndhydrnt/python-oauth2.git@afa9d84b54392391b888dc3bff36aaf08bbd5834#egg=python_oauth2
redis==5.2.1
tomli==2.2.1

name: python-oauth2
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - async-timeout==5.0.1
      - dnspython==2.7.0
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - mock==5.2.0
      - mysql-connector-python==1.1.7
      - nose==1.3.7
      - packaging==24.2
      - pluggy==1.5.0
      - pymongo==4.11.3
      - pytest==8.3.5
      - redis==5.2.1
      - tomli==2.2.1
prefix: /opt/conda/envs/python-oauth2

oauth2/test/test_grant.py::AuthorizationCodeAuthHandlerTestCase::test_process oauth2/test/test_grant.py::ImplicitGrantHandlerTestCase::test_process_redirect_with_state
oauth2/test/test_grant.py::AuthorizationCodeGrantTestCase::test_create_auth_handler oauth2/test/test_grant.py::AuthorizationCodeGrantTestCase::test_create_no_match oauth2/test/test_grant.py::AuthorizationCodeGrantTestCase::test_create_token_handler oauth2/test/test_grant.py::AuthRequestMixinTestCase::test_read_validate_params_all_valid oauth2/test/test_grant.py::AuthorizeMixinTestCase::test_authorize_dict_return oauth2/test/test_grant.py::AuthorizeMixinTestCase::test_authorize_tuple_return oauth2/test/test_grant.py::AuthorizeMixinTestCase::test_authorize_user_denied_access oauth2/test/test_grant.py::AuthorizeMixinTestCase::test_authorize_user_not_authenticated oauth2/test/test_grant.py::AuthorizationCodeAuthHandlerTestCase::test_process_not_confirmed oauth2/test/test_grant.py::AuthorizationCodeAuthHandlerTestCase::test_redirect_oauth_error oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_process_no_refresh_token oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_process_with_refresh_token oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_process_with_unique_access_token oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_process_with_unique_access_token_different_scope oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_process_with_unique_access_token_expired_token oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_process_with_unique_access_token_no_user_id oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_process_with_unique_access_token_not_found oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_read_validate_params oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_read_validate_params_missing_code oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_read_validate_params_no_auth_code_found oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_read_validate_params_token_expired oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_read_validate_params_unknown_code oauth2/test/test_grant.py::AuthorizationCodeTokenHandlerTestCase::test_read_validate_params_wrong_redirect_uri_in_code_data oauth2/test/test_grant.py::ImplicitGrantTestCase::test_create_matching_response_type oauth2/test/test_grant.py::ImplicitGrantTestCase::test_create_not_matching_response_type oauth2/test/test_grant.py::ImplicitGrantHandlerTestCase::test_process_redirect_with_token oauth2/test/test_grant.py::ImplicitGrantHandlerTestCase::test_process_unconfirmed oauth2/test/test_grant.py::ImplicitGrantHandlerTestCase::test_process_user_denied_access oauth2/test/test_grant.py::ImplicitGrantHandlerTestCase::test_process_with_scope oauth2/test/test_grant.py::ImplicitGrantHandlerTestCase::test_redirect_oauth_error oauth2/test/test_grant.py::ResourceOwnerGrantTestCase::test_call oauth2/test/test_grant.py::ResourceOwnerGrantTestCase::test_call_no_resource_request oauth2/test/test_grant.py::ResourceOwnerGrantHandlerTestCase::test_handle_error_owner_not_authenticated oauth2/test/test_grant.py::ResourceOwnerGrantHandlerTestCase::test_process oauth2/test/test_grant.py::ResourceOwnerGrantHandlerTestCase::test_process_invalid_user oauth2/test/test_grant.py::ResourceOwnerGrantHandlerTestCase::test_process_redirect_with_scope oauth2/test/test_grant.py::ResourceOwnerGrantHandlerTestCase::test_process_with_refresh_token oauth2/test/test_grant.py::ResourceOwnerGrantHandlerTestCase::test_read_validate_params oauth2/test/test_grant.py::ScopeTestCase::test_compare_invalid_scope_requested oauth2/test/test_grant.py::ScopeTestCase::test_compare_scopes_equal oauth2/test/test_grant.py::ScopeTestCase::test_compare_valid_scope_subset oauth2/test/test_grant.py::ScopeTestCase::test_parse_scope_default_on_no_matching_scopes oauth2/test/test_grant.py::ScopeTestCase::test_parse_scope_default_on_no_scope oauth2/test/test_grant.py::ScopeTestCase::test_parse_scope_exception_on_available_scopes_no_scope_given oauth2/test/test_grant.py::ScopeTestCase::test_parse_scope_no_value_on_no_scope_no_default oauth2/test/test_grant.py::ScopeTestCase::test_parse_scope_scope_present_in_body oauth2/test/test_grant.py::ScopeTestCase::test_parse_scope_scope_present_in_query oauth2/test/test_grant.py::RefreshTokenTestCase::test_call oauth2/test/test_grant.py::RefreshTokenTestCase::test_call_other_grant_type oauth2/test/test_grant.py::RefreshTokenTestCase::test_call_wrong_path oauth2/test/test_grant.py::RefreshTokenHandlerTestCase::test_process_no_reissue oauth2/test/test_grant.py::RefreshTokenHandlerTestCase::test_process_with_reissue oauth2/test/test_grant.py::RefreshTokenHandlerTestCase::test_read_validate_params oauth2/test/test_grant.py::RefreshTokenHandlerTestCase::test_read_validate_params_expired_refresh_token oauth2/test/test_grant.py::RefreshTokenHandlerTestCase::test_read_validate_params_invalid_refresh_token oauth2/test/test_grant.py::RefreshTokenHandlerTestCase::test_read_validate_params_no_refresh_token oauth2/test/test_grant.py::ClientCredentialsGrantTestCase::test_call oauth2/test/test_grant.py::ClientCredentialsGrantTestCase::test_call_other_grant_type oauth2/test/test_grant.py::ClientCredentialsGrantTestCase::test_call_wrong_request_path oauth2/test/test_grant.py::ClientCredentialsHandlerTestCase::test_process oauth2/test/test_grant.py::ClientCredentialsHandlerTestCase::test_process_with_refresh_token oauth2/test/test_grant.py::ClientCredentialsHandlerTestCase::test_read_validate_params
oauth2/grant.py CHANGELOG.md
oauth2/grant.py CHANGELOG.md