diff --git a/djblets/recaptcha/__init__.py b/djblets/recaptcha/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..867fa5ce93df88a87d647c474fdf2f8907e355b5
--- /dev/null
+++ b/djblets/recaptcha/__init__.py
@@ -0,0 +1,4 @@
+from __future__ import unicode_literals
+
+
+default_app_config = 'djblets.recaptcha.apps.RecaptchaAppConfig'
diff --git a/djblets/recaptcha/apps.py b/djblets/recaptcha/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc8efc6a7309071afab3433d9b204f0f70fdd561
--- /dev/null
+++ b/djblets/recaptcha/apps.py
@@ -0,0 +1,12 @@
+from __future__ import unicode_literals
+
+try:
+    from django.apps import AppConfig
+except ImportError:
+    # Django < 1.7
+    AppConfig = object
+
+
+class RecaptchaAppConfig(AppConfig):
+    name = 'djblets.recaptcha'
+    label = 'djblets_recaptcha'
diff --git a/djblets/recaptcha/mixins.py b/djblets/recaptcha/mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb727870dede7131ecd1e25ef689091c765c8920
--- /dev/null
+++ b/djblets/recaptcha/mixins.py
@@ -0,0 +1,104 @@
+"""Mixins for providing reCAPTCHA validation support in forms.
+
+See :ref:`using-recaptcha` for a guide on using reCAPTCHA validation.
+"""
+
+from __future__ import unicode_literals
+
+import json
+import logging
+
+from django import forms
+from django.conf import settings
+from django.core.exceptions import ValidationError
+from django.utils.six.moves.urllib.error import URLError
+from django.utils.six.moves.urllib.parse import urlencode
+from django.utils.six.moves.urllib.request import urlopen
+from django.utils.translation import ugettext as _
+
+from djblets.recaptcha.widgets import RecaptchaWidget
+
+
+class RecaptchaFormMixin(forms.Form):
+    """A form mixin for providing reCAPTCHA verification.
+
+    If other mixins are used, this should be the first in the list of base
+    classes to ensure the reCAPTCHA field is the last.
+    """
+
+    def __init__(self, request, *args, **kwargs):
+        """Initialize the mixin.
+
+        Args:
+            request (django.http.HttpRequest):
+                The current HTTP request.
+
+            *args (tuple):
+                Additional positional arguments to pass to the superclass
+                constructor.
+
+            **kwargs (dict):
+                Additional keyword arguments to pass to the superclass
+                constructor.
+        """
+        super(RecaptchaFormMixin, self).__init__(*args, **kwargs)
+        self.fields['g-recaptcha-response'] = forms.CharField(
+            required=True,
+            widget=RecaptchaWidget)
+
+    @property
+    def verify_recaptcha(self):
+        """Whether or not the reCAPTCHA is to be verified.
+
+        Returns:
+            bool: Whether or not the reCAPTCHA is to be verified.
+        """
+        return True
+
+    def clean(self):
+        if self.verify_recaptcha:
+            data = urlencode({
+                'secret': settings.RECAPTCHA_PRIVATE_KEY,
+                'response': self.cleaned_data['g-recaptcha-response'],
+                'remote-ip': self.request.META.get('REMOTE_ADDR'),
+            })
+
+            try:
+                resp = urlopen(
+                    'https://www.google.com/recaptcha/api/siteverify',
+                    data)
+
+                payload = resp.read()
+            except URLError as e:
+                logging.exception('Could not make reCAPTCHA request: HTTP %s: '
+                                  '%s',
+                                  e.code, e.read())
+                raise ValidationError([
+                    _('Could not validate reCAPTCHA. Please contact an '
+                      'administrator.'),
+                ])
+
+            try:
+                payload = json.loads(payload)
+            except ValueError:
+                logging.exception('Could not parse JSON payload from %r',
+                                  payload)
+                raise ValidationError([
+                    _('Could not validate reCAPTCHA. Please contact an '
+                      'administrator.'),
+                ])
+
+            try:
+                if not payload['success']:
+                    raise ValidationError([
+                        _('Invalid reCAPTCHA response.'),
+                    ])
+            except KeyError:
+                logging.exception('No "success" key in reCAPTCHA payload %r',
+                                  payload)
+                raise ValidationError([
+                    _('Could not validate reCAPTCHA. Please contact an '
+                      'administrator.'),
+                ])
+
+        return super(RecaptchaFormMixin, self).clean()
diff --git a/djblets/recaptcha/siteconfig.py b/djblets/recaptcha/siteconfig.py
new file mode 100644
index 0000000000000000000000000000000000000000..9847f1146af2987e91a3ccc0bbc7922520974a73
--- /dev/null
+++ b/djblets/recaptcha/siteconfig.py
@@ -0,0 +1,13 @@
+from __future__ import unicode_literals
+
+
+settings_map = {
+    'recaptcha_private_key': 'RECAPTCHA_PRIVATE_KEY',
+    'recaptcha_public_key': 'RECAPTCHA_PUBLIC_KEY',
+}
+
+
+defaults = {
+    'recaptcha_private_key': None,
+    'recaptcha_public_key': None,
+}
diff --git a/djblets/recaptcha/templatetags/__init__.py b/djblets/recaptcha/templatetags/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/djblets/recaptcha/templatetags/djblets_recaptcha.py b/djblets/recaptcha/templatetags/djblets_recaptcha.py
new file mode 100644
index 0000000000000000000000000000000000000000..b10b6103d2f6c0896f92750f4994228b5628953c
--- /dev/null
+++ b/djblets/recaptcha/templatetags/djblets_recaptcha.py
@@ -0,0 +1,37 @@
+from __future__ import unicode_literals
+
+from django import template
+from django.utils.html import mark_safe
+
+
+register = template.Library()
+
+
+@register.simple_tag
+def recaptcha_js():
+    """Render the reCAPTCHA JavaScript tag.
+
+    Returns:
+        django.utils.safestring.SafeText:
+        The rendered tag.
+    """
+    return mark_safe('<script src="https://www.google.com/recaptcha/api.js">'
+                     '</script>')
+
+
+@register.simple_tag
+def recaptcha_form_field(form):
+    """Return the reCAPTCHA field from the specified form.
+
+    This can be used to render the reCAPTCHA widget.
+
+    Args:
+        form (django.forms.forms.Form):
+            The form that is being rendered.
+
+    Returns:
+        django.forms.boundfield.BoundField:
+        The bound reCAPTCHA field. This will render as its widget in a
+        template.
+    """
+    return form['g-recaptcha-response']
diff --git a/djblets/recaptcha/widgets.py b/djblets/recaptcha/widgets.py
new file mode 100644
index 0000000000000000000000000000000000000000..021edb47d1be4d50126049aae9b1ee83d9ffdab0
--- /dev/null
+++ b/djblets/recaptcha/widgets.py
@@ -0,0 +1,27 @@
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.forms import widgets
+from django.utils.html import format_html
+
+
+class RecaptchaWidget(widgets.Widget):
+    """A widget for rendering the reCAPTCHA form field."""
+
+    def render(self, *args, **kwargs):
+        """Render the reCAPTCHA form field.
+
+        Args:
+            *args (tuple):
+                Unused positional arguments.
+
+            **kwargs (dict):
+                Unused keyword arguments.
+
+        Returns:
+            django.utils.safestring.SafeText:
+            The rendered reCAPTCHA widget.
+        """
+        return format_html(
+            '<div class="g-recaptcha" data-sitekey="{0}"></div>',
+            settings.RECAPTCHA_PUBLIC_KEY)
diff --git a/docs/djblets/coderef/index.rst b/docs/djblets/coderef/index.rst
index 8f09236ebb0f7f396638534302d471d8dbd385fb..9f2673b842c1f42c4eda9ab14e4f19a58946915b 100644
--- a/docs/djblets/coderef/index.rst
+++ b/docs/djblets/coderef/index.rst
@@ -159,6 +159,17 @@ Markdown Utilities and Extensions
    djblets.markdown.extensions.wysiwyg_email
 
 
+reCAPTCHA
+=========
+
+.. autosummary::
+   :toctree: python
+
+   djblets.recaptcha.mixins
+   djblets.recaptcha.templatetags.djblets_recaptcha
+   djblets.recaptcha.widgets
+
+
 Registries
 ==========
 
diff --git a/docs/djblets/guides/index.rst b/docs/djblets/guides/index.rst
index 5e1587ab920c56bd7714d2709bfc1a3b94dd8e52..180f678dad3289cf40a37fcedc0d228ca46b445b 100644
--- a/docs/djblets/guides/index.rst
+++ b/docs/djblets/guides/index.rst
@@ -6,5 +6,6 @@ Guides
    :maxdepth: 2
 
    extensions/index
+   recaptcha/index
    registries/index
    webapi/index
diff --git a/docs/djblets/guides/recaptcha/index.rst b/docs/djblets/guides/recaptcha/index.rst
new file mode 100644
index 0000000000000000000000000000000000000000..178527b3b57212db3ab6dee5c98bc0bb63c4234f
--- /dev/null
+++ b/docs/djblets/guides/recaptcha/index.rst
@@ -0,0 +1,8 @@
+================
+reCAPTCHA Guides
+================
+
+.. toctree::
+   :maxdepth: 2
+
+   using-recaptcha
diff --git a/docs/djblets/guides/recaptcha/using-recaptcha.rst b/docs/djblets/guides/recaptcha/using-recaptcha.rst
new file mode 100644
index 0000000000000000000000000000000000000000..cf87610478b715b4f19090d578914d163799e5d4
--- /dev/null
+++ b/docs/djblets/guides/recaptcha/using-recaptcha.rst
@@ -0,0 +1,165 @@
+.. _using-recaptcha:
+
+===============
+Using reCAPTCHA
+===============
+
+.. currentmodule:: djblets.recaptcha
+
+
+Using reCAPTCHA validation in forms requires valid reCAPTCHA secret and site
+keys. You can get keys from https://www.google.com/recaptcha/.
+
+The keys can be stored in either Django settings or
+:py:mod:`site configuration settings <djblets.siteconfig.models>`. The keys for
+Django settings are:
+
+* ``RECAPTCHA_PUBLIC_KEY``
+* ``RECPATCHA_PRIVATE_KEY``
+
+The equivalent site configuration keys are:
+
+* ``recaptcha_public_key``
+* ``recaptcha_private_key``
+
+
+reCAPTCHA in Forms
+------------------
+
+If you want to use reCAPTCHA in a form, that form will have to inherit from the
+:py:class:`~mixins.RecaptchaFormMixin` (as well as
+:py:class:`~django.forms.Form`).
+Your form can optionally provide a property,
+:py:meth:`~mixins.RecaptchaFormMixin.verify_recaptcha`, which will determine
+whether or not to validate the reCAPTCHA when the form is submitted. This can
+be used to optionally disable reCAPTCHA through a setting, for example.
+
+Your form will require special rendering to use reCAPTCHA. The
+:py:mod:`~djblets.recaptcha.templatetags.djblets_recaptcha` template tag
+library includes two template tags to help with this:
+
+* :py:meth:`~templatetags.djblets_recaptcha.recaptcha_js`, which renders the
+  necessary JavaScript to use reCAPTCHA on the page. This should be included
+  in the ``<head>`` of your document.
+
+* :py:meth:`~templatetags.djblets_recaptcha.recaptcha_form_field`, which
+  renders the actual reCAPTCHA field. This should be included in your form,
+  before the ``<input type="submit">`` tag.
+
+
+Using Site Configuration
+------------------------
+
+To use site configuration settings instead of Django settings, you will have to
+load the settings map for this module. This should happen once your app is
+initialized. An example using an :py:class:`django.apps.AppConfig` follows:
+
+.. code-block:: python
+
+   from django.apps import AppConfig
+   from djblets.siteconfig.django_settings import (apply_django_settings,
+                                                   generate_defaults,
+                                                   get_django_defaults,
+                                                   get_django_settings_map)
+   from djblets.recaptcha import (defaults as recaptcha_defaults,
+                                  settings_map as recaptcha_settings_map)
+
+   class MyAppConfig(AppConfig):
+       def ready(self):
+           if not siteconfig.get_defaults():
+               defaults = get_django_defaults()
+               defaults.update(recaptcha_defaults)
+
+               siteconfig.add_defaults(defaults)
+
+           settings_map = get_django_settings_map()
+           settings_map.update(recaptcha_settings_map)
+
+           apply_django_settings(settings_map)
+
+
+Using reCAPTCHA in a Form Template
+----------------------------------
+
+If you are not using customized form rendering (i.e. you are rendering your
+form as ``{{form}}`` in your template, you can continue to do so; just ensure
+that the :py:meth:`~templatetags.djblets_recaptcha.recaptcha_js` template tag
+appears in the document ``<head>``. However, if you are rendering your form
+fields individually, you will have to use the
+:py:meth:`~templatetags.djblets_recaptcha.recaptcha_form_field` template tag.
+
+For example, consider the following form:
+
+.. code-block:: python
+
+   from django import forms
+   from django.forms import fields
+   from djblets.recaptcha.mixins import RecaptchaFormMixin
+
+
+   class RegistrationForm(RecaptchaFormMixin, forms.Form):
+       username = fields.CharField(max_length=32,
+                                   label='Username')
+       password = fields.CharField(min_length=8,
+                                   label='Password',
+                                   widget=forms.PasswordInput)
+
+The following two templates can be used to render the form, assuming ``form``
+is the instance of the form. The first template shows rendering using Django's
+built-in form rendering:
+
+.. code-block:: html
+
+   {% load djblets_recaptcha %}
+
+   <!DOCTYPE html>
+   <html>
+    <head>
+     <title>Register</title>
+     {% recaptcha_js %}
+    </head>
+    <body>
+     {{form}}
+    </body>
+   </html>
+
+The second example shows how to use direct field rendering:
+
+.. code-block:: html
+
+   {% load djblets_recaptcha %}
+
+   <!DOCTYPE html>
+   <html>
+    <head>
+     <title>Register</title>
+     {% recaptcha_js %}
+    </head>
+    <body>
+     <form method="POST" action="." id="register-form">
+      <div class="row">
+       {{form.username.label_tag}}
+       {{form.username}}
+       {{form.errors.username
+      </div>
+      <div class="row">
+       {{form.password.label_tag}}
+       {{form.password}}
+       {{form.errors.password}}
+      </div>
+      <div class="row">
+       {% recaptcha_form_field form %}
+      </div>
+      <div class="row">
+       <input type="submit" value="Register">
+      </div>
+     </form>
+    </body>
+   </html>
+
+
+Styling reCAPTCHA fields
+------------------------
+
+The reCAPTCHA field will render as a ``<div>`` element with the class
+``g-recaptcha`` if you wish to style it.
