diff --git a/README.rst b/README.rst
index 0eb4f633c0f47b7da1add2d857cd317fb59dacb0..848443354a61d7c14a85c8ac38cfcdc5595af9da 100644
--- a/README.rst
+++ b/README.rst
@@ -80,6 +80,9 @@ template tags, templates, etc. that can be used by your own codebase.
   Base support for defining in-code registries, which tracks and allows lookup
   of custom-registered objects
 
+* djblets.secrets_ -
+  Uilities and infrastructure for encryption/decryption and token generation.
+
 * djblets.siteconfig_ -
   In-database site configuration and settings, with Django settings mappings
 
diff --git a/djblets/dependencies.py b/djblets/dependencies.py
index 7f41b2236c562b02be8ab79704364d93399a91dc..a5ef3b0e7a00f5b6897da5d17ff0135b8ceed58d 100644
--- a/djblets/dependencies.py
+++ b/djblets/dependencies.py
@@ -58,6 +58,7 @@ npm_dependencies.update(babel_npm_dependencies)
 
 #: All dependencies required to install Djblets.
 package_dependencies = {
+    'cryptography': '>=1.8.1',
     'Django': django_version,
     'django-pipeline': '~=2.0.8',
     'dnspython': '>=1.14.0',
diff --git a/djblets/secrets/__init__.py b/djblets/secrets/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..15a25d21e50d880f3757ebcd13875f6017431f0f
--- /dev/null
+++ b/djblets/secrets/__init__.py
@@ -0,0 +1,5 @@
+"""Utilities for working with secrets and tokens.
+
+Version Added:
+    3.0
+"""
diff --git a/djblets/secrets/crypto.py b/djblets/secrets/crypto.py
new file mode 100644
index 0000000000000000000000000000000000000000..1437e9471d481bd6e1406cc16b185e556a9b8d88
--- /dev/null
+++ b/djblets/secrets/crypto.py
@@ -0,0 +1,209 @@
+"""Encryption/decryption utilities.
+
+Version Added:
+    3.0
+"""
+
+import base64
+import os
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from django.conf import settings
+
+
+AES_BLOCK_SIZE = algorithms.AES.block_size // 8
+
+
+def _create_cipher(iv, key):
+    """Create a cipher for use in symmetric encryption/decryption.
+
+    This will use AES encryption in CFB mode (using an 8-bit shift register)
+    and a random IV.
+
+    Version Added:
+        3.0
+
+    Args:
+        iv (bytes):
+            The random IV to use for the cipher.
+
+        key (bytes):
+            The encryption key to use.
+
+    Returns:
+        cryptography.hazmat.primitives.cipher.Cipher:
+        The cipher to use for encryption/decryption.
+
+    Raises:
+        ValueError:
+            The encryption key was not in the right format.
+    """
+    if not isinstance(key, bytes):
+        raise TypeError('The encryption key must be of type "bytes", not "%s"'
+                        % type(key))
+
+    return Cipher(algorithms.AES(key),
+                  modes.CFB8(iv),
+                  default_backend())
+
+
+def get_default_aes_encryption_key():
+    """Return the default AES encryption key for the install.
+
+    The default key is the first 16 characters (128 bits) of
+    :django:setting:`SECRET_KEY`.
+
+    Version Added:
+        3.0
+
+    Returns:
+        bytes:
+        The default encryption key.
+    """
+    return settings.SECRET_KEY[:16].encode('utf-8')
+
+
+def aes_encrypt(data, *, key=None):
+    """Encrypt data using AES encryption.
+
+    This uses AES encryption in CFB mode (using an 8-bit shift register) and a
+    random IV (which will be prepended to the encrypted value). The encrypted
+    data will be decryptable using the :py:func:`aes_decrypt` function.
+
+    Version Added:
+        3.0
+
+    Args:
+        data (bytes):
+            The data to encrypt. If a Unicode string is passed in, it will be
+            encoded to UTF-8 first.
+
+        key (bytes, optional):
+            The optional custom encryption key to use. If not supplied, the
+            default encryption key (from
+            :py:func:`get_default_aes_encryption_key)` will be used.
+
+    Returns:
+        bytes:
+        The resulting encrypted value, with the random IV prepended.
+
+    Raises:
+        ValueError:
+            The encryption key was not in the right format.
+    """
+    if isinstance(data, str):
+        data = data.encode('utf-8')
+
+    iv = os.urandom(AES_BLOCK_SIZE)
+    cipher = _create_cipher(iv, key or get_default_aes_encryption_key())
+    encryptor = cipher.encryptor()
+
+    return iv + encryptor.update(data) + encryptor.finalize()
+
+
+def aes_encrypt_base64(data, *, key=None):
+    """Encrypt data and encode as Base64.
+
+    The result will be encrypted using AES encryption in CFB mode (using an
+    8-bit shift register), and serialized into Base64.
+
+    Version Added:
+        3.0
+
+    Args:
+        data (bytes or str):
+            The data to encrypt. If a Unicode string is passed in, it will
+            be encoded to UTF-8 first.
+
+        key (bytes, optional):
+            The optional custom encryption key to use. If not supplied, the
+            default encryption key (from
+            :py:func:`get_default_aes_encryption_key)` will be used.
+
+    Returns:
+        str:
+        The encrypted password encoded in Base64.
+
+    Raises:
+        ValueError:
+            The encryption key was not in the right format.
+    """
+    return base64.b64encode(aes_encrypt(data, key=key)).decode('utf-8')
+
+
+def aes_decrypt(encrypted_data, *, key=None):
+    """Decrypt AES-encrypted data.
+
+    This will decrypt an AES-encrypted value in CFB mode (using an 8-bit
+    shift register). It expects the 16-byte cipher IV to be prepended to the
+    string.
+
+    This is intended as a counterpart for :py:func:`aes_encrypt`.
+
+    Version Added:
+        3.0
+
+    Args:
+        encrypted_data (bytes):
+            The data to decrypt.
+
+        key (bytes, optional):
+            The optional custom encryption key to use. This must match the key
+            used for encryption. If not supplied, the default encryption key
+            (from :py:func:`get_default_aes_encryption_key)` will be used.
+
+    Returns:
+        bytes:
+        The decrypted value.
+
+    Raises:
+        TypeError:
+            One or more arguments had an invalid type.
+
+        ValueError:
+            The encryption key was not in the right format.
+    """
+    if not isinstance(encrypted_data, bytes):
+        raise TypeError('The data to decrypt must be of type "bytes", not "%s"'
+                        % (type(encrypted_data)))
+
+    cipher = _create_cipher(encrypted_data[:AES_BLOCK_SIZE],
+                            key or get_default_aes_encryption_key())
+    decryptor = cipher.decryptor()
+
+    return (decryptor.update(encrypted_data[AES_BLOCK_SIZE:]) +
+            decryptor.finalize())
+
+
+def aes_decrypt_base64(encrypted_data, *, key=None):
+    """Decrypt an encrypted value encoded in Base64.
+
+    This will decrypt a Base64-encoded encrypted value (from
+    :py:func:`aes_encrypt_base64`) into a string.
+
+    Version Added:
+        3.0
+
+    Args:
+        encrypted_data (bytes or str):
+            The Base64-encoded encrypted data to decrypt.
+
+        key (bytes, optional):
+            The optional custom encryption key to use. This must match the key
+            used for encryption. If not supplied, the default encryption key
+            (from :py:func:`get_default_aes_encryption_key)` will be used.
+
+    Returns:
+        str:
+        The resulting decrypted data.
+
+    Raises:
+        ValueError:
+            The encryption key was not in the right format.
+    """
+    return (
+        aes_decrypt(base64.b64decode(encrypted_data),
+                    key=key)
+        .decode('utf-8')
+    )
diff --git a/djblets/secrets/tests/__init__.py b/djblets/secrets/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd961995d512184dd65f5f6c89d7850193358c66
--- /dev/null
+++ b/djblets/secrets/tests/__init__.py
@@ -0,0 +1,5 @@
+"""Unit tests for djblets.secrets.
+
+Version Added:
+    3.0
+"""
diff --git a/djblets/secrets/tests/test_crypto_utils.py b/djblets/secrets/tests/test_crypto_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..65df41096cd34d1b4f17c9da393f4b657768cf45
--- /dev/null
+++ b/djblets/secrets/tests/test_crypto_utils.py
@@ -0,0 +1,187 @@
+"""Unit tests for djblets.secrets.crypto."""
+
+from django.test.utils import override_settings
+
+from djblets.secrets.crypto import (aes_decrypt,
+                                    aes_decrypt_base64,
+                                    aes_encrypt,
+                                    aes_encrypt_base64,
+                                    get_default_aes_encryption_key)
+from djblets.testing.testcases import TestCase
+
+
+@override_settings(SECRET_KEY='abcdefghijklmnopqrstuvwxyz012345')
+class BaseAESTestCase(TestCase):
+    """Base testcase for AES-related tests."""
+
+    PLAIN_TEXT_UNICODE = 'this is a test 123 ^&*'
+    PLAIN_TEXT_BYTES = b'this is a test 123 ^&*'
+
+    PASSWORD_UNICODE = 'this is a test 123 ^&*'
+    PASSWORD_BYTES = b'this is a test 123 ^&*'
+
+    CUSTOM_KEY = b'0123456789abcdef'
+
+
+class AESDecryptTests(BaseAESTestCase):
+    """Unit tests for aes_decrypt."""
+
+    def test_with_bytes(self):
+        """Testing aes_decrypt with byte string"""
+        # The encrypted value was made with PyCrypto, to help with
+        # compatibility testing from older installs.
+        encrypted = (
+            b'\xfb\xdc\xb5h\x15\xa1\xb2\xdc\xec\xf1\x14\xa9\xc6\xab\xb2J\x10'
+            b'\'\xd4\xf6&\xd4k9\x82\xf6\xb5\x8bmu\xc8E\x9c\xac\xc5\x04@B'
+        )
+
+        decrypted = aes_decrypt(encrypted)
+        self.assertIsInstance(decrypted, bytes)
+        self.assertEqual(decrypted, self.PLAIN_TEXT_BYTES)
+
+    def test_with_unicode(self):
+        """Testing aes_decrypt with Unicode string"""
+        expected_message = ('The data to decrypt must be of type "bytes", '
+                            'not "<class \'str\'>"')
+
+        with self.assertRaisesMessage(TypeError, expected_message):
+            aes_decrypt('abc')
+
+    def test_with_custom_key(self):
+        """Testing aes_decrypt with custom key"""
+        # The encrypted value was made with PyCrypto, to help with
+        # compatibility testing from older installs.
+        encrypted = (
+            b'\x9cd$e\xb1\x9e\xe0z\xb8[\x9e!\xf2h\x90\x8d\x82f%G4\xc2\xf0'
+            b'\xda\x8dr\x81ER?S6\x12%7\x98\x89\x90'
+        )
+
+        decrypted = aes_decrypt(encrypted, key=self.CUSTOM_KEY)
+        self.assertIsInstance(decrypted, bytes)
+        self.assertEqual(decrypted, self.PLAIN_TEXT_BYTES)
+
+    def test_with_custom_key_unicode(self):
+        """Testing aes_decrypt with custom key as Unicode"""
+        encrypted = (
+            b'\x9cd$e\xb1\x9e\xe0z\xb8[\x9e!\xf2h\x90\x8d\x82f%G4\xc2\xf0'
+            b'\xda\x8dr\x81ER?S6\x12%7\x98\x89\x90'
+        )
+        expected_message = ('The encryption key must be of type "bytes", '
+                            'not "<class \'str\'>"')
+
+        with self.assertRaisesMessage(TypeError, expected_message):
+            aes_decrypt(encrypted, key='abc')
+
+
+class AESDecryptBase64(BaseAESTestCase):
+    """Unit tests for aes_decrypt_base64."""
+
+    def test_with_bytes(self):
+        """Testing aes_decrypt_base64 with byte string"""
+        # The encrypted value was made with PyCrypto, to help with
+        # compatibility testing from older installs.
+        encrypted = b'AjsUGevO3UiVH7iN3zO9vxvqr5X5ozuAbOUByTATsitkhsih1Zc='
+        decrypted = aes_decrypt_base64(encrypted)
+
+        self.assertIsInstance(decrypted, str)
+        self.assertEqual(decrypted, self.PASSWORD_UNICODE)
+
+    def test_with_unicode(self):
+        """Testing aes_decrypt_base64 with Unicode string"""
+        # The encrypted value was made with PyCrypto, to help with
+        # compatibility testing from older installs.
+        encrypted = 'AjsUGevO3UiVH7iN3zO9vxvqr5X5ozuAbOUByTATsitkhsih1Zc='
+        decrypted = aes_decrypt_base64(encrypted)
+
+        self.assertIsInstance(decrypted, str)
+        self.assertEqual(decrypted, self.PASSWORD_UNICODE)
+
+    def test_with_custom_key(self):
+        """Testing aes_decrypt_base64 with custom key"""
+        # The encrypted value was made with PyCrypto, to help with
+        # compatibility testing from older installs.
+        encrypted = b'/pOO3VWHRXd1ZAeHZo8MBGQsNClD4lS7XK9WAydt8zW/ob+e63E='
+        decrypted = aes_decrypt_base64(encrypted, key=self.CUSTOM_KEY)
+
+        self.assertIsInstance(decrypted, str)
+        self.assertEqual(decrypted, self.PASSWORD_UNICODE)
+
+
+class AESEncryptTests(BaseAESTestCase):
+    """Unit tests for aes_encrypt."""
+
+    def test_with_bytes(self):
+        """Testing aes_encrypt with byte string"""
+        # The encrypted value will change every time, since the iv changes,
+        # so we can't compare a direct value. Instead, we need to ensure that
+        # we can decrypt what we encrypt.
+        encrypted = aes_encrypt(self.PLAIN_TEXT_BYTES)
+        self.assertIsInstance(encrypted, bytes)
+        self.assertEqual(aes_decrypt(encrypted), self.PLAIN_TEXT_BYTES)
+
+    def test_with_unicode(self):
+        """Testing aes_encrypt with Unicode string"""
+        # The encrypted value will change every time, since the iv changes,
+        # so we can't compare a direct value. Instead, we need to ensure that
+        # we can decrypt what we encrypt.
+        encrypted = aes_encrypt(self.PLAIN_TEXT_UNICODE)
+        self.assertIsInstance(encrypted, bytes)
+        self.assertEqual(aes_decrypt(encrypted), self.PLAIN_TEXT_BYTES)
+
+    def test_with_custom_key(self):
+        """Testing aes_encrypt with custom key"""
+        # The encrypted value will change every time, since the iv changes,
+        # so we can't compare a direct value. Instead, we need to ensure that
+        # we can decrypt what we encrypt.
+        encrypted = aes_encrypt(self.PLAIN_TEXT_BYTES, key=self.CUSTOM_KEY)
+        decrypted = aes_decrypt(encrypted, key=self.CUSTOM_KEY)
+
+        self.assertIsInstance(decrypted, bytes)
+        self.assertEqual(decrypted, self.PLAIN_TEXT_BYTES)
+
+
+class AESEncryptBase64(BaseAESTestCase):
+    """Unit tests for aes_encrypt_base64."""
+
+    def test_with_bytes(self):
+        """Testing aes_encrypt_base64 with byte string"""
+        # The encrypted value will change every time, since the iv changes,
+        # so we can't compare a direct value. Instead, we need to ensure that
+        # we can decrypt what we encrypt.
+        encrypted = aes_decrypt_base64(
+            aes_encrypt_base64(self.PASSWORD_BYTES))
+        self.assertIsInstance(encrypted, str)
+        self.assertEqual(encrypted, self.PASSWORD_UNICODE)
+
+    def test_with_unicode(self):
+        """Testing aes_encrypt_base64 with Unicode string"""
+        # The encrypted value will change every time, since the iv changes,
+        # so we can't compare a direct value. Instead, we need to ensure that
+        # we can decrypt what we encrypt.
+        encrypted = aes_decrypt_base64(
+            aes_encrypt_base64(self.PASSWORD_UNICODE))
+        self.assertIsInstance(encrypted, str)
+        self.assertEqual(encrypted, self.PASSWORD_UNICODE)
+
+    def test_with_custom_key(self):
+        """Testing aes_encrypt_base64 with custom key"""
+        # The encrypted value will change every time, since the iv changes,
+        # so we can't compare a direct value. Instead, we need to ensure that
+        # we can decrypt what we encrypt.
+        encrypted = aes_encrypt_base64(self.PASSWORD_UNICODE,
+                                       key=self.CUSTOM_KEY)
+        self.assertIsInstance(encrypted, str)
+
+        decrypted = aes_decrypt_base64(encrypted, key=self.CUSTOM_KEY)
+        self.assertIsInstance(decrypted, str)
+        self.assertEqual(decrypted, self.PASSWORD_UNICODE)
+
+
+class GetDefaultAESEncryptionKeyTests(BaseAESTestCase):
+    """Unit tests for get_default_aes_encryption_key."""
+
+    def test_get_default_aes_encryption_key(self):
+        """Testing get_default_aes_encryption_key"""
+        key = get_default_aes_encryption_key()
+        self.assertIsInstance(key, bytes)
+        self.assertEqual(key, b'abcdefghijklmnop')
diff --git a/docs/djblets/coderef/index.rst b/docs/djblets/coderef/index.rst
index f955b1626545fb0c7b5e02491429dd405097cd58..56a281bc66910f5b4507051d1ba499e6a0766a65 100644
--- a/docs/djblets/coderef/index.rst
+++ b/docs/djblets/coderef/index.rst
@@ -390,6 +390,18 @@ Registries
    :ref:`registry-guides`
 
 
+.. _coderef-djblets-secrets:
+
+Secrets
+=======
+
+.. autosummary::
+   :toctree: python
+
+   djblets.secrets
+   djblets.secrets.crypto
+
+
 .. _coderef-djblets-siteconfig:
 
 Site Configuration
