diff --git a/djblets/extensions/hooks.py b/djblets/extensions/hooks.py
index a24fef54c30256cccb59e694d8e57fa3f43ce757..36379e761eaa551e1329a593d49f042b60b5e149 100644
--- a/djblets/extensions/hooks.py
+++ b/djblets/extensions/hooks.py
@@ -25,6 +25,8 @@
 
 from __future__ import unicode_literals
 
+import uuid
+
 from django.core.urlresolvers import NoReverseMatch, reverse
 from django.template.loader import render_to_string
 
@@ -105,6 +107,30 @@ class URLHook(ExtensionHook):
 
 
 @six.add_metaclass(ExtensionHookPoint)
+class SignalHook(ExtensionHook):
+    """Connects to a Django signal.
+
+    This will handle connecting to a signal, calling the specified callback
+    when fired. It will disconnect from the signal when the extension is
+    disabled.
+    """
+    def __init__(self, extension, signal, callback, sender=None):
+        super(SignalHook, self).__init__(extension)
+
+        self.signal = signal
+        self.callback = callback
+        self.dispatch_uid = uuid.uuid1()
+
+        signal.connect(callback, sender=sender, weak=False,
+                       dispatch_uid=self.dispatch_uid)
+
+    def shutdown(self):
+        super(SignalHook, self).shutdown()
+
+        self.signal.disconnect(dispatch_uid=self.dispatch_uid)
+
+
+@six.add_metaclass(ExtensionHookPoint)
 class TemplateHook(ExtensionHook):
     """Custom templates hook.
 
diff --git a/djblets/extensions/tests.py b/djblets/extensions/tests.py
index 9ee1f62f5a1728677173d34409c83680ad092fab..7a4af8f0841da71f6b6170c0725e162114c203d6 100644
--- a/djblets/extensions/tests.py
+++ b/djblets/extensions/tests.py
@@ -30,11 +30,13 @@ import os
 from django.conf import settings
 from django.conf.urls import include, patterns
 from django.core.exceptions import ImproperlyConfigured
+from django.dispatch import Signal
+from kgb import SpyAgency
 from mock import Mock
 
 from djblets.extensions.extension import Extension, ExtensionInfo
 from djblets.extensions.hooks import (ExtensionHook, ExtensionHookPoint,
-                                      TemplateHook, URLHook)
+                                      SignalHook, TemplateHook, URLHook)
 from djblets.extensions.manager import (_extension_managers, ExtensionManager,
                                         SettingListWrapper)
 from djblets.extensions.settings import Settings
@@ -612,6 +614,40 @@ class SettingListWrapperTests(TestCase):
         self.assertEqual(wrapper.ref_counts.get('item1'), 1)
 
 
+class SignalHookTest(SpyAgency, TestCase):
+    """Unit tests for djblets.extensions.hooks.SignalHook."""
+    def setUp(self):
+        manager = ExtensionManager('')
+        self.test_extension = \
+            TestExtensionWithRegistration(extension_manager=manager)
+        self.patterns = patterns('',
+            (r'^url_hook_test/', include('djblets.extensions.test.urls')))
+
+        self.signal = Signal()
+        self.spy_on(self._on_signal_fired)
+
+    def test_initialize(self):
+        """Testing SignalHook initialization connects to signal"""
+        SignalHook(self.test_extension, self.signal, self._on_signal_fired)
+
+        self.assertEqual(len(self._on_signal_fired.calls), 0)
+        self.signal.send(self)
+        self.assertEqual(len(self._on_signal_fired.calls), 1)
+
+    def test_shutdown(self):
+        """Testing SignalHook.shutdown disconnects from signal"""
+        hook = SignalHook(self.test_extension, self.signal,
+                          self._on_signal_fired)
+        hook.shutdown()
+
+        self.assertEqual(len(self._on_signal_fired.calls), 0)
+        self.signal.send(self)
+        self.assertEqual(len(self._on_signal_fired.calls), 0)
+
+    def _on_signal_fired(self, *args, **kwargs):
+        pass
+
+
 class URLHookTest(TestCase):
     def setUp(self):
         manager = ExtensionManager('')
