Skip to main content

Django model fields with transparent encryption and searchable hash columns.

Project description

django-hashed-encrypted-fields

Django model fields that store encrypted values with separate searchable hash columns.

Data is encrypted at rest using Fernet symmetric encryption and can be queried via HMAC-SHA256 hashes without ever decrypting. Encryption keys, hashing, and storage are all pluggable.

Features

  • Transparent encryption/decryption on model fields
  • Searchable fields via a separate indexed hash column ({field}_hash)
  • Encrypted column stored as {field}_encrypted
  • Pluggable key providers (use Django settings, a vault, or any custom source)
  • Pluggable encryption/hash providers (Fernet + HMAC-SHA256 by default)
  • Per-field provider overrides
  • Key rotation support via MultiFernet
  • Django admin integration with configurable value masking
  • Django forms support (encryption is transparent)
  • Encrypted file storage for FileField and ImageField
  • 11 field types: Char, Text, Integer, Boolean, Date, DateTime, Decimal, JSON, Binary, File, Image

Requirements

  • Python 3.10+
  • Django 4.2+
  • cryptography 41.0+

Installation

pip install django-hashed-encrypted-fields

Add to INSTALLED_APPS:

INSTALLED_APPS = [
    # ...
    "encrypted_fields",
]

Generate an encryption key:

python manage.py generate_encryption_key

Add the output to your settings:

ENCRYPTED_FIELDS_KEY = "your-generated-fernet-key"
ENCRYPTED_FIELDS_HASH_SALT = "your-generated-salt"

Quick Start

from django.db import models
from encrypted_fields import EncryptedCharField, EncryptedIntegerField

class Patient(models.Model):
    name = EncryptedCharField(max_length=100)
    ssn = EncryptedCharField(max_length=11, searchable=True, unique=True)
    age = EncryptedIntegerField()

This creates two database columns for name (name_encrypted), three for ssn (ssn_encrypted + ssn_hash), and two for age (age_encrypted):

# Create
patient = Patient.objects.create(name="Alice", ssn="123-45-6789", age=30)

# Read — decryption is transparent
patient = Patient.objects.get(pk=patient.pk)
print(patient.name)  # "Alice"
print(patient.ssn)   # "123-45-6789"
print(patient.age)   # 30

# Search — only on searchable fields
Patient.objects.filter(ssn="123-45-6789")           # exact
Patient.objects.filter(ssn__in=["123-45-6789"])      # in
Patient.objects.filter(ssn__isnull=False)             # isnull

Field Types

Field Base Type Notes
EncryptedCharField CharField Requires max_length
EncryptedTextField TextField
EncryptedIntegerField IntegerField
EncryptedBooleanField BooleanField
EncryptedDateField DateField Stored as ISO format
EncryptedDateTimeField DateTimeField Stored as ISO format
EncryptedDecimalField DecimalField Requires max_digits, decimal_places
EncryptedJSONField JSONField JSON serialized with sorted keys
EncryptedBinaryField BinaryField searchable=True not supported
EncryptedFileField FileField Uses encrypted file storage
EncryptedImageField ImageField Uses encrypted file storage

All fields accept standard Django field options (null, blank, default, etc.) plus:

Parameter Default Description
searchable False Creates a {name}_hash column for lookups
unique False Enforced on the hash column (requires searchable=True)
mask 'last4' Admin display masking ('last4', 'full', or callable)
key_provider None Per-field key provider override
encryption_provider None Per-field encryption provider override

Searchable Fields

When searchable=True, a companion HashField column is created with a database index. Lookups are performed against the hash, so the encrypted data never needs to be decrypted for queries.

Supported lookups:

# Exact match — WHERE ssn_hash = hash('123-45-6789')
Patient.objects.filter(ssn="123-45-6789")

# IN — WHERE ssn_hash IN (hash('...'), hash('...'))
Patient.objects.filter(ssn__in=["123-45-6789", "987-65-4321"])

# IS NULL — WHERE ssn_encrypted IS NULL
Patient.objects.filter(ssn__isnull=True)

Unsupported lookups (contains, startswith, gt, lt, etc.) raise LookupNotSupported. Non-searchable fields only support isnull.

Settings

# Required: Fernet encryption key (string or list for key rotation)
ENCRYPTED_FIELDS_KEY = "base64-encoded-fernet-key"

# Optional: Salt for HMAC-SHA256 hashing (recommended)
ENCRYPTED_FIELDS_HASH_SALT = "random-salt-string"

# Optional: Global key provider (dotted import path)
ENCRYPTED_FIELDS_KEY_PROVIDER = "myapp.providers.VaultKeyProvider"

# Optional: Global encryption provider (dotted import path)
ENCRYPTED_FIELDS_ENCRYPTION_PROVIDER = "myapp.providers.AESGCMProvider"

Key Rotation

Key rotation is supported via MultiFernet. Provide keys as a list — the first key is used for new encryptions, and all keys are tried for decryption:

ENCRYPTED_FIELDS_KEY = [
    "new-primary-key",    # Used for encrypting new data
    "old-key-1",          # Can still decrypt existing data
    "old-key-2",          # Can still decrypt older data
]

Hashes are salt-based (not key-based), so search continues working without any changes after key rotation. To fully migrate, re-save each record to re-encrypt with the new key, then remove old keys.

Custom Providers

Key Provider

Implement BaseKeyProvider to load keys from a vault or other source:

from encrypted_fields import BaseKeyProvider

class VaultKeyProvider(BaseKeyProvider):
    def __init__(self, secret_path="/encryption/keys"):
        self.secret_path = secret_path

    def get_keys(self) -> list[str]:
        # First key = primary (for encryption), rest = decryption only
        return vault_client.get_secret(self.secret_path)

Encryption Provider

Implement BaseEncryptionProvider to use a different algorithm:

from encrypted_fields import BaseEncryptionProvider

class AESGCMProvider(BaseEncryptionProvider):
    def encrypt(self, value: bytes, keys: list[str]) -> str:
        # Encrypt using primary key, return string
        ...

    def decrypt(self, value: str, keys: list[str]) -> bytes:
        # Decrypt trying all keys, return bytes
        ...

    def hash(self, value: bytes, salt: str | None) -> str:
        # Return deterministic 64-char hex hash
        ...

Configuration

Providers can be set globally in settings or per-field:

# Global (in settings.py)
ENCRYPTED_FIELDS_KEY_PROVIDER = "myapp.providers.VaultKeyProvider"

# Per-field (overrides global)
class Patient(models.Model):
    ssn = EncryptedCharField(
        max_length=11,
        searchable=True,
        key_provider=VaultKeyProvider(secret_path="/pii/keys"),
        encryption_provider="myapp.providers.AESGCMProvider",
    )

Resolution order: field-level parameter > Django settings > library default.

Admin Integration

Encrypted fields appear in Django admin forms as normal input fields. In list views, values are masked automatically.

Masking options via the mask parameter:

# Show last 4 characters (default): "*******6789"
ssn = EncryptedCharField(max_length=11, mask="last4")

# Fully masked: "***********"
secret = EncryptedCharField(max_length=100, mask="full")

# Custom callable
token = EncryptedCharField(max_length=100, mask=lambda v: v[:4] + "****")

The companion _hash column is never shown in admin forms (editable=False).

File Fields

EncryptedFileField and EncryptedImageField encrypt file content at rest using EncryptedFileSystemStorage. Files are encrypted on save and decrypted on open.

from encrypted_fields import EncryptedFileField, EncryptedImageField

class Document(models.Model):
    file = EncryptedFileField(upload_to="documents/")
    photo = EncryptedImageField(upload_to="photos/")

File content on disk is always ciphertext. Reading through Django's file API returns decrypted content transparently.

Management Commands

generate_encryption_key

Generate a Fernet encryption key and optional hash salt:

python manage.py generate_encryption_key

Output:

ENCRYPTED_FIELDS_KEY = "base64-encoded-key"
ENCRYPTED_FIELDS_HASH_SALT = "random-hex-salt"

Pass --no-salt to skip salt generation.

reencrypt

Bulk re-encrypt and re-hash all encrypted field values. Use after key rotation or hash salt changes:

# Re-encrypt all encrypted fields across all models
python manage.py reencrypt

# Re-encrypt a specific model
python manage.py reencrypt myapp.Patient

# Re-encrypt a specific field
python manage.py reencrypt myapp.Patient.ssn

# Control batch size (default: 100)
python manage.py reencrypt --batch-size=500

# Preview without modifying data
python manage.py reencrypt --dry-run

Development

Setup

uv sync

Testing

uv run pytest

The test suite includes 94 tests covering fields, lookups, providers, admin integration, forms, encrypted file storage, and the re-encryption command. Tests use pytest-django with an in-memory SQLite database.

Type Checking

uv run mypy encrypted_fields

Uses mypy with the django-stubs plugin for full Django type awareness.

Linting

uv run ruff check .    # Lint
uv run ruff format .   # Format

License

MIT. See LICENSE for details.

Limitations

  • QuerySet.update() encrypts the value but does not update the hash companion field. Use model instance save() instead.
  • values() / values_list() returns ciphertext, not decrypted values. Access encrypted fields through model instances.
  • order_by() is not meaningful on encrypted fields (ciphertext has no sort order).
  • Lookups are limited to exact, in, and isnull. Partial matches (contains, startswith) and comparisons (gt, lt) are not possible on encrypted data.
  • EncryptedBinaryField does not support searchable=True.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_hashed_encrypted_fields-0.1.0.tar.gz (59.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

File details

Details for the file django_hashed_encrypted_fields-0.1.0.tar.gz.

File metadata

File hashes

Hashes for django_hashed_encrypted_fields-0.1.0.tar.gz
Algorithm Hash digest
SHA256 706636aed3dbbe459c5a269e4696a60eb829c9b18b379b2f5e93861dd3424503
MD5 9cae13db5f524fff5a4707b3232e1a0a
BLAKE2b-256 8ba0dd6e361e89d3a2b2f3785ae8e1326910f6a225026946664a9ea7fa6becc6

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_hashed_encrypted_fields-0.1.0.tar.gz:

Publisher: publish.yml on kolanos/django-hashed-encrypted-fields

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_hashed_encrypted_fields-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_hashed_encrypted_fields-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c642133b9c50b0626c8a700bed46c74acd054936e678a38121466a3538c108cc
MD5 d1de8b0ddf925c17349081209423a445
BLAKE2b-256 c0685618042698a90e890e785479f5122e5f03ab2c42112bc217616927ded546

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_hashed_encrypted_fields-0.1.0-py3-none-any.whl:

Publisher: publish.yml on kolanos/django-hashed-encrypted-fields

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page