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 instancesave()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, andisnull. Partial matches (contains,startswith) and comparisons (gt,lt) are not possible on encrypted data. EncryptedBinaryFielddoes not supportsearchable=True.
Metadata
Release files for django-hashed-encrypted-fields 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_hashed_encrypted_fields-0.1.0.tar.gz | 59.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_hashed_encrypted_fields-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 77.7 kB
Release files / django_hashed_encrypted_fields-0.1.0.tar.gz
| Download URL | django_hashed_encrypted_fields-0.1.0.tar.gz |
|---|---|
| Size | 59.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
706636aed3dbbe459c5a269e4696a60eb829c9b18b379b2f5e93861dd3424503
|
|
BLAKE2b-256 checksum How to use checksums |
8ba0dd6e361e89d3a2b2f3785ae8e1326910f6a225026946664a9ea7fa6becc6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 7, 2026.
Transparency logRelease files / django_hashed_encrypted_fields-0.1.0-py3-none-any.whl
| Download URL | django_hashed_encrypted_fields-0.1.0-py3-none-any.whl |
|---|---|
| Size | 18.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c642133b9c50b0626c8a700bed46c74acd054936e678a38121466a3538c108cc
|
|
BLAKE2b-256 checksum How to use checksums |
c0685618042698a90e890e785479f5122e5f03ab2c42112bc217616927ded546
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Apr 7, 2026.
Transparency log