frankyn: @IlyaFaer this LGTM, but please update relevant conformance tests before merging this code to confirm that it's working as expected. 

I updated conformance tests in https://github.com/googleapis/conformance-tests/tree/master/storage/v1

If you have questions PLMK. Specifically for this change you should rely on the following two conformance tests:

* Query Parameter Encoding
* Query Parameter Ordering
tseaver: This PR also addresses part of the change for #12.
tseaver: @frankyn We don't currently do *any* of the cross-language conformance tests inside `python-storage`.  I just made #57 to track that.
frankyn: @IlyaFaer please respond to @tseaver's comments before merging
frankyn: Friendly ping and pending Kokoro passing. 
frankyn: Merging when green.
frankyn: Pending approval from @tseaver as there is on-going discussion.
frankyn: @tseaver friendly ping
diff --git a/google/cloud/storage/_signing.py b/google/cloud/storage/_signing.py
index 0da075d..8151786 100644
--- a/google/cloud/storage/_signing.py
+++ b/google/cloud/storage/_signing.py
@@ -509,7 +509,7 @@ def generate_signed_url_v4(
 
     :type query_parameters: dict
     :param query_parameters:
-        (Optional) Additional query paramtersto be included as part of the
+        (Optional) Additional query parameters to be included as part of the
         signed URLs.  See:
         https://cloud.google.com/storage/docs/xml-api/reference-headers#query
 
@@ -585,8 +585,7 @@ def generate_signed_url_v4(
     if generation is not None:
         query_parameters["generation"] = generation
 
-    ordered_query_parameters = sorted(query_parameters.items())
-    canonical_query_string = six.moves.urllib.parse.urlencode(ordered_query_parameters)
+    canonical_query_string = _url_encode(query_parameters)
 
     lowercased_headers = dict(ordered_headers)
 
@@ -672,3 +671,34 @@ def _sign_message(message, access_token, service_account_email):
 
     data = json.loads(response.data.decode("utf-8"))
     return data["signature"]
+
+
+def _url_encode(query_params):
+    """Encode query params into URL.
+
+    :type query_params: dict
+    :param query_params: Query params to be encoded.
+
+    :rtype: str
+    :returns: URL encoded query params.
+    """
+    params = [
+        "{}={}".format(_quote_param(name), _quote_param(value))
+        for name, value in query_params.items()
+    ]
+
+    return "&".join(sorted(params))
+
+
+def _quote_param(param):
+    """Quote query param.
+
+    :type param: Any
+    :param param: Query param to be encoded.
+
+    :rtype: str
+    :returns: URL encoded query param.
+    """
+    if not isinstance(param, bytes):
+        param = str(param)
+    return six.moves.urllib.parse.quote(param, safe="~")

[Storage] V4 Signature Supplying Additional Query Parameters With GCS Signed URLs
Tracking issue for work defined in companion document.

[New Client Request - Support For Query Params With GCS Signed URLs](https://docs.google.com/document/d/1PnH2K-M-2wkkYkUyLBh7JDDRqYgBNimP5fUFuEeB4u4/edit#heading=h.xgsgzd636vko)
diff --git a/tests/unit/test__signing.py b/tests/unit/test__signing.py
index c7231b5..47ed2bd 100644
--- a/tests/unit/test__signing.py
+++ b/tests/unit/test__signing.py
@@ -1,3 +1,5 @@
+# -*- coding: utf-8 -*-
+#
 # Copyright 2017 Google LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -705,6 +707,61 @@ class Test_sign_message(unittest.TestCase):
                 )
 
 
+class TestCustomURLEncoding(unittest.TestCase):
+    def test_url_encode(self):
+        from google.cloud.storage._signing import _url_encode
+
+        # param1 includes safe symbol ~
+        # param# includes symbols, which must be encoded
+        query_params = {"param1": "value~1-2", "param#": "*value+value/"}
+
+        self.assertEqual(
+            _url_encode(query_params), "param%23=%2Avalue%2Bvalue%2F&param1=value~1-2"
+        )
+
+
+class TestQuoteParam(unittest.TestCase):
+    def test_ascii_symbols(self):
+        from google.cloud.storage._signing import _quote_param
+
+        encoded_param = _quote_param("param")
+        self.assertIsInstance(encoded_param, str)
+        self.assertEqual(encoded_param, "param")
+
+    def test_quoted_symbols(self):
+        from google.cloud.storage._signing import _quote_param
+
+        encoded_param = _quote_param("!#$%&'()*+,/:;=?@[]")
+        self.assertIsInstance(encoded_param, str)
+        self.assertEqual(
+            encoded_param, "%21%23%24%25%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D"
+        )
+
+    def test_unquoted_symbols(self):
+        from google.cloud.storage._signing import _quote_param
+        import string
+
+        UNQUOTED = string.ascii_letters + string.digits + ".~_-"
+
+        encoded_param = _quote_param(UNQUOTED)
+        self.assertIsInstance(encoded_param, str)
+        self.assertEqual(encoded_param, UNQUOTED)
+
+    def test_unicode_symbols(self):
+        from google.cloud.storage._signing import _quote_param
+
+        encoded_param = _quote_param("ЁЙЦЯЩЯЩ")
+        self.assertIsInstance(encoded_param, str)
+        self.assertEqual(encoded_param, "%D0%81%D0%99%D0%A6%D0%AF%D0%A9%D0%AF%D0%A9")
+
+    def test_bytes(self):
+        from google.cloud.storage._signing import _quote_param
+
+        encoded_param = _quote_param(b"bytes")
+        self.assertIsInstance(encoded_param, str)
+        self.assertEqual(encoded_param, "bytes")
+
+
 _DUMMY_SERVICE_ACCOUNT = None
 
 
@@ -731,6 +788,7 @@ def _run_conformance_test(resource, test_data):
         method=test_data["method"],
         _request_timestamp=test_data["timestamp"],
         headers=test_data.get("headers"),
+        query_parameters=test_data.get("queryParameters"),
     )
 
     assert url == test_data["expectedUrl"]

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-xdist mock
apt-get update apt-get install -y gcc
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
cachetools==4.2.4
certifi==2025.1.31
charset-normalizer==3.4.1
coverage==7.8.0
exceptiongroup==1.2.2
execnet==2.1.1
google-api-core==2.10.2
google-auth==1.35.0
google-cloud-core==1.7.3
-e git+https://github.com/googleapis/python-storage.git@0df8a91cf78d0352b96684228f599e8973a32531#egg=google_cloud_storage
google-resumable-media==0.5.1
googleapis-common-protos==1.69.2
idna==3.10
iniconfig==2.1.0
mock==5.2.0
packaging==24.2
pluggy==1.5.0
protobuf==4.25.6
pyasn1==0.6.1
pyasn1_modules==0.4.2
pytest==8.3.5
pytest-cov==6.0.0
pytest-xdist==3.6.1
requests==2.32.3
rsa==4.9
six==1.17.0
tomli==2.2.1
urllib3==2.3.0

name: python-storage
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:
      - cachetools==4.2.4
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - execnet==2.1.1
      - google-api-core==2.10.2
      - google-auth==1.35.0
      - google-cloud-core==1.7.3
      - google-resumable-media==0.5.1
      - googleapis-common-protos==1.69.2
      - idna==3.10
      - iniconfig==2.1.0
      - mock==5.2.0
      - packaging==24.2
      - pluggy==1.5.0
      - protobuf==4.25.6
      - pyasn1==0.6.1
      - pyasn1-modules==0.4.2
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - pytest-xdist==3.6.1
      - requests==2.32.3
      - rsa==4.9
      - six==1.17.0
      - tomli==2.2.1
      - urllib3==2.3.0
prefix: /opt/conda/envs/python-storage

tests/unit/test__signing.py::TestCustomURLEncoding::test_url_encode tests/unit/test__signing.py::TestQuoteParam::test_ascii_symbols tests/unit/test__signing.py::TestQuoteParam::test_bytes tests/unit/test__signing.py::TestQuoteParam::test_quoted_symbols tests/unit/test__signing.py::TestQuoteParam::test_unicode_symbols tests/unit/test__signing.py::TestQuoteParam::test_unquoted_symbols
tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_expiration_int tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_expiration_naive_datetime tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_expiration_none tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_expiration_other_zone_datetime tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_expiration_timedelta_days tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_expiration_timedelta_seconds tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_expiration_utc_datetime tests/unit/test__signing.py::Test_get_expiration_seconds_v2::test_w_invalid_expiration_type tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_expiration_int tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_expiration_int_gt_seven_days tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_expiration_naive_datetime tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_expiration_none tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_expiration_other_zone_datetime tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_expiration_timedelta tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_expiration_utc_datetime tests/unit/test__signing.py::Test_get_expiration_seconds_v4::test_w_invalid_expiration_type tests/unit/test__signing.py::Test_get_signed_query_params_v2::test_it tests/unit/test__signing.py::Test_get_canonical_headers::test_w_dict tests/unit/test__signing.py::Test_get_canonical_headers::test_w_embedded_ws tests/unit/test__signing.py::Test_get_canonical_headers::test_w_list_and_multiples tests/unit/test__signing.py::Test_get_canonical_headers::test_w_none tests/unit/test__signing.py::Test_canonicalize_v2::test_w_headers_and_resumable tests/unit/test__signing.py::Test_canonicalize_v2::test_w_query_paramters tests/unit/test__signing.py::Test_canonicalize_v2::test_wo_headers_or_query_parameters tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_custom_headers_dict tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_custom_headers_list tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_custom_query_parameters_w_none_value tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_custom_query_parameters_w_string_value tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_endpoint tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_expiration_int tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_generation tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_method tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_method_resumable tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_response_disposition tests/unit/test__signing.py::Test_generate_signed_url_v2::test_w_response_type tests/unit/test__signing.py::Test_generate_signed_url_v2::test_with_access_token tests/unit/test__signing.py::Test_generate_signed_url_v2::test_with_google_credentials tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_api_access_endpoint tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_content_md5 tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_content_type tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_custom_headers tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_custom_host_header tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_custom_payload_hash_goog tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_custom_query_parameters_w_none_value tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_custom_query_parameters_w_string_value tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_defaults tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_expiration_too_long tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_generation tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_method tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_method_resumable tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_response_disposition tests/unit/test__signing.py::Test_generate_signed_url_v4::test_w_response_type tests/unit/test__signing.py::Test_generate_signed_url_v4::test_with_access_token tests/unit/test__signing.py::Test_sign_message::test_sign_bytes tests/unit/test__signing.py::Test_sign_message::test_sign_bytes_failure tests/unit/test__signing.py::test_conformance_bucket[test_data0] tests/unit/test__signing.py::test_conformance_blob[test_data0] tests/unit/test__signing.py::test_conformance_blob[test_data1] tests/unit/test__signing.py::test_conformance_blob[test_data2] tests/unit/test__signing.py::test_conformance_blob[test_data3] tests/unit/test__signing.py::test_conformance_blob[test_data4] tests/unit/test__signing.py::test_conformance_blob[test_data5] tests/unit/test__signing.py::test_conformance_blob[test_data6] tests/unit/test__signing.py::test_conformance_blob[test_data7] tests/unit/test__signing.py::test_conformance_blob[test_data8]
google/cloud/storage/_signing.py
google/cloud/storage/_signing.py