Repository: urllib3/urllib3

Issue:
Switch to setting SSLContext.minimum_version
The `ssl.OP_NO_TLSvX` options were deprecated in Python 3.7. These values are available on OpenSSL 1.1.0g+, if we detect they are available with an SSLContext that supports them we should set `SSLContext.minumum_version = TLSVersion.TLSv1_2`

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/urllib3/urllib3/pull/2220?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) Report
> Merging [#2220](https://codecov.io/gh/urllib3/urllib3/pull/2220?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) (b2bd59e) into [main](https://codecov.io/gh/urllib3/urllib3/commit/52079f1df9d1e3810e02762458bc665c1a438778?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) (52079f1) will **decrease** coverage by `0.65%`.
> The diff coverage is `50.00%`.

[![Impacted file tree graph](https://codecov.io/gh/urllib3/urllib3/pull/2220/graphs/tree.svg?width=650&height=150&src=pr&token=UYKHB2uXDn&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3)](https://codecov.io/gh/urllib3/urllib3/pull/2220?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3)

```diff
@@             Coverage Diff             @@
##              main    #2220      +/-   ##
===========================================
- Coverage   100.00%   99.34%   -0.66%     
===========================================
  Files           25       25              
  Lines         2275     2283       +8     
===========================================
- Hits          2275     2268       -7     
- Misses           0       15      +15     
```


| [Impacted Files](https://codecov.io/gh/urllib3/urllib3/pull/2220?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3) | Coverage Δ | |
|---|---|---|
| [src/urllib3/poolmanager.py](https://codecov.io/gh/urllib3/urllib3/pull/2220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvcG9vbG1hbmFnZXIucHk=) | `100.00% <ø> (ø)` | |
| [src/urllib3/connection.py](https://codecov.io/gh/urllib3/urllib3/pull/2220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvY29ubmVjdGlvbi5weQ==) | `96.42% <33.33%> (-3.58%)` | :arrow_down: |
| [src/urllib3/util/ssl\_.py](https://codecov.io/gh/urllib3/urllib3/pull/2220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvdXRpbC9zc2xfLnB5) | `97.84% <66.66%> (-2.16%)` | :arrow_down: |
| [src/urllib3/util/wait.py](https://codecov.io/gh/urllib3/urllib3/pull/2220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvdXRpbC93YWl0LnB5) | `90.69% <0.00%> (-9.31%)` | :arrow_down: |
| [src/urllib3/\_collections.py](https://codecov.io/gh/urllib3/urllib3/pull/2220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvX2NvbGxlY3Rpb25zLnB5) | `99.42% <0.00%> (-0.58%)` | :arrow_down: |
| [src/urllib3/response.py](https://codecov.io/gh/urllib3/urllib3/pull/2220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvcmVzcG9uc2UucHk=) | `99.48% <0.00%> (-0.52%)` | :arrow_down: |
| [src/urllib3/connectionpool.py](https://codecov.io/gh/urllib3/urllib3/pull/2220/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3#diff-c3JjL3VybGxpYjMvY29ubmVjdGlvbnBvb2wucHk=) | `100.00% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/urllib3/urllib3/pull/2220?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/urllib3/urllib3/pull/2220?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3). Last update [52079f1...b2bd59e](https://codecov.io/gh/urllib3/urllib3/pull/2220?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=urllib3).

pquentin: You can now remove the Python 3.6 specific code! This will make this easier
sethmlarson: Definitely! I have some local changes that rebased this on `main` without the Python 3.6 fallbacks but still have tests to work though.
sethmlarson: I think this is ready for review functionality-wise! For future reviewers please rebase merge this PR as it contains multiple features.
sethmlarson: And now the feature is documented too, ready for a full review.

Patch:
diff --git a/changelog/2110.feature.rst b/changelog/2110.feature.rst
new file mode 100644
index 00000000..666cf4eb
--- /dev/null
+++ b/changelog/2110.feature.rst
@@ -0,0 +1,9 @@
+Added ``ssl_minimum_version`` and ``ssl_maximum_version`` options which set
+``SSLContext.minimum_version`` and ``SSLContext.maximum_version``.
+
+Changed ``ssl_version`` to instead set the corresponding ``SSLContext.minimum_version``
+and ``SSLContext.maximum_version`` values.  Regardless of ``ssl_version`` passed
+``SSLContext`` objects are now constructed using ``ssl.PROTOCOL_TLS_CLIENT``.
+
+Deprecated ``ssl_version`` option in favor of ``ssl_minimum_version``. ``ssl_version``
+will be removed in a future 2.x release of urllib3.
diff --git a/changelog/2373.feature.rst b/changelog/2373.feature.rst
new file mode 100644
index 00000000..b20e3a0e
--- /dev/null
+++ b/changelog/2373.feature.rst
@@ -0,0 +1,1 @@
+Changed default ``SSLContext.minimum_version`` to be ``TLSVersion.TLSv1_2`` in line with Python 3.10.
\ No newline at end of file
diff --git a/docs/advanced-usage.rst b/docs/advanced-usage.rst
index 2452a857..3c49fdfd 100644
--- a/docs/advanced-usage.rst
+++ b/docs/advanced-usage.rst
@@ -357,6 +357,38 @@ the ``key_password`` parameter to specify a password to decrypt the key.
 
 If your key isn't encrypted the ``key_password`` parameter isn't required.
 
+TLS minimum and maximum versions
+--------------------------------
+
+When the configured TLS versions by urllib3 aren't compatible with the TLS versions that
+the server is willing to use you'll likely see an error like this one:
+
+.. code-block::
+
+    SSLError(1, '[SSL: UNSUPPORTED_PROTOCOL] unsupported protocol (_ssl.c:1124)')
+
+Starting in v2.0 by default urllib3 uses TLS 1.2 and later so servers that only support TLS 1.1
+or earlier will not work by default with urllib3.
+
+To fix the issue you'll need to use the ``ssl_minimum_version`` option along with the `TLSVersion enum`_
+in the standard library ``ssl`` module to configure urllib3 to accept a wider range of TLS versions.
+
+For the best security it's a good idea to set this value to the version of TLS that's being used by the
+server. For example if the server requires TLS 1.0 you'd configure urllib3 like so:
+
+.. code-block:: python
+    
+    import ssl
+    import urllib3
+    
+    http = urllib3.PoolManager(
+        ssl_minimum_version=ssl.TLSVersion.TLSv1
+    )
+    # This request works!
+    resp = http.request("GET", "https://tls-v1-0.badssl.com:1010")
+
+.. _TLSVersion enum: https://docs.python.org/3/library/ssl.html#ssl.TLSVersion
+
 .. _ssl_mac:
 .. _certificate_validation_and_mac_os_x:
 
diff --git a/docs/v2-roadmap.rst b/docs/v2-roadmap.rst
index 26ddb5df..261524a5 100644
--- a/docs/v2-roadmap.rst
+++ b/docs/v2-roadmap.rst
@@ -55,7 +55,7 @@ over the wire.
 
 If you still need to use TLS 1.0 or 1.1 in your application
 you can still upgrade to v2.0, you'll only need to set
-``ssl_version`` to the proper values to continue using
+``ssl_minimum_version`` to the proper value to continue using
 legacy TLS versions.
 
 
diff --git a/src/urllib3/connection.py b/src/urllib3/connection.py
index f4aebee1..240ab516 100644
--- a/src/urllib3/connection.py
+++ b/src/urllib3/connection.py
@@ -349,6 +349,8 @@ class HTTPSConnection(HTTPConnection):
     ca_cert_dir: Optional[str] = None
     ca_cert_data: Union[None, str, bytes] = None
     ssl_version: Optional[Union[int, str]] = None
+    ssl_minimum_version: Optional[int] = None
+    ssl_maximum_version: Optional[int] = None
     assert_fingerprint: Optional[str] = None
     tls_in_tls_required: bool = False
 
@@ -387,6 +389,9 @@ class HTTPSConnection(HTTPConnection):
         self.key_password = key_password
         self.ssl_context = ssl_context
         self.server_hostname = server_hostname
+        self.ssl_version = None
+        self.ssl_minimum_version = None
+        self.ssl_maximum_version = None
 
     def set_cert(
         self,
@@ -466,6 +471,8 @@ class HTTPSConnection(HTTPConnection):
             default_ssl_context = True
             self.ssl_context = create_urllib3_context(
                 ssl_version=resolve_ssl_version(self.ssl_version),
+                ssl_minimum_version=self.ssl_minimum_version,
+                ssl_maximum_version=self.ssl_maximum_version,
                 cert_reqs=resolve_cert_reqs(self.cert_reqs),
             )
             # In some cases, we want to verify hostnames ourselves
@@ -510,23 +517,6 @@ class HTTPSConnection(HTTPConnection):
             tls_in_tls=tls_in_tls,
         )
 
-        # If we're using all defaults and the connection
-        # is TLSv1 or TLSv1.1 we throw a DeprecationWarning
-        # for the host.
-        if (
-            default_ssl_context
-            and self.ssl_version is None
-            and hasattr(self.sock, "version")
-            and self.sock.version() in {"TLSv1", "TLSv1.1"}
-        ):
-            warnings.warn(
-                "Negotiating TLSv1/TLSv1.1 by default is deprecated "
-                "and will be disabled in urllib3 v2.0.0. Connecting to "
-                f"'{self.host}' with '{self.sock.version()}' can be "
-                "enabled by explicitly opting-in with 'ssl_version'",
-                DeprecationWarning,
-            )
-
         if self.assert_fingerprint:
             assert_fingerprint(
                 self.sock.getpeercert(binary_form=True), self.assert_fingerprint
diff --git a/src/urllib3/connectionpool.py b/src/urllib3/connectionpool.py
index 6578f6c2..f0d99b0c 100644
--- a/src/urllib3/connectionpool.py
+++ b/src/urllib3/connectionpool.py
@@ -53,6 +53,8 @@ from .util.url import parse_url
 from .util.util import to_str
 
 if TYPE_CHECKING:
+    import ssl
+
     from typing_extensions import Literal
 
 log = logging.getLogger(__name__)
@@ -902,6 +904,8 @@ class HTTPSConnectionPool(HTTPConnectionPool):
         key_password: Optional[str] = None,
         ca_certs: Optional[str] = None,
         ssl_version: Optional[Union[int, str]] = None,
+        ssl_minimum_version: Optional["ssl.TLSVersion"] = None,
+        ssl_maximum_version: Optional["ssl.TLSVersion"] = None,
         assert_hostname: Optional[Union[str, "Literal[False]"]] = None,
         assert_fingerprint: Optional[str] = None,
         ca_cert_dir: Optional[str] = None,
@@ -928,6 +932,8 @@ class HTTPSConnectionPool(HTTPConnectionPool):
         self.ca_certs = ca_certs
         self.ca_cert_dir = ca_cert_dir
         self.ssl_version = ssl_version
+        self.ssl_minimum_version = ssl_minimum_version
+        self.ssl_maximum_version = ssl_maximum_version
         self.assert_hostname = assert_hostname
         self.assert_fingerprint = assert_fingerprint
 
@@ -949,6 +955,9 @@ class HTTPSConnectionPool(HTTPConnectionPool):
                 assert_fingerprint=self.assert_fingerprint,
             )
             conn.ssl_version = self.ssl_version
+            conn.ssl_minimum_version = self.ssl_minimum_version
+            conn.ssl_maximum_version = self.ssl_maximum_version
+
         return conn
 
     def _prepare_proxy(self, conn: HTTPSConnection) -> None:  # type: ignore[override]
diff --git a/src/urllib3/contrib/pyopenssl.py b/src/urllib3/contrib/pyopenssl.py
index 9851b7b6..a5888fbe 100644
--- a/src/urllib3/contrib/pyopenssl.py
+++ b/src/urllib3/contrib/pyopenssl.py
@@ -107,6 +107,44 @@ _stdlib_to_openssl_verify = {
 }
 _openssl_to_stdlib_verify = {v: k for k, v in _stdlib_to_openssl_verify.items()}
 
+# The SSLvX values are the most likely to be missing in the future
+# but we check them all just to be sure.
+_OP_NO_SSLv2: int = getattr(OpenSSL.SSL, "OP_NO_SSLv2", 0)
+_OP_NO_SSLv3: int = getattr(OpenSSL.SSL, "OP_NO_SSLv3", 0)
+_OP_NO_TLSv1: int = getattr(OpenSSL.SSL, "OP_NO_TLSv1", 0)
+_OP_NO_TLSv1_1: int = getattr(OpenSSL.SSL, "OP_NO_TLSv1_1", 0)
+_OP_NO_TLSv1_2: int = getattr(OpenSSL.SSL, "OP_NO_TLSv1_2", 0)
+_OP_NO_TLSv1_3: int = getattr(OpenSSL.SSL, "OP_NO_TLSv1_3", 0)
+
+_openssl_to_ssl_minimum_version: Dict[int, int] = {
+    ssl.TLSVersion.MINIMUM_SUPPORTED: _OP_NO_SSLv2,
+    ssl.TLSVersion.SSLv3: _OP_NO_SSLv2,
+    ssl.TLSVersion.TLSv1: _OP_NO_SSLv2 | _OP_NO_SSLv3,
+    ssl.TLSVersion.TLSv1_1: _OP_NO_SSLv2 | _OP_NO_SSLv3 | _OP_NO_TLSv1,
+    ssl.TLSVersion.TLSv1_2: _OP_NO_SSLv2 | _OP_NO_SSLv3 | _OP_NO_TLSv1 | _OP_NO_TLSv1_1,
+    ssl.TLSVersion.TLSv1_3: (
+        _OP_NO_SSLv2 | _OP_NO_SSLv3 | _OP_NO_TLSv1 | _OP_NO_TLSv1_1 | _OP_NO_TLSv1_2
+    ),
+    ssl.TLSVersion.MAXIMUM_SUPPORTED: (
+        _OP_NO_SSLv2 | _OP_NO_SSLv3 | _OP_NO_TLSv1 | _OP_NO_TLSv1_1 | _OP_NO_TLSv1_2
+    ),
+}
+_openssl_to_ssl_maximum_version: Dict[int, int] = {
+    ssl.TLSVersion.MINIMUM_SUPPORTED: (
+        _OP_NO_SSLv2 | _OP_NO_TLSv1 | _OP_NO_TLSv1_1 | _OP_NO_TLSv1_2 | _OP_NO_TLSv1_3
+    ),
+    ssl.TLSVersion.SSLv3: (
+        _OP_NO_SSLv2 | _OP_NO_TLSv1 | _OP_NO_TLSv1_1 | _OP_NO_TLSv1_2 | _OP_NO_TLSv1_3
+    ),
+    ssl.TLSVersion.TLSv1: (
+        _OP_NO_SSLv2 | _OP_NO_TLSv1_1 | _OP_NO_TLSv1_2 | _OP_NO_TLSv1_3
+    ),
+    ssl.TLSVersion.TLSv1_1: _OP_NO_SSLv2 | _OP_NO_TLSv1_2 | _OP_NO_TLSv1_3,
+    ssl.TLSVersion.TLSv1_2: _OP_NO_SSLv2 | _OP_NO_TLSv1_3,
+    ssl.TLSVersion.TLSv1_3: _OP_NO_SSLv2,
+    ssl.TLSVersion.MAXIMUM_SUPPORTED: _OP_NO_SSLv2,
+}
+
 # OpenSSL will only write 16K at a time
 SSL_WRITE_BLOCKSIZE = 16384
 
@@ -407,6 +445,8 @@ class PyOpenSSLContext:
         self._ctx = OpenSSL.SSL.Context(self.protocol)
         self._options = 0
         self.check_hostname = False
+        self._minimum_version: int = ssl.TLSVersion.MINIMUM_SUPPORTED
+        self._maximum_version: int = ssl.TLSVersion.MAXIMUM_SUPPORTED
 
     @property
     def options(self) -> int:
@@ -415,7 +455,7 @@ class PyOpenSSLContext:
     @options.setter
     def options(self, value: int) -> None:
         self._options = value
-        self._ctx.set_options(value)
+        self._set_ctx_options()
 
     @property
     def verify_mode(self) -> int:
@@ -498,6 +538,31 @@ class PyOpenSSLContext:
 
         return WrappedSocket(cnx, sock)
 
+    def _set_ctx_options(self) -> None:
+        self._ctx.set_options(
+            self._options
+            | _openssl_to_ssl_minimum_version[self._minimum_version]
+            | _openssl_to_ssl_maximum_version[self._maximum_version]
+        )
+
+    @property
+    def minimum_version(self) -> int:
+        return self._minimum_version
+
+    @minimum_version.setter
+    def minimum_version(self, minimum_version: int) -> None:
+        self._minimum_version = minimum_version
+        self._set_ctx_options()
+
+    @property
+    def maximum_version(self) -> int:
+        return self._maximum_version
+
+    @maximum_version.setter
+    def maximum_version(self, maximum_version: int) -> None:
+        self._maximum_version = maximum_version
+        self._set_ctx_options()
+
 
 def _verify_callback(
     cnx: OpenSSL.SSL.Connection,
diff --git a/src/urllib3/contrib/securetransport.py b/src/urllib3/contrib/securetransport.py
index e39d479d..91dc9488 100644
--- a/src/urllib3/contrib/securetransport.py
+++ b/src/urllib3/contrib/securetransport.py
@@ -67,6 +67,7 @@ from typing import (
     TYPE_CHECKING,
     Any,
     BinaryIO,
+    Dict,
     Generator,
     List,
     Optional,
@@ -165,6 +166,15 @@ if hasattr(ssl, "PROTOCOL_TLSv1_2"):
     )
 
 
+_tls_version_to_st: Dict[int, int] = {
+    ssl.TLSVersion.MINIMUM_SUPPORTED: SecurityConst.kTLSProtocol1,
+    ssl.TLSVersion.TLSv1: SecurityConst.kTLSProtocol1,
+    ssl.TLSVersion.TLSv1_1: SecurityConst.kTLSProtocol11,
+    ssl.TLSVersion.TLSv1_2: SecurityConst.kTLSProtocol12,
+    ssl.TLSVersion.MAXIMUM_SUPPORTED: SecurityConst.kTLSProtocol12,
+}
+
+
 def inject_into_urllib3() -> None:
     """
     Monkey-patch urllib3 with SecureTransport-backed SSL-support.
@@ -753,7 +763,11 @@ class SecureTransportContext:
     """
 
     def __init__(self, protocol: int) -> None:
-        self._min_version, self._max_version = _protocol_to_min_max[protocol]
+        self._minimum_version: int = ssl.TLSVersion.MINIMUM_SUPPORTED
+        self._maximum_version: int = ssl.TLSVersion.MAXIMUM_SUPPORTED
+        if protocol not in (None, ssl.PROTOCOL_TLS, ssl.PROTOCOL_TLS_CLIENT):
+            self._min_version, self._max_version = _protocol_to_min_max[protocol]
+
         self._options = 0
         self._verify = False
         self._trust_bundle: Optional[bytes] = None
@@ -882,11 +896,27 @@ class SecureTransportContext:
             server_hostname,
             self._verify,
             self._trust_bundle,
-            self._min_version,
-            self._max_version,
+            _tls_version_to_st[self._minimum_version],
+            _tls_version_to_st[self._maximum_version],
             self._client_cert,
             self._client_key,
             self._client_key_passphrase,
             self._alpn_protocols,
         )
         return wrapped_socket
+
+    @property
+    def minimum_version(self) -> int:
+        return self._minimum_version
+
+    @minimum_version.setter
+    def minimum_version(self, minimum_version: int) -> None:
+        self._minimum_version = minimum_version
+
+    @property
+    def maximum_version(self) -> int:
+        return self._maximum_version
+
+    @maximum_version.setter
+    def maximum_version(self, maximum_version: int) -> None:
+        self._maximum_version = maximum_version
diff --git a/src/urllib3/poolmanager.py b/src/urllib3/poolmanager.py
index 01fd8b6b..0c622465 100644
--- a/src/urllib3/poolmanager.py
+++ b/src/urllib3/poolmanager.py
@@ -50,6 +50,8 @@ SSL_KEYWORDS = (
     "cert_reqs",
     "ca_certs",
     "ssl_version",
+    "ssl_minimum_version",
+    "ssl_maximum_version",
     "ca_cert_dir",
     "ssl_context",
     "key_password",
@@ -79,6 +81,8 @@ class PoolKey(NamedTuple):
     key_cert_reqs: Optional[str]
     key_ca_certs: Optional[str]
     key_ssl_version: Optional[Union[int, str]]
+    key_ssl_minimum_version: Optional["ssl.TLSVersion"]
+    key_ssl_maximum_version: Optional["ssl.TLSVersion"]
     key_ca_cert_dir: Optional[str]
     key_ssl_context: Optional["ssl.SSLContext"]
     key_maxsize: Optional[int]
diff --git a/src/urllib3/util/ssl_.py b/src/urllib3/util/ssl_.py
index 2da3c821..35fbea59 100644
--- a/src/urllib3/util/ssl_.py
+++ b/src/urllib3/util/ssl_.py
@@ -41,6 +41,9 @@ if TYPE_CHECKING:
 
     from .ssltransport import SSLTransport as SSLTransportType
 
+# Mapping from 'ssl.PROTOCOL_TLSX' to 'TLSVersion.X'
+_SSL_VERSION_TO_TLS_VERSION: Dict[int, int] = {}
+
 try:  # Do we have ssl at all?
     import ssl
     from ssl import (  # type: ignore[misc]
@@ -56,12 +59,24 @@ try:  # Do we have ssl at all?
         OP_NO_SSLv2,
         OP_NO_SSLv3,
         SSLContext,
+        TLSVersion,
     )
 
     USE_DEFAULT_SSLCONTEXT_CIPHERS = _is_ge_openssl_v1_1_1(
         OPENSSL_VERSION, OPENSSL_VERSION_NUMBER
     )
     PROTOCOL_SSLv23 = PROTOCOL_TLS
+
+    # Need to be careful here in case old TLS versions get
+    # removed in future 'ssl' module implementations.
+    for attr in ("TLSv1", "TLSv1_1", "TLSv1_2"):
+        try:
+            _SSL_VERSION_TO_TLS_VERSION[getattr(ssl, f"PROTOCOL_{attr}")] = getattr(
+                TLSVersion, attr
+            )
+        except AttributeError:  # Defensive:
+            continue
+
     from .ssltransport import SSLTransport  # type: ignore[misc]
 except ImportError:
     OP_NO_COMPRESSION = 0x20000  # type: ignore[assignment]
@@ -77,7 +92,6 @@ _PCTRTTT = Tuple[_PCTRTT, ...]
 _TYPE_PEER_CERT_RET_DICT = Dict[str, Union[str, _PCTRTTT, _PCTRTT]]
 _TYPE_PEER_CERT_RET = Union[_TYPE_PEER_CERT_RET_DICT, bytes, None]
 
-
 # A secure default.
 # Sources for more information on TLS ciphers:
 #
@@ -190,6 +204,8 @@ def create_urllib3_context(
     cert_reqs: Optional[int] = None,
     options: Optional[int] = None,
     ciphers: Optional[str] = None,
+    ssl_minimum_version: Optional[int] = None,
+    ssl_maximum_version: Optional[int] = None,
 ) -> "ssl.SSLContext":
     """All arguments have the same meaning as ``ssl_wrap_socket``.
 
@@ -212,6 +228,14 @@ def create_urllib3_context(
         The desired protocol version to use. This will default to
         PROTOCOL_SSLv23 which will negotiate the highest protocol that both
         the server and your installation of OpenSSL support.
+
+        This parameter is deprecated instead use 'ssl_minimum_version'.
+    :param ssl_minimum_version:
+        The minimum version of TLS to be used. Use the 'ssl.TLSVersion' enum for specifying the value.
+    :param ssl_maximum_version:
+        The maximum version of TLS to be used. Use the 'ssl.TLSVersion' enum for specifying the value.
+        Not recommended to set to anything other than 'ssl.TLSVersion.MAXIMUM_SUPPORTED' which is the
+        default value.
     :param cert_reqs:
         Whether to require the certificate verification. This defaults to
         ``ssl.CERT_REQUIRED``.
@@ -228,11 +252,47 @@ def create_urllib3_context(
     if SSLContext is None:
         raise TypeError("Can't create an SSLContext object without an ssl module")
 
-    # PROTOCOL_TLS is deprecated in Python 3.10 so we pass PROTOCOL_TLS_CLIENT instead.
-    if ssl_version in (None, PROTOCOL_TLS):
-        ssl_version = PROTOCOL_TLS_CLIENT
+    # This means 'ssl_version' was specified as an exact value.
+    if ssl_version not in (None, PROTOCOL_TLS, PROTOCOL_TLS_CLIENT):
+        # Disallow setting 'ssl_version' and 'ssl_minimum|maximum_version'
+        # to avoid conflicts.
+        if ssl_minimum_version is not None or ssl_maximum_version is not None:
+            raise ValueError(
+                "Can't specify both 'ssl_version' and either "
+                "'ssl_minimum_version' or 'ssl_maximum_version'"
+            )
+
+        # 'ssl_version' is deprecated and will be removed in the future.
+        else:
+            # Use 'ssl_minimum_version' and 'ssl_maximum_version' instead.
+            ssl_minimum_version = _SSL_VERSION_TO_TLS_VERSION.get(
+                ssl_version, TLSVersion.MINIMUM_SUPPORTED
+            )
+            ssl_maximum_version = _SSL_VERSION_TO_TLS_VERSION.get(
+                ssl_version, TLSVersion.MAXIMUM_SUPPORTED
+            )
+
+            # This warning message is pushing users to use 'ssl_minimum_version'
+            # instead of both min/max. Best practice is to only set the minimum version and
+            # keep the maximum version to be it's default value: 'TLSVersion.MAXIMUM_SUPPORTED'
+            warnings.warn(
+                "'ssl_version' option is deprecated and will be "
+                "removed in a future release of urllib3 2.x. Instead "
+                "use 'ssl_minimum_version'",
+                category=DeprecationWarning,
+                stacklevel=2,
+            )
+
+    # PROTOCOL_TLS is deprecated in Python 3.10 so we always use PROTOCOL_TLS_CLIENT
+    context = SSLContext(PROTOCOL_TLS_CLIENT)
+
+    if ssl_minimum_version is not None:
+        context.minimum_version = ssl_minimum_version
+    else:  # Python <3.10 defaults to 'MINIMUM_SUPPORTED' so explicitly set TLSv1.2 here
+        context.minimum_version = TLSVersion.TLSv1_2
 
-    context = SSLContext(ssl_version)
+    if ssl_maximum_version is not None:
+        context.maximum_version = ssl_maximum_version
 
     # Unless we're given ciphers defer to either system ciphers in
     # the case of OpenSSL 1.1.1+ or use our own secure default ciphers.


Test patch:
diff --git a/test/test_ssl.py b/test/test_ssl.py
index 92e9664c..f5dd1fd6 100644
--- a/test/test_ssl.py
+++ b/test/test_ssl.py
@@ -1,3 +1,4 @@
+import ssl
 from unittest import mock
 
 import pytest
@@ -173,6 +174,63 @@ class TestSSL:
         else:
             context.set_ciphers.assert_called_with(ssl_.DEFAULT_CIPHERS)
 
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            {
+                "ssl_version": ssl.PROTOCOL_TLSv1,
+                "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED,
+            },
+            {
+                "ssl_version": ssl.PROTOCOL_TLSv1,
+                "ssl_maximum_version": ssl.TLSVersion.TLSv1,
+            },
+            {
+                "ssl_version": ssl.PROTOCOL_TLSv1,
+                "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED,
+                "ssl_maximum_version": ssl.TLSVersion.MAXIMUM_SUPPORTED,
+            },
+        ],
+    )
+    def test_create_urllib3_context_ssl_version_and_ssl_min_max_version_errors(
+        self, kwargs
+    ):
+        with pytest.raises(ValueError) as e:
+            ssl_.create_urllib3_context(**kwargs)
+
+        assert str(e.value) == (
+            "Can't specify both 'ssl_version' and either 'ssl_minimum_version' or 'ssl_maximum_version'"
+        )
+
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            {
+                "ssl_version": ssl.PROTOCOL_TLS,
+                "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED,
+            },
+            {
+                "ssl_version": ssl.PROTOCOL_TLS_CLIENT,
+                "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED,
+            },
+            {
+                "ssl_version": None,
+                "ssl_minimum_version": ssl.TLSVersion.MINIMUM_SUPPORTED,
+            },
+            {"ssl_version": ssl.PROTOCOL_TLSv1, "ssl_minimum_version": None},
+            {"ssl_version": ssl.PROTOCOL_TLSv1, "ssl_maximum_version": None},
+            {
+                "ssl_version": ssl.PROTOCOL_TLSv1,
+                "ssl_minimum_version": None,
+                "ssl_maximum_version": None,
+            },
+        ],
+    )
+    def test_create_urllib3_context_ssl_version_and_ssl_min_max_version_no_error(
+        self, kwargs
+    ):
+        ssl_.create_urllib3_context(**kwargs)
+
     def test_assert_fingerprint_raises_exception_on_none_cert(self):
         with pytest.raises(SSLError):
             ssl_.assert_fingerprint(
diff --git a/test/with_dummyserver/test_https.py b/test/with_dummyserver/test_https.py
index 580c524f..b13f62ce 100644
--- a/test/with_dummyserver/test_https.py
+++ b/test/with_dummyserver/test_https.py
@@ -22,6 +22,7 @@ import pytest
 import trustme
 
 import urllib3.util as util
+import urllib3.util.ssl_
 from dummyserver.server import (
     DEFAULT_CA,
     DEFAULT_CA_KEY,
@@ -75,9 +76,27 @@ CLIENT_CERT = CLIENT_INTERMEDIATE_PEM
 class TestHTTPS(HTTPSDummyServerTestCase):
     tls_protocol_name = None
 
-    def tls_protocol_deprecated(self):
+    def tls_protocol_not_default(self):
         return self.tls_protocol_name in {"TLSv1", "TLSv1.1"}
 
+    def tls_version(self):
+        if self.tls_protocol_name is None:
+            return pytest.skip("Skipping base test class")
+        try:
+            from ssl import TLSVersion
+        except ImportError:
+            return pytest.skip("ssl.TLSVersion isn't available")
+        return getattr(TLSVersion, self.tls_protocol_name.replace(".", "_"))
+
+    def ssl_version(self):
+        if self.tls_protocol_name is None:
+            return pytest.skip("Skipping base test class")
+        attribute = f"PROTOCOL_{self.tls_protocol_name.replace('.', '_')}"
+        ssl_version = getattr(ssl, attribute, None)
+        if ssl_version is None:
+            return pytest.skip(f"ssl.{attribute} isn't available")
+        return ssl_version
+
     @classmethod
     def setup_class(cls):
         super().setup_class()
@@ -120,7 +139,10 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_simple(self):
         with HTTPSConnectionPool(
-            self.host, self.port, ca_certs=DEFAULT_CA
+            self.host,
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             r = https_pool.request("GET", "/")
             assert r.status == 200, r.data
@@ -128,7 +150,10 @@ class TestHTTPS(HTTPSDummyServerTestCase):
     @resolvesLocalhostFQDN()
     def test_dotted_fqdn(self):
         with HTTPSConnectionPool(
-            self.host + ".", self.port, ca_certs=DEFAULT_CA
+            self.host + ".",
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as pool:
             r = pool.request("GET", "/")
             assert r.status == 200, r.data
@@ -147,6 +172,7 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             key_file=os.path.join(self.certs_dir, CLIENT_INTERMEDIATE_KEY),
             cert_file=os.path.join(self.certs_dir, CLIENT_INTERMEDIATE_PEM),
             ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             r = https_pool.request("GET", "/certificate")
             subject = json.loads(r.data.decode("utf-8"))
@@ -164,6 +190,7 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             cert_file=os.path.join(self.certs_dir, CLIENT_NO_INTERMEDIATE_PEM),
             key_file=os.path.join(self.certs_dir, CLIENT_INTERMEDIATE_KEY),
             ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             with pytest.raises((SSLError, ProtocolError)):
                 https_pool.request("GET", "/certificate", retries=False)
@@ -177,6 +204,7 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             key_file=os.path.join(self.certs_dir, PASSWORD_CLIENT_KEYFILE),
             cert_file=os.path.join(self.certs_dir, CLIENT_CERT),
             key_password="letmein",
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             r = https_pool.request("GET", "/certificate")
             subject = json.loads(r.data.decode("utf-8"))
@@ -190,6 +218,7 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             key_file=os.path.join(self.certs_dir, PASSWORD_CLIENT_KEYFILE),
             cert_file=os.path.join(self.certs_dir, CLIENT_CERT),
             key_password=None,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             with pytest.raises(MaxRetryError, match="password is required") as e:
                 https_pool.request("GET", "/certificate")
@@ -198,7 +227,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_verified(self):
         with HTTPSConnectionPool(
-            self.host, self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            self.host,
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             conn = https_pool._new_conn()
             assert conn.__class__ == VerifiedHTTPSConnection
@@ -207,14 +240,12 @@ class TestHTTPS(HTTPSDummyServerTestCase):
                 r = https_pool.request("GET", "/")
                 assert r.status == 200
 
-            # If we're using a deprecated TLS version we can remove 'DeprecationWarning'
-            if self.tls_protocol_deprecated():
-                w = [x for x in w if x.category != DeprecationWarning]
-
             assert w == []
 
     def test_verified_with_context(self):
-        ctx = util.ssl_.create_urllib3_context(cert_reqs=ssl.CERT_REQUIRED)
+        ctx = util.ssl_.create_urllib3_context(
+            cert_reqs=ssl.CERT_REQUIRED, ssl_minimum_version=self.tls_version()
+        )
         ctx.load_verify_locations(cafile=DEFAULT_CA)
         with HTTPSConnectionPool(self.host, self.port, ssl_context=ctx) as https_pool:
             conn = https_pool._new_conn()
@@ -226,7 +257,9 @@ class TestHTTPS(HTTPSDummyServerTestCase):
                 assert not warn.called, warn.call_args_list
 
     def test_context_combines_with_ca_certs(self):
-        ctx = util.ssl_.create_urllib3_context(cert_reqs=ssl.CERT_REQUIRED)
+        ctx = util.ssl_.create_urllib3_context(
+            cert_reqs=ssl.CERT_REQUIRED, ssl_minimum_version=self.tls_version()
+        )
         with HTTPSConnectionPool(
             self.host, self.port, ca_certs=DEFAULT_CA, ssl_context=ctx
         ) as https_pool:
@@ -246,7 +279,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
         shutil.copyfile(DEFAULT_CA, str(tmpdir / "81deb5f7.0"))
 
         with HTTPSConnectionPool(
-            self.host, self.port, cert_reqs="CERT_REQUIRED", ca_cert_dir=str(tmpdir)
+            self.host,
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_cert_dir=str(tmpdir),
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             conn = https_pool._new_conn()
             assert conn.__class__ == VerifiedHTTPSConnection
@@ -255,15 +292,15 @@ class TestHTTPS(HTTPSDummyServerTestCase):
                 r = https_pool.request("GET", "/")
                 assert r.status == 200
 
-            # If we're using a deprecated TLS version we can remove 'DeprecationWarning'
-            if self.tls_protocol_deprecated():
-                w = [x for x in w if x.category != DeprecationWarning]
-
             assert w == []
 
     def test_invalid_common_name(self):
         with HTTPSConnectionPool(
-            "127.0.0.1", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            "127.0.0.1",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             with pytest.raises(MaxRetryError) as e:
                 https_pool.request("GET", "/", retries=0)
@@ -274,7 +311,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_verified_with_bad_ca_certs(self):
         with HTTPSConnectionPool(
-            self.host, self.port, cert_reqs="CERT_REQUIRED", ca_certs=self.bad_ca_path
+            self.host,
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=self.bad_ca_path,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             with pytest.raises(MaxRetryError) as e:
                 https_pool.request("GET", "/")
@@ -288,7 +329,10 @@ class TestHTTPS(HTTPSDummyServerTestCase):
     def test_verified_without_ca_certs(self):
         # default is cert_reqs=None which is ssl.CERT_NONE
         with HTTPSConnectionPool(
-            self.host, self.port, cert_reqs="CERT_REQUIRED"
+            self.host,
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             with pytest.raises(MaxRetryError) as e:
                 https_pool.request("GET", "/")
@@ -320,7 +364,12 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_unverified_ssl(self):
         """ Test that bare HTTPSConnection can connect, make requests """
-        with HTTPSConnectionPool(self.host, self.port, cert_reqs=ssl.CERT_NONE) as pool:
+        with HTTPSConnectionPool(
+            self.host,
+            self.port,
+            cert_reqs=ssl.CERT_NONE,
+            ssl_minimum_version=self.tls_version(),
+        ) as pool:
             with mock.patch("warnings.warn") as warn:
                 r = pool.request("GET", "/")
                 assert r.status == 200
@@ -334,7 +383,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_ssl_unverified_with_ca_certs(self):
         with HTTPSConnectionPool(
-            self.host, self.port, cert_reqs="CERT_NONE", ca_certs=self.bad_ca_path
+            self.host,
+            self.port,
+            cert_reqs="CERT_NONE",
+            ca_certs=self.bad_ca_path,
+            ssl_minimum_version=self.tls_version(),
         ) as pool:
             with mock.patch("warnings.warn") as warn:
                 r = pool.request("GET", "/")
@@ -346,23 +399,27 @@ class TestHTTPS(HTTPSDummyServerTestCase):
                 # warnings, which we want to ignore here.
                 calls = warn.call_args_list
 
-                # If we're using a deprecated TLS version we can remove 'DeprecationWarning'
-                if self.tls_protocol_deprecated():
-                    calls = [call for call in calls if call[0][1] != DeprecationWarning]
-
                 category = calls[0][0][1]
                 assert category == InsecureRequestWarning
 
     def test_assert_hostname_false(self):
         with HTTPSConnectionPool(
-            "localhost", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            "localhost",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.assert_hostname = False
             https_pool.request("GET", "/")
 
     def test_assert_specific_hostname(self):
         with HTTPSConnectionPool(
-            "localhost", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            "localhost",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.assert_hostname = "localhost"
             https_pool.request("GET", "/")
@@ -374,6 +431,7 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             cert_reqs="CERT_REQUIRED",
             ca_certs=DEFAULT_CA,
             server_hostname="localhost",
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             conn = https_pool._new_conn()
             conn.request("GET", "/")
@@ -387,7 +445,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_assert_fingerprint_md5(self):
         with HTTPSConnectionPool(
-            "localhost", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            "localhost",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.assert_fingerprint = (
                 "55:39:BF:70:05:12:43:FA:1F:D1:BF:4E:E8:1B:07:1D"
@@ -397,7 +459,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_assert_fingerprint_sha1(self):
         with HTTPSConnectionPool(
-            "localhost", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            "localhost",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.assert_fingerprint = (
                 "72:8B:55:4C:9A:FC:1E:88:A1:1C:AD:1B:B2:E7:CC:3E:DB:C8:F9:8A"
@@ -406,7 +472,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_assert_fingerprint_sha256(self):
         with HTTPSConnectionPool(
-            "localhost", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            "localhost",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.assert_fingerprint = (
                 "E3:59:8E:69:FF:C5:9F:C7:88:87:44:58:22:7F:90:8D:D9:BC:12:C4:90:79:D5:"
@@ -422,7 +492,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             return cm.value.reason
 
         with HTTPSConnectionPool(
-            self.host, self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            self.host,
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
 
             https_pool.assert_fingerprint = (
@@ -459,7 +533,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_verify_none_and_good_fingerprint(self):
         with HTTPSConnectionPool(
-            "127.0.0.1", self.port, cert_reqs="CERT_NONE", ca_certs=self.bad_ca_path
+            "127.0.0.1",
+            self.port,
+            cert_reqs="CERT_NONE",
+            ca_certs=self.bad_ca_path,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.assert_fingerprint = (
                 "72:8B:55:4C:9A:FC:1E:88:A1:1C:AD:1B:B2:E7:CC:3E:DB:C8:F9:8A"
@@ -473,7 +551,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
         # test doesn't do (deliberately). We should revisit this if we make
         # new decisions.
         with HTTPSConnectionPool(
-            "127.0.0.1", self.port, cert_reqs="CERT_REQUIRED", ca_certs=DEFAULT_CA
+            "127.0.0.1",
+            self.port,
+            cert_reqs="CERT_REQUIRED",
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.assert_fingerprint = (
                 "72:8B:55:4C:9A:FC:1E:88:A1:1C:AD:1B:B2:E7:CC:3E:DB:C8:F9:8A"
@@ -490,6 +572,7 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             timeout=timeout,
             retries=False,
             cert_reqs="CERT_REQUIRED",
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             with pytest.raises(ConnectTimeoutError):
                 https_pool.request("GET", "/")
@@ -501,6 +584,7 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             timeout=timeout,
             retries=False,
             cert_reqs="CERT_REQUIRED",
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.ca_certs = DEFAULT_CA
             https_pool.assert_fingerprint = (
@@ -509,7 +593,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
         timeout = Timeout(total=None)
         with HTTPSConnectionPool(
-            self.host, self.port, timeout=timeout, cert_reqs="CERT_NONE"
+            self.host,
+            self.port,
+            timeout=timeout,
+            cert_reqs="CERT_NONE",
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.request("GET", "/")
 
@@ -517,7 +605,11 @@ class TestHTTPS(HTTPSDummyServerTestCase):
         """ test the _tunnel behavior """
         timeout = Timeout(total=None)
         with HTTPSConnectionPool(
-            self.host, self.port, timeout=timeout, cert_reqs="CERT_NONE"
+            self.host,
+            self.port,
+            timeout=timeout,
+            cert_reqs="CERT_NONE",
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             conn = https_pool._new_conn()
             try:
@@ -581,13 +673,17 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             cert_reqs="CERT_REQUIRED",
             ca_certs=DEFAULT_CA,
             assert_fingerprint=fingerprint,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             r = https_pool.request("GET", "/")
             assert r.status == 200
 
     def test_ssl_correct_system_time(self):
         with HTTPSConnectionPool(
-            self.host, self.port, ca_certs=DEFAULT_CA
+            self.host,
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.cert_reqs = "CERT_REQUIRED"
             https_pool.ca_certs = DEFAULT_CA
@@ -597,7 +693,10 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
     def test_ssl_wrong_system_time(self):
         with HTTPSConnectionPool(
-            self.host, self.port, ca_certs=DEFAULT_CA
+            self.host,
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             https_pool.cert_reqs = "CERT_REQUIRED"
             https_pool.ca_certs = DEFAULT_CA
@@ -616,16 +715,15 @@ class TestHTTPS(HTTPSDummyServerTestCase):
         with warnings.catch_warnings(record=True) as w:
             warnings.simplefilter("always")
             with HTTPSConnectionPool(
-                self.host, self.port, ca_certs=DEFAULT_CA
+                self.host,
+                self.port,
+                ca_certs=DEFAULT_CA,
+                ssl_minimum_version=self.tls_version(),
             ) as https_pool:
                 https_pool.request(method, url)
 
         w = [x for x in w if not isinstance(x.message, ResourceWarning)]
 
-        # If we're using a deprecated TLS version we can remove 'DeprecationWarning'
-        if self.tls_protocol_deprecated():
-            w = [x for x in w if x.category != DeprecationWarning]
-
         return w
 
     def test_set_ssl_version_to_tls_version(self):
@@ -649,7 +747,10 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             pytest.skip("Skipping base test class")
 
         with HTTPSConnectionPool(
-            self.host, self.port, ca_certs=DEFAULT_CA
+            self.host,
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             conn = https_pool._get_conn()
             try:
@@ -660,37 +761,41 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             finally:
                 conn.close()
 
-    def test_default_tls_version_deprecations(self):
+    def test_ssl_version_is_deprecated(self):
         if self.tls_protocol_name is None:
             pytest.skip("Skipping base test class")
 
         with HTTPSConnectionPool(
-            self.host, self.port, ca_certs=DEFAULT_CA
+            self.host, self.port, ca_certs=DEFAULT_CA, ssl_version=self.ssl_version()
         ) as https_pool:
             conn = https_pool._get_conn()
             try:
                 with warnings.catch_warnings(record=True) as w:
                     conn.connect()
-                    if not hasattr(conn.sock, "version"):
-                        pytest.skip("SSLSocket.version() not available")
             finally:
                 conn.close()
 
-        if self.tls_protocol_deprecated():
-            assert len(w) == 1
-            assert str(w[0].message) == (
-                "Negotiating TLSv1/TLSv1.1 by default is deprecated "
-                "and will be disabled in urllib3 v2.0.0. Connecting to "
-                "'%s' with '%s' can be enabled by explicitly opting-in "
-                "with 'ssl_version'" % (self.host, self.tls_protocol_name)
+        assert len(w) >= 1
+        assert any(x.category == DeprecationWarning for x in w)
+        assert any(
+            str(x.message)
+            == (
+                "'ssl_version' option is deprecated and will be removed in "
+                "a future release of urllib3 2.x. Instead use 'ssl_minimum_version'"
             )
-        else:
-            assert w == []
+            for x in w
+        )
 
-    @pytest.mark.parametrize("ssl_version", [ssl.PROTOCOL_TLS, ssl.PROTOCOL_TLS_CLIENT])
-    def test_no_tls_version_deprecation_with_ssl_version(self, ssl_version):
+    @pytest.mark.parametrize(
+        "ssl_version", [None, ssl.PROTOCOL_TLS, ssl.PROTOCOL_TLS_CLIENT]
+    )
+    def test_ssl_version_with_protocol_tls_or_client_not_deprecated(self, ssl_version):
         if self.tls_protocol_name is None:
             pytest.skip("Skipping base test class")
+        if self.tls_protocol_not_default():
+            pytest.skip(
+                f"Skipping because '{self.tls_protocol_name}' isn't set by default"
+            )
 
         with HTTPSConnectionPool(
             self.host, self.port, ca_certs=DEFAULT_CA, ssl_version=ssl_version
@@ -708,11 +813,13 @@ class TestHTTPS(HTTPSDummyServerTestCase):
         if self.tls_protocol_name is None:
             pytest.skip("Skipping base test class")
 
+        ctx = util.ssl_.create_urllib3_context(ssl_minimum_version=self.tls_version())
+
         with HTTPSConnectionPool(
             self.host,
             self.port,
             ca_certs=DEFAULT_CA,
-            ssl_context=util.ssl_.create_urllib3_context(),
+            ssl_context=ctx,
         ) as https_pool:
             conn = https_pool._get_conn()
             try:
@@ -723,14 +830,46 @@ class TestHTTPS(HTTPSDummyServerTestCase):
 
         assert w == []
 
+    def test_tls_version_maximum_and_minimum(self):
+        if self.tls_protocol_name is None:
+            pytest.skip("Skipping base test class")
+
+        from ssl import TLSVersion
+
+        min_max_versions = [
+            (self.tls_version(), self.tls_version()),
+            (TLSVersion.MINIMUM_SUPPORTED, self.tls_version()),
+            (TLSVersion.MINIMUM_SUPPORTED, TLSVersion.MAXIMUM_SUPPORTED),
+        ]
+
+        for minimum_version, maximum_version in min_max_versions:
+            with HTTPSConnectionPool(
+                self.host,
+                self.port,
+                ca_certs=DEFAULT_CA,
+                ssl_minimum_version=minimum_version,
+                ssl_maximum_version=maximum_version,
+            ) as https_pool:
+                conn = https_pool._get_conn()
+                try:
+                    conn.connect()
+                    assert conn.sock.version() == self.tls_protocol_name
+                finally:
+                    conn.close()
+
     @pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python 3.8+")
     def test_sslkeylogfile(self, tmpdir, monkeypatch):
         if not hasattr(util.SSLContext, "keylog_filename"):
             pytest.skip("requires OpenSSL 1.1.1+")
+
         keylog_file = tmpdir.join("keylogfile.txt")
         monkeypatch.setenv("SSLKEYLOGFILE", str(keylog_file))
+
         with HTTPSConnectionPool(
-            self.host, self.port, ca_certs=DEFAULT_CA
+            self.host,
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
         ) as https_pool:
             r = https_pool.request("GET", "/")
             assert r.status == 200, r.data
@@ -751,7 +890,12 @@ class TestHTTPS(HTTPSDummyServerTestCase):
             monkeypatch.setenv("SSLKEYLOGFILE", sslkeylogfile)
         else:
             monkeypatch.delenv("SSLKEYLOGFILE", raising=False)
-        with HTTPSConnectionPool(self.host, self.port, ca_certs=DEFAULT_CA) as pool:
+        with HTTPSConnectionPool(
+            self.host,
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
+        ) as pool:
             r = pool.request("GET", "/")
             assert r.status == 200, r.data
 
@@ -759,11 +903,26 @@ class TestHTTPS(HTTPSDummyServerTestCase):
         """Default ALPN protocols are sent by default."""
         if not has_alpn() or not has_alpn(ssl.SSLContext):
             pytest.skip("ALPN-support not available")
-        with HTTPSConnectionPool(self.host, self.port, ca_certs=DEFAULT_CA) as pool:
+        with HTTPSConnectionPool(
+            self.host,
+            self.port,
+            ca_certs=DEFAULT_CA,
+            ssl_minimum_version=self.tls_version(),
+        ) as pool:
             r = pool.request("GET", "/alpn_protocol", retries=0)
             assert r.status == 200
             assert r.data.decode("utf-8") == util.ALPN_PROTOCOLS[0]
 
+    def test_default_ssl_context_ssl_min_max_versions(self):
+        ctx = urllib3.util.ssl_.create_urllib3_context()
+        assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2
+        assert ctx.maximum_version == ssl.TLSVersion.MAXIMUM_SUPPORTED
+
+    def test_ssl_context_ssl_version_uses_ssl_min_max_versions(self):
+        ctx = urllib3.util.ssl_.create_urllib3_context(ssl_version=self.ssl_version())
+        assert ctx.minimum_version == self.tls_version()
+        assert ctx.maximum_version == self.tls_version()
+
 
 @pytest.mark.usefixtures("requires_tlsv1")
 class TestHTTPS_TLSv1(TestHTTPS):