diff --git a/reviewboard/accounts/errors.py b/reviewboard/accounts/errors.py
index 747390abf047938ee69b9f8cc21962bb550316d6..6cc8134b057b62e12f85e01fd8c2efe2ab7aff39 100644
--- a/reviewboard/accounts/errors.py
+++ b/reviewboard/accounts/errors.py
@@ -1,3 +1,21 @@
+"""Exception classes for accounts."""
+
+from __future__ import annotations
+
+from django.utils.translation import gettext as _
+
+
+class LoginNotAllowedError(Exception):
+    """An error when a user login is not allowed.
+
+    This error is used when a user cannot log in, for example, if the user is
+    marked inactive.
+
+    Version Added:
+        6.0.3
+    """
+
+
 class UserQueryError(Exception):
     """An error for when a user query fails during user population.
 
@@ -7,13 +25,16 @@ class UserQueryError(Exception):
     reported back to the caller.
     """
 
-    def __init__(self, msg):
+    def __init__(
+        self,
+        msg: str,
+    ) -> None:
         """Initialize the error.
 
         Args:
-            msg (unicode):
+            msg (str):
                 The error message to display.
         """
-        super(Exception, self).__init__(
-            'Error while populating users from the auth backend: %s'
-            % msg)
+        super().__init__(
+            _('Error while populating users from the auth backend: {}')
+            .format(msg))
diff --git a/reviewboard/accounts/sso/backends/base.py b/reviewboard/accounts/sso/backends/base.py
index aa057156556b4f997fe0913803c5fefdc4394e52..8d14e16c02d0bacb3c053c4086f231b951757e4d 100644
--- a/reviewboard/accounts/sso/backends/base.py
+++ b/reviewboard/accounts/sso/backends/base.py
@@ -4,10 +4,19 @@
     5.0
 """
 
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
 from django.contrib import auth
 from djblets.siteconfig.models import SiteConfiguration
 
 from reviewboard.accounts.backends import StandardAuthBackend
+from reviewboard.accounts.errors import LoginNotAllowedError
+
+if TYPE_CHECKING:
+    from django.contrib.auth.models import User
+    from django.http import HttpRequest
 
 
 class BaseSSOBackend(object):
@@ -95,7 +104,11 @@ def is_enabled(self):
         siteconfig = SiteConfiguration.objects.get_current()
         return siteconfig.get('%s_enabled' % self.backend_id, False)
 
-    def login_user(self, request, user):
+    def login_user(
+        self,
+        request: HttpRequest,
+        user: User,
+    ) -> None:
         """Log in the given user.
 
         Args:
@@ -104,7 +117,14 @@ def login_user(self, request, user):
 
             user (django.contrib.auth.models.User):
                 The user to log in.
+
+        Raises:
+            reviewboard.accounts.errors.LoginNotAllowedError:
+                The user is not allowed to log in.
         """
+        if not user.is_active:
+            raise LoginNotAllowedError()
+
         user.backend = '%s.%s' % (StandardAuthBackend.__module__,
                                   StandardAuthBackend.__name__)
         auth.login(request, user)
diff --git a/reviewboard/accounts/sso/backends/saml/forms.py b/reviewboard/accounts/sso/backends/saml/forms.py
index d4672e947df05b69842aec5db0f8fb2c03bb92b3..97db73418284446f1941db9d7b09e198e4abaee8 100644
--- a/reviewboard/accounts/sso/backends/saml/forms.py
+++ b/reviewboard/accounts/sso/backends/saml/forms.py
@@ -4,10 +4,15 @@
     5.0
 """
 
+from __future__ import annotations
+
+from typing import Any
+
 from cryptography import x509
 from cryptography.hazmat.backends import default_backend
 from django import forms
 from django.contrib.auth.forms import AuthenticationForm
+from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.core.validators import URLValidator
 from django.utils.translation import gettext_lazy as _
@@ -31,11 +36,11 @@ class SAMLLinkUserForm(AuthenticationForm):
         5.0
     """
 
-    provision = forms.BooleanField(
+    provision: forms.BooleanField = forms.BooleanField(
         widget=forms.HiddenInput(),
         required=False)
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, **kwargs) -> None:
         """Initialize the form.
 
         Args:
@@ -53,7 +58,7 @@ def __init__(self, *args, **kwargs):
             self.fields['username'].required = False
             self.fields['password'].required = False
 
-    def clean(self):
+    def clean(self) -> dict[str, Any]:
         """Run validation on the form.
 
         Returns:
@@ -65,7 +70,13 @@ def clean(self):
             # authenticating the login/password.
             return self.cleaned_data
         else:
-            return super(SAMLLinkUserForm, self).clean()
+            username = self.cleaned_data.get('username')
+
+            if username:
+                user = User.objects.get(username=username)
+                self.confirm_login_allowed(user)
+
+            return super().clean()
 
 
 def validate_x509(value):
diff --git a/reviewboard/accounts/sso/backends/saml/sso_backend.py b/reviewboard/accounts/sso/backends/saml/sso_backend.py
index 056afab2de4b33f8acd812961d03b6358de5a51b..5d03c643b8f8a69ac01ef36caf1c4978e8161655 100644
--- a/reviewboard/accounts/sso/backends/saml/sso_backend.py
+++ b/reviewboard/accounts/sso/backends/saml/sso_backend.py
@@ -56,7 +56,7 @@ class SAMLSSOBackend(BaseSSOBackend):
         'saml_slo_url': '',
         'saml_sso_binding_type': SAMLBinding.HTTP_POST,
         'saml_sso_url': '',
-        'saml_verfication_cert': '',
+        'saml_verification_cert': '',
     }
     login_view_cls = SAMLLoginView
 
diff --git a/reviewboard/accounts/sso/backends/saml/views.py b/reviewboard/accounts/sso/backends/saml/views.py
index cb058cc9aa91af44373baa8703cb674badd2b19e..20b7432f9311bceb032d5d8b0e40cd514c6278b0 100644
--- a/reviewboard/accounts/sso/backends/saml/views.py
+++ b/reviewboard/accounts/sso/backends/saml/views.py
@@ -4,9 +4,12 @@
     5.0
 """
 
+from __future__ import annotations
+
+import logging
 from enum import Enum
+from typing import TYPE_CHECKING
 from urllib.parse import urlparse
-import logging
 
 from django.conf import settings
 from django.contrib.auth import REDIRECT_FIELD_NAME
@@ -18,6 +21,7 @@
                          HttpResponseBadRequest,
                          HttpResponseRedirect,
                          HttpResponseServerError)
+from django.shortcuts import render
 from django.utils.decorators import method_decorator
 from django.views.decorators.csrf import csrf_protect
 from django.views.generic.base import View
@@ -36,6 +40,7 @@
     OneLogin_Saml2_Settings = None
     OneLogin_Saml2_Utils = None
 
+from reviewboard.accounts.errors import LoginNotAllowedError
 from reviewboard.accounts.models import LinkedAccount
 from reviewboard.accounts.sso.backends.saml.forms import SAMLLinkUserForm
 from reviewboard.accounts.sso.backends.saml.settings import get_saml2_settings
@@ -45,6 +50,9 @@
 from reviewboard.admin.server import get_server_url
 from reviewboard.site.urlresolvers import local_site_reverse
 
+if TYPE_CHECKING:
+    from django.http import HttpRequest
+
 
 logger = logging.getLogger(__name__)
 
@@ -226,7 +234,12 @@ def link_user_url(self):
             request=self.request,
             kwargs={'backend_id': self.sso_backend.backend_id})
 
-    def post(self, request, *args, **kwargs):
+    def post(
+        self,
+        request: HttpRequest,
+        *args,
+        **kwargs,
+    ) -> HttpResponse:
         """Handle a POST request.
 
         Args:
@@ -279,7 +292,14 @@ def post(self, request, *args, **kwargs):
 
         if linked_account:
             user = linked_account.user
-            self.sso_backend.login_user(request, user)
+
+            try:
+                self.sso_backend.login_user(request, user)
+            except LoginNotAllowedError:
+                return render(request=request,
+                              template_name='permission_denied.html',
+                              status=403)
+
             return HttpResponseRedirect(self.success_url)
         else:
             username = auth.get_nameid()
@@ -533,8 +553,11 @@ def get(self, request, *args, **kwargs):
 
         return super(SAMLLinkUserView, self).get(request, *args, **kwargs)
 
-    def form_valid(self, form):
-        """Handler for when the form has successfully authenticated.
+    def form_valid(
+        self,
+        form: SAMLLinkUserForm,
+    ) -> HttpResponse:
+        """Handle a successfully authenticated form.
 
         Args:
             form (reviewboard.accounts.sso.backends.saml.forms.
@@ -568,16 +591,19 @@ def form_valid(self, form):
 
         return self.link_user(user)
 
-    def link_user(self, user):
+    def link_user(
+        self,
+        user: User,
+    ) -> HttpResponse:
         """Link the given user.
 
         Args:
             user (django.contrib.auth.models.User):
                 The user to link.
 
         Returns:
-            django.http.HttpResponseRedirect:
-            A redirect to the success URL.
+            django.http.HttpResponse:
+            A redirect to the success URL, or an error page.
         """
         sso_id = self._sso_user_data.get('id')
 
@@ -587,7 +613,9 @@ def link_user(self, user):
         user.linked_accounts.create(
             service_id='sso:saml',
             service_user_id=sso_id)
+
         self.sso_backend.login_user(self.request, user)
+
         return HttpResponseRedirect(self.get_success_url())
 
 
diff --git a/reviewboard/accounts/tests/test_saml_forms.py b/reviewboard/accounts/tests/test_saml_forms.py
index 885da34662dcc90a80b878efec4079ac17956148..abd5cb7ce570db75417471d6bf5a6526f7f62a21 100644
--- a/reviewboard/accounts/tests/test_saml_forms.py
+++ b/reviewboard/accounts/tests/test_saml_forms.py
@@ -1,5 +1,10 @@
 """Unit tests for SAML forms."""
 
+from __future__ import annotations
+
+from typing import ClassVar
+
+from django.contrib.auth.models import User
 from djblets.siteconfig.models import SiteConfiguration
 
 from reviewboard.accounts.sso.backends import sso_backends
@@ -33,9 +38,9 @@
 class SAMLLinkUserFormTests(TestCase):
     """Unit tests for SAMLLinkUserForm."""
 
-    fixtures = ['test_users']
+    fixtures: ClassVar[list[str]] = ['test_users']
 
-    def test_valid_login(self):
+    def test_valid_login(self) -> None:
         """Testing SAMLLinkUserForm validation in login mode"""
         form = SAMLLinkUserForm(data={
             'username': 'doc',
@@ -45,9 +50,9 @@ def test_valid_login(self):
 
         self.assertTrue(form.is_valid())
 
-    def test_invalid_login(self):
+    def test_invalid_login(self) -> None:
         """Testing SAMLLinkUserForm validation with incorrect password in login
-        mode.
+        mode
         """
         form = SAMLLinkUserForm(data={
             'username': 'doc',
@@ -60,7 +65,23 @@ def test_invalid_login(self):
                          ['Please enter a correct username and password. Note '
                           'that both fields may be case-sensitive.'])
 
-    def test_provision(self):
+    def test_inactive_user(self) -> None:
+        """Testing SAMLLinkUserForm validation with an inactive user"""
+        user = User.objects.get(username='doc')
+        user.is_active = False
+        user.save(update_fields=['is_active'])
+
+        form = SAMLLinkUserForm(data={
+            'username': 'doc',
+            'password': 'nope',
+            'provision': False,
+        })
+
+        self.assertFalse(form.is_valid())
+        self.assertEqual(form.errors['__all__'],
+                         ['This account is inactive.'])
+
+    def test_provision(self) -> None:
         """Testing SAMLLinkUserForm validation in provision mode"""
         form = SAMLLinkUserForm(data={
             'username': 'doc',
diff --git a/reviewboard/accounts/tests/test_saml_views.py b/reviewboard/accounts/tests/test_saml_views.py
index 90b8f64600fcb384a37627e04e702a3cdc8867f1..96c5b830a8ad976cde70c8b3eedaa4a380a3c42d 100644
--- a/reviewboard/accounts/tests/test_saml_views.py
+++ b/reviewboard/accounts/tests/test_saml_views.py
@@ -1,16 +1,24 @@
 """Unit tests for SAML views."""
 
+from __future__ import annotations
+
+from typing import ClassVar, TYPE_CHECKING
 from xml.etree import ElementTree
 
 import kgb
 from django.contrib.auth.models import User
+from django.core.cache import cache
 from django.urls import reverse
+from djblets.cache.backend import make_cache_key
 
 from reviewboard.accounts.sso.backends.saml.views import (SAMLACSView,
                                                           SAMLLinkUserView,
                                                           SAMLSLSView)
 from reviewboard.testing import TestCase
 
+if TYPE_CHECKING:
+    from djblets.util.typing import JSONDict
+
 
 VALID_CERT = """-----BEGIN CERTIFICATE-----
 MIICZjCCAc+gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBQMQswCQYDVQQGEwJ1czEL
@@ -29,14 +37,12 @@
 -----END CERTIFICATE-----"""
 
 
-class SAMLViewTests(kgb.SpyAgency, TestCase):
-    """Unit tests for SAML views."""
-
-    fixtures = ['test_users']
+class SAMLMetadataViewTests(TestCase):
+    """Unit tests for SAMLMetadataView."""
 
-    def test_metadata_view(self):
+    def test_metadata_view(self) -> None:
         """Testing SAMLMetadataView"""
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_verification_cert': VALID_CERT,
         }
@@ -74,9 +80,15 @@ def test_metadata_view(self):
                 acs.get('Location'),
                 'http://example.com/account/sso/saml/acs/')
 
-    def test_get_link_user_existing_account(self):
+
+class SAMLLinkUserViewTests(kgb.SpyAgency, TestCase):
+    """Unit tests for SAMLLinkUserView."""
+
+    fixtures: ClassVar[list[str]] = ['test_users']
+
+    def test_get_link_user_existing_account(self) -> None:
         """Testing SAMLLinkUserView form render with existing account"""
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_require_login_to_link': True,
         }
@@ -103,11 +115,11 @@ def test_get_link_user_existing_account(self):
             self.assertEqual(context['mode'],
                              SAMLLinkUserView.Mode.CONNECT_EXISTING_ACCOUNT)
 
-    def test_get_link_user_existing_account_email_match(self):
+    def test_get_link_user_existing_account_email_match(self) -> None:
         """Testing SAMLLinkUserView form render with existing account matching
         email address
         """
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_require_login_to_link': True,
         }
@@ -134,11 +146,11 @@ def test_get_link_user_existing_account_email_match(self):
             self.assertEqual(context['mode'],
                              SAMLLinkUserView.Mode.CONNECT_EXISTING_ACCOUNT)
 
-    def test_get_link_user_existing_account_email_username_match(self):
+    def test_get_link_user_existing_account_email_username_match(self) -> None:
         """Testing SAMLLinkUserView form render with existing account matching
         username from email address
         """
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_require_login_to_link': True,
         }
@@ -165,9 +177,9 @@ def test_get_link_user_existing_account_email_username_match(self):
             self.assertEqual(context['mode'],
                              SAMLLinkUserView.Mode.CONNECT_EXISTING_ACCOUNT)
 
-    def test_get_link_user_no_match(self):
+    def test_get_link_user_no_match(self) -> None:
         """Testing SAMLLinkUserView form render with no match"""
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_require_login_to_link': True,
         }
@@ -194,9 +206,9 @@ def test_get_link_user_no_match(self):
             self.assertEqual(context['mode'],
                              SAMLLinkUserView.Mode.PROVISION)
 
-    def test_post_link_user_login(self):
+    def test_post_link_user_login(self) -> None:
         """Testing SAMLLinkUserView form POST with login"""
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_require_login_to_link': True,
         }
@@ -232,9 +244,9 @@ def test_post_link_user_login(self):
             self.assertEqual(linked_account.service_id, 'sso:saml')
             self.assertEqual(linked_account.service_user_id, 'doc2')
 
-    def test_post_link_user_provision(self):
+    def test_post_link_user_provision(self) -> None:
         """Testing SAMLLinkUserView form POST with provision"""
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_require_login_to_link': True,
         }
@@ -274,9 +286,51 @@ def test_post_link_user_provision(self):
             self.assertEqual(linked_account.service_id, 'sso:saml')
             self.assertEqual(linked_account.service_user_id, 'sleepy')
 
-    def test_post_link_user_invalid_mode(self):
+    def test_post_link_user_inactive(self) -> None:
+        """Testing SAMLLinkUserView form POST with an inactive user"""
+        settings: JSONDict = {
+            'saml_enabled': True,
+            'saml_require_login_to_link': True,
+        }
+
+        with self.siteconfig_settings(settings):
+            session = self.client.session
+            session['sso'] = {
+                'user_data': {
+                    'id': 'doc2',
+                    'first_name': 'Doc',
+                    'last_name': 'Dwarf',
+                    'email': 'doc@example.com',
+                },
+            }
+            session.save()
+
+            user = User.objects.get(username='doc')
+            user.is_active = False
+            user.save(update_fields=['is_active'])
+            self.assertFalse(user.linked_accounts.exists())
+
+            url = reverse('sso:saml:link-user', kwargs={'backend_id': 'saml'})
+            rsp = self.client.post(url, {
+                'username': 'doc',
+                'password': 'doc',
+                'provision': False,
+            })
+
+            self.assertEqual(rsp.status_code, 200)
+
+            form = rsp.context['form']
+            self.assertFalse(form.is_valid())
+            self.assertEqual(form.errors['__all__'],
+                             ['This account is inactive.'])
+
+            linked_accounts = list(user.linked_accounts.all())
+
+            self.assertEqual(len(linked_accounts), 0)
+
+    def test_post_link_user_invalid_mode(self) -> None:
         """Testing SAMLLinkUserView form POST with invalid mode"""
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
             'saml_require_login_to_link': True,
         }
@@ -317,8 +371,14 @@ def test_post_link_user_invalid_mode(self):
             self.assertEqual(linked_account.service_id, 'sso:saml')
             self.assertEqual(linked_account.service_user_id, 'sleepy')
 
-    def test_post_assertion_replay_countermeasures(self):
-        """Testing SAMLACSView POST replay attack countermeasures"""
+
+class SAMLACSViewTests(kgb.SpyAgency, TestCase):
+    """Unit tests for SAMLACSView."""
+
+    fixtures: ClassVar[list[str]] = ['test_users']
+
+    def setUp(self) -> None:
+        """Set up the test case."""
         class FakeAuth:
             def process_response(*args, **kwargs):
                 pass
@@ -350,7 +410,72 @@ def get_session_index(self):
                     op=kgb.SpyOpReturn(fake_auth),
                     owner=SAMLACSView)
 
-        settings = {
+        super().setUp()
+
+    def tearDown(self) -> None:
+        """Tear down the test case."""
+        cache.delete(make_cache_key('saml_replay_id_message-id'))
+
+        super().tearDown()
+
+    def test_post_assertion_login(self) -> None:
+        """Testing SAMLACSView POST log in to existing linked account"""
+        settings: JSONDict = {
+            'saml_enabled': True,
+        }
+
+        user = User.objects.get(username='doc')
+        user.linked_accounts.create(
+            service_id='sso:saml',
+            service_user_id='username')
+
+        self.assertNotIn('_auth_user_id', self.client.session)
+
+        with self.siteconfig_settings(settings):
+            url = reverse('sso:saml:acs', kwargs={'backend_id': 'saml'})
+
+            rsp = self.client.post(
+                path=url,
+                data={},
+                HTTP_HOST='localhost')
+            self.assertEqual(rsp.status_code, 302)
+
+            # Make sure we've logged in as the expected user.
+            self.assertEqual(int(self.client.session['_auth_user_id']),
+                             user.pk)
+
+    def test_post_assertion_login_with_inactive_user(self) -> None:
+        """Testing SAMLACSView POST log in to existing linked account with an
+        inactive user
+        """
+        settings: JSONDict = {
+            'saml_enabled': True,
+        }
+
+        user = User.objects.get(username='doc')
+        user.is_active = False
+        user.save(update_fields=['is_active'])
+        user.linked_accounts.create(
+            service_id='sso:saml',
+            service_user_id='username')
+
+        self.assertNotIn('_auth_user_id', self.client.session)
+
+        with self.siteconfig_settings(settings):
+            url = reverse('sso:saml:acs', kwargs={'backend_id': 'saml'})
+
+            rsp = self.client.post(
+                path=url,
+                data={},
+                HTTP_HOST='localhost')
+            self.assertEqual(rsp.status_code, 403)
+
+            # Make sure we're not logged in.
+            self.assertNotIn('_auth_user_id', self.client.session)
+
+    def test_post_assertion_replay_countermeasures(self) -> None:
+        """Testing SAMLACSView POST replay attack countermeasures"""
+        settings: JSONDict = {
             'saml_enabled': True,
         }
 
@@ -367,7 +492,11 @@ def get_session_index(self):
             self.assertEqual(rsp.content,
                              b'SAML message IDs have already been used')
 
-    def test_get_sls_replay_countermeasures(self):
+
+class SAMLSLSViewTests(kgb.SpyAgency, TestCase):
+    """Unit tests for SAMLSLSView."""
+
+    def test_get_sls_replay_countermeasures(self) -> None:
         """Testing SAMLSLSView GET replay attack countermeasures"""
         class FakeAuth:
             def process_slo(*args, **kwargs):
@@ -388,7 +517,7 @@ def get_last_error_reason(self):
                     op=kgb.SpyOpReturn(fake_auth),
                     owner=SAMLSLSView)
 
-        settings = {
+        settings: JSONDict = {
             'saml_enabled': True,
         }
 
