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.

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.1.0.tar.gz (9.9 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.1.0.tar.gz.

File metadata

File hashes

Hashes for shopcloud_django_encrypted_fields-0.1.0.tar.gz
Algorithm Hash digest
SHA256 021746d8416f85607209f0da2b51c8cb188d299b54dd6cc8aca084eb4b0519c1
MD5 2e7201936bcf605781e30dc440ee2fca
BLAKE2b-256 e6fc62057f578486087e31aea18028c03ec53410970580138494836c412f9e3e

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for shopcloud_django_encrypted_fields-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3e4e6547f0d21171f0d09c99e5f7d05fb7ac65481cb3646cbe89e2bb534bf90a
MD5 6bcaab9b78f2c843bf68604bffae4e6e
BLAKE2b-256 1f404554199c19ff90dbde7e6fe9681168831cbf9e88004890ee0484e89c5f57

See more details on using hashes here.

Provenance

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

0.3.0

2 files

0.2.0

2 files

This release

0.1.0 This release

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