diff --git a/rbtools/api/tests/test_auth_handlers.py b/rbtools/api/tests/test_auth_handlers.py
new file mode 100644
index 0000000000000000000000000000000000000000..35b5299847203a634056c9fd0dc91b0f8513bc63
--- /dev/null
+++ b/rbtools/api/tests/test_auth_handlers.py
@@ -0,0 +1,439 @@
+"""Unit tests for HTTP auth handlers.
+
+Version Added:
+    5.3.1
+"""
+
+from __future__ import annotations
+
+import base64
+from http.client import HTTPMessage
+from urllib.request import build_opener
+
+import kgb
+
+from rbtools.api.request import (PresetHTTPAuthHandler,
+                                 Request,
+                                 ReviewBoardHTTPBasicAuthHandler,
+                                 ReviewBoardHTTPPasswordMgr,
+                                 ReviewBoardWebLoginHandler)
+from rbtools.commands.base import BaseCommand
+from rbtools.testing import CommandTestsMixin, TestCase
+from rbtools.utils.web_login import WebLoginNotAllowed
+
+
+class TestCommand(BaseCommand):
+    """Test command.
+
+    Version Added:
+        5.3.1
+    """
+
+    needs_api = True
+
+    option_list = [
+        BaseCommand.server_options,
+    ]
+
+    def main(self, *args) -> int:
+        """Run the command.
+
+        Args:
+            *args (tuple):
+                Positional arguments for the command.
+
+        Returns:
+            int:
+            The return code for the process.
+        """
+        return 0
+
+
+class PresetHTTPAuthHandlerTests(CommandTestsMixin[TestCommand], TestCase):
+    """Unit tests for PresetHTTPAuthHandler.
+
+    Version Added:
+        5.3.1
+    """
+
+    command_cls = TestCommand
+
+    def setUp(self) -> None:
+        """Set up the test case."""
+        super().setUp()
+
+        server_url = self.TEST_SERVER_URL
+        command = self.create_command()
+        self.command = command
+        password_manager = ReviewBoardHTTPPasswordMgr(
+            reviewboard_url=server_url,
+            auth_callback=command.credentials_prompt,
+            otp_token_callback=command.otp_token_prompt,
+            web_login_callback=command.web_login_callback)
+        handler = PresetHTTPAuthHandler(url=self.TEST_SERVER_URL,
+                                        password_mgr=password_manager)
+
+        self.password_manager = password_manager
+        self.handler = handler
+        req_before = Request(url=f'{self.TEST_SERVER_URL}api')
+
+        # timeout gets set in urllib when calling open() on the request.
+        req_before.timeout = None
+        self.req_before = req_before
+
+    def test_api_token(self) -> None:
+        """Test PresetHTTPAuthHandler with API token"""
+        handler = self.handler
+        password_manager = self.password_manager
+        password_manager.api_token = 'ABC123'
+
+        req_after = handler.http_request(self.req_before)
+
+        self.assertEqual(req_after.get_header('Authorization'), 'token ABC123')
+        self.assertTrue(handler.used)
+
+    def test_username_password(self) -> None:
+        """Test PresetHTTPAuthHandler with username and password"""
+        handler = self.handler
+        password_manager = self.password_manager
+        password_manager.rb_user = 'user'
+        password_manager.rb_pass = 'pass'
+
+        req_after = handler.http_request(self.req_before)
+
+        header = (b'Basic %s'
+                  % base64.b64encode('user:pass'.encode('utf-8')).strip())
+
+        self.assertEqual(req_after.get_header('Authorization'),
+                         header.decode())
+        self.assertTrue(handler.used)
+
+    def test_username(self) -> None:
+        """Test PresetHTTPAuthHandler with username"""
+        handler = self.handler
+        password_manager = self.password_manager
+        password_manager.rb_user = 'user'
+
+        self.spy_on(password_manager.find_user_password,
+                    op=kgb.SpyOpReturn(('user', 'pass')))
+
+        req_after = handler.http_request(self.req_before)
+
+        header = (b'Basic %s'
+                  % base64.b64encode('user:pass'.encode('utf-8')).strip())
+
+        self.assertEqual(req_after.get_header('Authorization'),
+                         header.decode())
+        self.assertTrue(handler.used)
+        self.assertSpyCalled(password_manager.find_user_password)
+
+    def test_used(self) -> None:
+        """Test PresetHTTPAuthHandler when already used"""
+        handler = self.handler
+        password_manager = self.password_manager
+        password_manager.api_token = 'ABC123'
+        handler.used = True
+
+        req_after = handler.http_request(self.req_before)
+
+        self.assertIsNone(req_after.get_header('Authorization'))
+
+
+class ReviewBoardWebLoginHandlerTests(CommandTestsMixin[TestCommand],
+                                      TestCase):
+    """Unit tests for ReviewBoardWebLoginHandler.
+
+    Version Added:
+        5.3.1
+    """
+
+    command_cls = TestCommand
+
+    def setUp(self) -> None:
+        """Set up the test case."""
+        super().setUp()
+
+        server_url = self.TEST_SERVER_URL
+        command = self.create_command()
+        password_manager = ReviewBoardHTTPPasswordMgr(
+            reviewboard_url=server_url,
+            auth_callback=command.credentials_prompt,
+            otp_token_callback=command.otp_token_prompt,
+            web_login_callback=command.web_login_callback)
+        handler = ReviewBoardWebLoginHandler(password_manager)
+        build_opener(handler)
+
+        self.command = command
+        self.password_manager = password_manager
+        self.handler = handler
+        req_before = Request(url=f'{self.TEST_SERVER_URL}api')
+
+        # timeout gets set in urllib when calling open() on the request.
+        req_before.timeout = None
+        self.req_before = req_before
+
+    def test_default(self) -> None:
+        """Test ReviewBoardWebLoginHandler with default state"""
+        command = self.command
+        handler = self.handler
+
+        self.spy_on(BaseCommand.web_login_callback,
+                    op=kgb.SpyOpReturn(True),
+                    owner=BaseCommand)
+        self.spy_on(handler.parent.open, call_original=False)
+
+        handler.http_error_401(
+            self.req_before,
+            fp=None,
+            code=401,
+            msg='Unauthorized',
+            headers=HTTPMessage())
+
+        self.assertSpyCalled(command.web_login_callback)
+        self.assertTrue(handler.used)
+        self.assertSpyCalled(handler.parent.open)
+
+    def test_used(self) -> None:
+        """Test ReviewBoardWebLoginHandler when its been used"""
+        command = self.command
+        handler = self.handler
+        handler.used = True
+
+        self.spy_on(BaseCommand.web_login_callback,
+                    owner=BaseCommand)
+        self.spy_on(handler.parent.open)
+
+        req_after = handler.http_error_401(
+            self.req_before,
+            fp=None,
+            code=401,
+            msg='Unauthorized',
+            headers=HTTPMessage())
+
+        self.assertSpyNotCalled(command.web_login_callback)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(req_after)
+
+    def test_not_authenticated(self) -> None:
+        """Test ReviewBoardWebLoginHandler with a not authenticated state
+        returned from the web login server
+        """
+        command = self.command
+        handler = self.handler
+
+        self.spy_on(BaseCommand.web_login_callback,
+                    op=kgb.SpyOpReturn(False),
+                    owner=BaseCommand)
+        self.spy_on(handler.parent.open)
+
+        req_after = handler.http_error_401(
+            self.req_before,
+            fp=None,
+            code=401,
+            msg='Unauthorized',
+            headers=HTTPMessage())
+
+        self.assertSpyCalled(command.web_login_callback)
+        self.assertTrue(handler.used)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(req_after)
+
+    def test_with_username(self) -> None:
+        """Test ReviewBoardWebLoginHandler with a username set on the
+        password manager
+        """
+        command = self.command
+        handler = self.handler
+        password_manager = self.password_manager
+        password_manager.rb_user = 'user'
+
+        self.spy_on(BaseCommand.web_login_callback,
+                    owner=BaseCommand)
+        self.spy_on(handler.parent.open)
+
+        req_after = handler.http_error_401(
+            self.req_before,
+            fp=None,
+            code=401,
+            msg='Unauthorized',
+            headers=HTTPMessage())
+
+        self.assertSpyNotCalled(command.web_login_callback)
+        self.assertTrue(handler.used)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(req_after)
+
+    def test_with_password(self) -> None:
+        """Test ReviewBoardWebLoginHandler with a password set on the
+        password manager
+        """
+        command = self.command
+        handler = self.handler
+        password_manager = self.password_manager
+        password_manager.rb_pass = 'password'
+
+        self.spy_on(BaseCommand.web_login_callback,
+                    owner=BaseCommand)
+        self.spy_on(handler.parent.open)
+
+        req_after = handler.http_error_401(
+            self.req_before,
+            fp=None,
+            code=401,
+            msg='Unauthorized',
+            headers=HTTPMessage())
+
+        self.assertSpyNotCalled(command.web_login_callback)
+        self.assertTrue(handler.used)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(req_after)
+
+    def test_with_api_token(self) -> None:
+        """Test ReviewBoardWebLoginHandler with an api token set on the
+        password manager
+        """
+        command = self.command
+        handler = self.handler
+        password_manager = self.password_manager
+        password_manager.api_token = 'ABC123'
+
+        self.spy_on(BaseCommand.web_login_callback,
+                    owner=BaseCommand)
+        self.spy_on(handler.parent.open)
+
+        req_after = handler.http_error_401(
+            self.req_before,
+            fp=None,
+            code=401,
+            msg='Unauthorized',
+            headers=HTTPMessage())
+
+        self.assertSpyNotCalled(command.web_login_callback)
+        self.assertTrue(handler.used)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(req_after)
+
+    def test_web_login_not_allowed(self) -> None:
+        """Test ReviewBoardWebLoginHandler with web login not allowed"""
+        command = self.command
+        handler = self.handler
+
+        self.spy_on(BaseCommand.web_login_callback,
+                    op=kgb.SpyOpRaise(WebLoginNotAllowed),
+                    owner=BaseCommand)
+        self.spy_on(handler.parent.open)
+
+        req_after = handler.http_error_401(
+            self.req_before,
+            fp=None,
+            code=401,
+            msg='Unauthorized',
+            headers=HTTPMessage())
+
+        self.assertSpyCalled(command.web_login_callback)
+        self.assertTrue(handler.used)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(req_after)
+
+
+class ReviewBoardHTTPBasicAuthHandlerTests(CommandTestsMixin[TestCommand],
+                                           TestCase):
+    """Unit tests for ReviewBoardHTTPBasicAuthHandler.
+
+    Version Added:
+        5.3.1
+    """
+
+    command_cls = TestCommand
+
+    def setUp(self) -> None:
+        super().setUp()
+
+        server_url = self.TEST_SERVER_URL
+        command = self.create_command()
+        password_manager = ReviewBoardHTTPPasswordMgr(
+            reviewboard_url=server_url,
+            auth_callback=command.credentials_prompt,
+            otp_token_callback=command.otp_token_prompt,
+            web_login_callback=command.web_login_callback)
+        handler = ReviewBoardHTTPBasicAuthHandler()
+        handler.passwd = password_manager
+        build_opener(handler)
+
+        self.command = command
+        self.password_manager = password_manager
+        self.handler = handler
+        req_before = Request(url=f'{self.TEST_SERVER_URL}api')
+
+        # timeout gets set in urllib when calling open() on the request.
+        req_before.timeout = None
+        self.req_before = req_before
+
+    def test_retry_with_username_password(self) -> None:
+        """retry_http_basic_auth with username and password"""
+        password_manager = self.password_manager
+        handler = self.handler
+        req_before = self.req_before
+
+        self.spy_on(handler.parent.open, call_original=False)
+        self.spy_on(password_manager.find_user_password,
+                    op=kgb.SpyOpReturn(('user', 'pass')))
+
+        self.assertIsNone(req_before.get_header('Authorization'))
+
+        self.handler.retry_http_basic_auth(
+            host='reviews.example.com',
+            request=req_before,
+            realm='Web API')
+
+        header = (b'Basic %s'
+                  % base64.b64encode('user:pass'.encode('utf-8')).strip())
+
+        self.assertEqual(req_before.get_header('Authorization'),
+                         header.decode())
+        self.assertSpyCalled(password_manager.find_user_password)
+
+    def test_retry_with_username(self) -> None:
+        """retry_http_basic_auth with only username"""
+        password_manager = self.password_manager
+        handler = self.handler
+        req_before = self.req_before
+
+        self.spy_on(handler.parent.open, call_original=False)
+        self.spy_on(password_manager.find_user_password,
+                    op=kgb.SpyOpReturn(('user', None)))
+
+        self.assertIsNone(req_before.get_header('Authorization'))
+
+        rsp = self.handler.retry_http_basic_auth(
+            host='reviews.example.com',
+            request=req_before,
+            realm='Web API')
+
+        self.assertIsNone(req_before.get_header('Authorization'))
+        self.assertSpyCalled(password_manager.find_user_password)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(rsp)
+
+    def test_retry_with_api_toke(self) -> None:
+        """retry_http_basic_auth with api token"""
+        password_manager = self.password_manager
+        handler = self.handler
+        req_before = self.req_before
+
+        password_manager.api_token = 'ABC123'
+
+        self.spy_on(handler.parent.open)
+        self.spy_on(password_manager.find_user_password)
+
+        rsp = self.handler.retry_http_basic_auth(
+            host='reviews.example.com',
+            request=self.req_before,
+            realm='Web API')
+
+        self.assertSpyNotCalled(password_manager.find_user_password)
+        self.assertSpyNotCalled(handler.parent.open)
+        self.assertIsNone(rsp)
+        self.assertIsNone(req_before.get_header('Authorization'))
+
+    # TODO: Add tests for otp token login.
diff --git a/rbtools/commands/tests/test_command_auth_session.py b/rbtools/commands/tests/test_command_auth_session.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb08618624f5dfbacb497e707b3d06e1fb6f9be3
--- /dev/null
+++ b/rbtools/commands/tests/test_command_auth_session.py
@@ -0,0 +1,290 @@
+"""Unit tests for session authentication in commands.
+
+Version Added:
+    5.3.1
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import kgb
+
+from rbtools.api.errors import AuthorizationError
+from rbtools.commands.base.commands import BaseCommand
+from rbtools.testing import CommandTestsMixin, TestCase
+from rbtools.testing.api.transport import URLMapTransport
+from rbtools.utils.users import credentials_prompt, get_authenticated_session
+from rbtools.utils.web_login import (WebLoginManager,
+                                     WebLoginNotAllowed,
+                                     attempt_web_login)
+
+if TYPE_CHECKING:
+    from typing import Any, Optional
+
+
+class HitAuthSessionCommand(BaseCommand):
+    """Command that gets an authenticated session.
+
+    This mimics the types of commands that call :py:func`~rbtools.utils.
+    users.get_authenticated_session` before making any API requests, which
+    will authenticate the command caller using the logic in that function
+    instead of going through our HTTP auth handlers.
+
+    Version Added:
+        5.1
+    """
+
+    needs_api = True
+
+    option_list = [
+        BaseCommand.server_options,
+    ]
+
+    def main(self, *args) -> int:
+        """Run the command.
+
+        Args:
+            *args (tuple):
+                Positional arguments for the command.
+
+        Returns:
+            int:
+            The return code for the process.
+        """
+        api_client = self.api_client
+        api_root = self.api_root
+        assert api_client is not None
+        assert api_root is not None
+
+        get_authenticated_session(
+            api_client=api_client,
+            api_root=api_root,
+            auth_required=True)
+
+        return 0
+
+
+class HitAuthSessionCommandTests(CommandTestsMixin[HitAuthSessionCommand],
+                                 TestCase):
+    """Tests for commands that get an authenticated session."""
+
+    command_cls = HitAuthSessionCommand
+
+    def test_default(self) -> None:
+        """Testing authenticating a command with the default settings"""
+        self.spy_on(attempt_web_login)
+        self.spy_on(credentials_prompt, op=kgb.SpyOpReturn(('user', 'pass')))
+
+        self._run_command()
+
+        self.assert_spy_last_raised(attempt_web_login, WebLoginNotAllowed)
+        self.assertSpyCalled(credentials_prompt)
+
+    def test_with_username(self) -> None:
+        """Testing authenticating a command with a username and password
+        passed
+        """
+        self.spy_on(URLMapTransport.handle_api_path)
+        self.spy_on(credentials_prompt)
+
+        self._run_command(
+            [
+                '--username',
+                'user',
+                '--password',
+                'pass',
+            ],
+            authenticated_session=True)
+
+        # In practice when passing a username to a command that calls
+        # get_authenticated_session(), we'll perform this API request which
+        # triggers our PresetHTTPAuthHandler. The handler prompts the user
+        # for a password if one isn't provided, then attaches the Basic Auth
+        # info to the request header.
+        #
+        # Since we're not using the SyncTransport that sets up auth handlers
+        # in our unit tests, its sufficient to just check that we're making
+        # this API request.
+        #
+        # If this test fails, we'll need to make sure that we're making
+        # some sort of authenticated request in get_authenticated_session()
+        # before we hit the web-login code.
+        self.assertSpyCalledWith(
+            URLMapTransport.handle_api_path.calls[1],
+            f'{self.DEFAULT_SERVER_URL}api/session/?expand=user',
+            'GET')
+
+        self.assertSpyNotCalled(credentials_prompt)
+
+    def test_with_api_token(self) -> None:
+        """Testing authenticating a command with an API token passed"""
+        self.spy_on(attempt_web_login)
+        self.spy_on(credentials_prompt)
+
+        self.spy_on(URLMapTransport.handle_api_path)
+
+        self._run_command(['--api-token', 'token'],
+                          authenticated_session=True)
+
+        # In practice when passing an api token to a command that calls
+        # get_authenticated_session(), we'll perform this API request which
+        # triggers our PresetHTTPAuthHandler. The handler then attaches the
+        # Basic Auth info to the request header.
+        #
+        # Since we're not using the SyncTransport that sets up auth handlers
+        # in our unit tests, its sufficient to just check that we're making
+        # this API request.
+        #
+        # If this test fails, we'll need to make sure that we're making
+        # some sort of authenticated request in get_authenticated_session()
+        # before we hit the web-login code.
+        self.assertSpyCalledWith(
+            URLMapTransport.handle_api_path.calls[1],
+            f'{self.DEFAULT_SERVER_URL}api/session/?expand=user',
+            'GET')
+
+        self.assertSpyNotCalled(credentials_prompt)
+
+    def test_with_username_and_web_login(self) -> None:
+        """Testing authenticating a command with a username and password
+        passed, and web login passed
+        """
+        self.spy_on(URLMapTransport.handle_api_path)
+        self.spy_on(attempt_web_login)
+
+        self._run_command(
+            [
+                '--username',
+                'user',
+                '--password',
+                'pass',
+                '--web-login',
+            ],
+            authenticated_session=True)
+
+        # In practice when passing a username to a command that calls
+        # get_authenticated_session(), we'll perform this API request which
+        # triggers our PresetHTTPAuthHandler. The handler prompts the user
+        # for a password if one isn't provided, then attaches the Basic Auth
+        # info to the request header.
+        #
+        # Since we're not using the SyncTransport that sets up auth handlers
+        # in our unit tests, its sufficient to just check that we're making
+        # this API request.
+        #
+        # If this test fails, we'll need to make sure that we're making
+        # some sort of authenticated request in get_authenticated_session()
+        # before we hit the web-login code.
+        self.assertSpyCalledWith(
+            URLMapTransport.handle_api_path.calls[1],
+            f'{self.DEFAULT_SERVER_URL}api/session/?expand=user',
+            'GET')
+
+        self.assertSpyNotCalled(attempt_web_login)
+
+    def test_with_web_login_option(self) -> None:
+        """Testing authenticating a command with the web login option passed"""
+        self.spy_on(WebLoginManager.wait_login_result,
+                    op=kgb.SpyOpReturn(True))
+        self.spy_on(credentials_prompt)
+
+        self._run_command(command_args=['--web-login'])
+
+        self.assertSpyCalled(WebLoginManager.wait_login_result)
+        self.assertSpyNotCalled(credentials_prompt)
+
+    def test_with_web_login_config(self) -> None:
+        """Testing authenticating a command with the web login set in
+        .reviewboardrc
+        """
+        self.spy_on(WebLoginManager.wait_login_result,
+                    op=kgb.SpyOpReturn(True))
+        self.spy_on(credentials_prompt)
+
+        config: dict[str, Any] = {
+            'WEB_LOGIN': True,
+        }
+
+        with self.reviewboardrc(config):
+            self._run_command()
+
+        self.assertSpyCalled(WebLoginManager.wait_login_result)
+        self.assertSpyNotCalled(credentials_prompt)
+
+    def test_with_web_login_not_enabled(self) -> None:
+        """Testing authenticating a command with the web login option passed
+        but web login not available on the server
+        """
+        self.spy_on(attempt_web_login)
+        self.spy_on(credentials_prompt, op=kgb.SpyOpReturn(('user', 'pass')))
+
+        self._run_command(command_args=['--web-login'],
+                          web_login_capability_enabled=False)
+
+        self.assert_spy_last_raised(attempt_web_login, WebLoginNotAllowed)
+
+        # Fall back on username and password.
+        self.assertSpyCalled(credentials_prompt)
+
+    def test_with_web_login_failed(self) -> None:
+        """Testing authenticating a command with the web login option passed
+        but web login failed
+        """
+        self.spy_on(WebLoginManager.wait_login_result,
+                    op=kgb.SpyOpReturn(False))
+        self.spy_on(credentials_prompt)
+
+        with self.assertRaises(AuthorizationError):
+            self._run_command(command_args=['--web-login'],)
+
+        self.assertSpyCalled(WebLoginManager.wait_login_result)
+        self.assertSpyNotCalled(credentials_prompt)
+
+    def _run_command(
+        self,
+        command_args: Optional[list[str]] = None,
+        web_login_capability_enabled: bool = True,
+        review_board_version: str = '5.3.0',
+        authenticated_session: bool = False,
+    ) -> None:
+        """Run the command.
+
+        This sets various things on the transport used in the tests.
+
+        Args:
+            command_args (list of str, optional):
+                Arguments to pass to the command.
+
+            web_login_capability_enabled (bool, optional):
+                Whether the web login capability should be enabled on
+                the server.
+
+            review_board_version (str, optional):
+                The Review Board version to set on the server.
+
+            authenticated_session (bool, optional):
+                Whether the session API URL should return an authenticated
+                session.
+
+        Raises:
+            AssertionError:
+                An expectation failed.
+        """
+        def setup_transport(transport: URLMapTransport) -> None:
+            transport.add_root_url(package_version=review_board_version)
+            transport.add_session_url(authenticated=authenticated_session)
+
+            transport.capabilities['authentication'] = {
+                'client_web_login': web_login_capability_enabled,
+            }
+
+        self.spy_on(WebLoginManager.start_web_login_server,
+                    call_original=False)
+
+        if not command_args:
+            command_args = []
+
+        self.run_command(
+            args=command_args + ['--debug'],
+            setup_transport_func=setup_transport)
