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.

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.3.0.tar.gz (13.8 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.3.0.tar.gz.

File metadata

File hashes

Hashes for shopcloud_django_encrypted_fields-0.3.0.tar.gz
Algorithm Hash digest
SHA256 6b4e667ef788ee4f0a12054d7c8743237daf627c46f6efaa56160cea105b5765
MD5 88c850b2445d2e49660582292e3b0cdf
BLAKE2b-256 a810746a755027c27d87c864a4860358d5280c80fc8100700db2b3073cba25a4

See more details on using hashes here.

Provenance

The following attestation bundles were made for shopcloud_django_encrypted_fields-0.3.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.3.0-py3-none-any.whl.

File metadata

File hashes

Hashes for shopcloud_django_encrypted_fields-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 36e58b3bc951aa79c6a1f47d05cd23816f28c58d03d9873b181570fe3d72f88c
MD5 b6bd4f8ffcaa1167202135f06b600165
BLAKE2b-256 dc9a60f8e1335695bc5584c19bea7b2ff8801771cf46d3d25098e27c354d3be4

See more details on using hashes here.

Provenance

The following attestation bundles were made for shopcloud_django_encrypted_fields-0.3.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

0.4.0

2 files

This release

0.3.0 This release

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