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:
-
Change the field type and run
makemigrations/migrate— this only widens the column to TEXT, the data stays untouched. -
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"])
-
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
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 shopcloud_django_encrypted_fields-0.3.0.tar.gz.
File metadata
- Download URL: shopcloud_django_encrypted_fields-0.3.0.tar.gz
- Upload date:
- Size: 13.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b4e667ef788ee4f0a12054d7c8743237daf627c46f6efaa56160cea105b5765
|
|
| MD5 |
88c850b2445d2e49660582292e3b0cdf
|
|
| BLAKE2b-256 |
a810746a755027c27d87c864a4860358d5280c80fc8100700db2b3073cba25a4
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shopcloud_django_encrypted_fields-0.3.0.tar.gz -
Subject digest:
6b4e667ef788ee4f0a12054d7c8743237daf627c46f6efaa56160cea105b5765 - Sigstore transparency entry: 2687040280
- Sigstore integration time:
-
Permalink:
Talk-Point/shopcloud-django-encrypted-fields@fc1b57d80408f0c6a17ce059dc66a13dde5f9f5d -
Branch / Tag:
refs/heads/master - Owner: https://github.com/Talk-Point
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fc1b57d80408f0c6a17ce059dc66a13dde5f9f5d -
Trigger Event:
push
-
Statement type:
File details
Details for the file shopcloud_django_encrypted_fields-0.3.0-py3-none-any.whl.
File metadata
- Download URL: shopcloud_django_encrypted_fields-0.3.0-py3-none-any.whl
- Upload date:
- Size: 10.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
36e58b3bc951aa79c6a1f47d05cd23816f28c58d03d9873b181570fe3d72f88c
|
|
| MD5 |
b6bd4f8ffcaa1167202135f06b600165
|
|
| BLAKE2b-256 |
dc9a60f8e1335695bc5584c19bea7b2ff8801771cf46d3d25098e27c354d3be4
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shopcloud_django_encrypted_fields-0.3.0-py3-none-any.whl -
Subject digest:
36e58b3bc951aa79c6a1f47d05cd23816f28c58d03d9873b181570fe3d72f88c - Sigstore transparency entry: 2687040309
- Sigstore integration time:
-
Permalink:
Talk-Point/shopcloud-django-encrypted-fields@fc1b57d80408f0c6a17ce059dc66a13dde5f9f5d -
Branch / Tag:
refs/heads/master - Owner: https://github.com/Talk-Point
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fc1b57d80408f0c6a17ce059dc66a13dde5f9f5d -
Trigger Event:
push
-
Statement type: