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 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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_hashed_encrypted_fields-0.1.0.tar.gz.
File metadata
- Download URL: django_hashed_encrypted_fields-0.1.0.tar.gz
- Upload date:
- Size: 59.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
706636aed3dbbe459c5a269e4696a60eb829c9b18b379b2f5e93861dd3424503
|
|
| MD5 |
9cae13db5f524fff5a4707b3232e1a0a
|
|
| BLAKE2b-256 |
8ba0dd6e361e89d3a2b2f3785ae8e1326910f6a225026946664a9ea7fa6becc6
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_hashed_encrypted_fields-0.1.0.tar.gz -
Subject digest:
706636aed3dbbe459c5a269e4696a60eb829c9b18b379b2f5e93861dd3424503 - Sigstore transparency entry: 1247672051
- Sigstore integration time:
-
Permalink:
kolanos/django-hashed-encrypted-fields@d355673e88218f4eac931efebd1a15f4d94c7934 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/kolanos
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d355673e88218f4eac931efebd1a15f4d94c7934 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_hashed_encrypted_fields-0.1.0-py3-none-any.whl.
File metadata
- Download URL: django_hashed_encrypted_fields-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c642133b9c50b0626c8a700bed46c74acd054936e678a38121466a3538c108cc
|
|
| MD5 |
d1de8b0ddf925c17349081209423a445
|
|
| BLAKE2b-256 |
c0685618042698a90e890e785479f5122e5f03ab2c42112bc217616927ded546
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_hashed_encrypted_fields-0.1.0-py3-none-any.whl -
Subject digest:
c642133b9c50b0626c8a700bed46c74acd054936e678a38121466a3538c108cc - Sigstore transparency entry: 1247672056
- Sigstore integration time:
-
Permalink:
kolanos/django-hashed-encrypted-fields@d355673e88218f4eac931efebd1a15f4d94c7934 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/kolanos
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d355673e88218f4eac931efebd1a15f4d94c7934 -
Trigger Event:
release
-
Statement type: