Skip to main content

shopcloud-django-encrypted-fields

Transparently encrypted Django model fields, backed by Fernet (AES-128-CBC + HMAC-SHA256) from cryptography.

The value is encrypted on its way into the database and decrypted on its way out. Everything in between — forms, DRF serializers, model_to_dict, templates — sees the plaintext, so existing code keeps working unchanged.

from shopcloud_django_encrypted_fields import EncryptedCharField

class Credential(models.Model):
    title = models.CharField(max_length=255)
    password = EncryptedCharField(max_length=255, null=True, blank=True)
>>> Credential.objects.create(title="GitHub", password="hunter2").password
'hunter2'
>>> # what the database actually holds:
'gAAAAABm...T7Q=='

Installation

pip install shopcloud-django-encrypted-fields

Generate a key and put it in the environment:

python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# settings.py
DJ_ENCRYPTED_FIELDS = {
    "KEYS": [os.environ["ENCRYPTION_KEY"]],   # newest key first
    "READ_PLAINTEXT": True,                   # see "Migrating existing data"
}

The app is not added to INSTALLED_APPS — there is nothing to install, only fields to import.

Field types

Field Base Notes
EncryptedCharField models.CharField max_length still validates the plaintext
EncryptedTextField models.TextField
EncryptedEmailField models.EmailField email validation runs on the plaintext
EncryptedJSONField models.TextField stores encrypted JSON; takes encoder/decoder

Any other text-backed field is three lines:

from shopcloud_django_encrypted_fields import EncryptedMixin

class EncryptedURLField(EncryptedMixin, models.URLField):
    pass

What this protects against

Database dumps, backups in a bucket, direct Cloud SQL access, and anyone reading rows who is not the application. It does not protect against a compromised application instance: the key is in the app's environment, because the app has to show the value to the user. That is a deliberate trade-off, not an oversight — genuine zero-knowledge (key derived from the user's password, crypto in the browser) rules out shared credentials and SSO, and is a different product.

Consequences you have to plan for

Filtering is impossible. Fernet is non-deterministic — the same plaintext encrypts to a different token every time — so filter(password="hunter2") could never match. Rather than silently returning an empty queryset, every lookup except isnull raises FieldError:

>>> Credential.objects.filter(password="hunter2")
FieldError: Lookup 'exact' is not supported on EncryptedCharField  the stored
value is a non-deterministic ciphertext and would never match.

If you need to search a value, keep a separate searchable column (a blind index, or a hash) next to the encrypted one.

The column becomes TEXT. A Fernet token is roughly twice the length of its plaintext, so varchar(255) would truncate. get_internal_type() returns "TextField" for every encrypted field. max_length keeps validating the plaintext in forms.

A wrong key raises, it never renders. A value that looks like a Fernet token but cannot be opened by any configured key raises DecryptionError — in every mode, including while READ_PLAINTEXT is on. The alternative would be worse than an error page: the raw token would reach the view as if it were the secret, and saving that form would encrypt the ciphertext a second time, destroying the original value. Plaintext rows are told apart from tokens structurally, so the migration fallback still works.

>>> Credential.objects.get(pk=1).password
DecryptionError: Value is a Fernet token but no configured key decrypts it.
Check DJ_ENCRYPTED_FIELDS['KEYS']  a retired key has to stay in the list
until everything it wrote has been rotated.

A missing or malformed key is reported by manage.py check (encrypted_fields.E001), so it fails the deploy rather than the first user who opens a record.

Never assign a database expression. update(field=F("other")) is compiled straight to SQL, so no field hook runs and the value lands in the column as plaintext — silently. Django offers nothing to intercept this from inside a field. Assign the value in Python and call save(); update() with a literal and bulk_create() both encrypt correctly.

No database-side uniqueness or ordering. unique=True compares ciphertexts, which are always different. order_by sorts ciphertexts, which is meaningless.

Migrating existing data

While READ_PLAINTEXT is True (the default), rows that are not encrypted yet are read as-is. That makes the switch a normal deploy instead of a maintenance window:

  1. Change the field type and run makemigrations / migrate — this only widens the column to TEXT, the data stays untouched.

  2. Backfill. Reading gives plaintext, saving writes ciphertext, so this is all it takes:

    for obj in Credential.objects.all().iterator():
        obj.save(update_fields=["password"])
    
  3. Set READ_PLAINTEXT: False. From then on an undecryptable value raises instead of being handed out as plaintext — which is what you want, because after the backfill it means the key is wrong, not that the row is old.

Key rotation

KEYS is a list, newest first. Every key in it can decrypt; only the first one encrypts. So rotating is: prepend the new key, deploy, re-encrypt, drop the old key.

from shopcloud_django_encrypted_fields import rotate
# re-encrypts a token with the newest key without exposing the plaintext

Keep the retired key in the list until the backfill has run — a token it wrote is unreadable without it.

Performance

Measured on 2000 rows, SQLite, Python 3.14:

encrypt + decrypt, one value ~14 µs
single credential lookup (the common request) 208 µs total, ~5 % of it crypto
loading 2000 rows with decryption +31 ms over the same query deferred

For a single credential the crypto is far below the 1–5 ms of one Cloud SQL round trip — it does not show up in a request. It only becomes visible on list views that load every row, and there it is avoidable: list serializers usually do not output the secret anyway, so exclude the column from the query.

queryset = Credential.objects.defer("password", "otp_secret")

The Fernet instance is cached on the key tuple, so the key is not rebuilt per value.

Development

pip install -e ".[dev]"
pytest tests/
tox            # Python 3.12/3.13 x Django 5.0-6.0

License

MIT — Talk-Point GmbH

Download files

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

Source Distribution

shopcloud_django_encrypted_fields-0.4.0.tar.gz (15.0 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 shopcloud_django_encrypted_fields-0.4.0.tar.gz.

File metadata

File hashes

Hashes for shopcloud_django_encrypted_fields-0.4.0.tar.gz
Algorithm Hash digest
SHA256 c1f6293d5cf3e6d1ae5395cea9397627c3ab55f5b567c0a62267a9afc4165717
MD5 d3904df7aa1ee011c807797fd2b6d45c
BLAKE2b-256 ff2df12a4f912cf52faaa3522bbaf6ded00be480b430e5e8ee6621f24f11732f

See more details on using hashes here.

Provenance

The following attestation bundles were made for shopcloud_django_encrypted_fields-0.4.0.tar.gz:

Publisher: publish.yml on Talk-Point/shopcloud-django-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 shopcloud_django_encrypted_fields-0.4.0-py3-none-any.whl.

File metadata

File hashes

Hashes for shopcloud_django_encrypted_fields-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0f31b888119aa68bf3cabbe6dd6acf0a5c4dec7fd5115ba6836178841cde1717
MD5 89e2542127d8d491df35b4b0bf998c5a
BLAKE2b-256 26679324cc41f6af4561dc2b67604fd6b6e2a10adcac92a6a0f2cca76038e182

See more details on using hashes here.

Provenance

The following attestation bundles were made for shopcloud_django_encrypted_fields-0.4.0-py3-none-any.whl:

Publisher: publish.yml on Talk-Point/shopcloud-django-encrypted-fields

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

Release history Release notifications | RSS feed

0.5.0

2 files

This release

0.4.0 This release

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page