Encrypted Model Fields#
If your custom_code or reusable app needs to store sensitive data,
TOM Toolkit provides a way to encrypt that data in the database.
Examples of this type of sensitive data include passwords or API keys for external services that your TOM stores on the user’s behalf. TOM Toolkit’s Facility modules, use the mechanism described here to store user-specific external-service credentials in a user profile model. Examples live in tom_demoapp , tom_eso and tom_swift.
Getting Started#
Here is a brief overview of the steps you’ll need to create, display, and update and encrypted field. Each step is covered in its own section below.
Add an
EncryptedModelFieldto your model.List the field in your
UpdateView’sfields.Pass the plaintext (from the model) to your profile-card template and include the
revealable_password_input.htmlpartial.
Adding an encrypted field to a model#
Declare an EncryptedModelField alongside
your other model fields:
from django.db import models
from tom_common.encryption import EncryptedModelField
class MyAppProfile(models.Model):
# you probably have a user OneToOneField here as well
api_key = EncryptedModelField(null=True, blank=True)
The underlying database column is a BinaryField (it holds
the encrypted ciphertext as bytes). Django sees api_key as a normal
named field. ModelForm, the Django admin, DRF ModelSerializer, etc
all introspect it appropriately.
Reading and writing the value in code#
Access the field like any other models.Field subclass:
# assignment
profile.api_key = 'something-secret'
profile.save()
# retrieval; later, possibly in a different process / request:
plaintext_value = profile.api_key # 'something-secret'
The field handles encryption on save and decryption on load.
Assigning None or the empty string '' clears the stored value
(column stored as NULL). Reading an unset value yields None.
For implementation details, see tom_common/encryption.py.
Displaying the current value to your TOM users (read-only)#
To display an encrypted field value with a user-driven “reveal
control” (i.e. the “click the eye icon to see the value” pattern),
use tom_common’s revealable_password_input.html partial template.
The partial needs the plaintext as its value argument, and
plaintext is available via direct attribute access on the model
instance — e.g. profile.api_key. Django introspection paths
(model_to_dict, ModelSerializer, dumpdata, admin display)
all go through EncryptedModelField.value_from_object, which
returns the REDACTED placeholder by design.
In practice, when a profile card uses an inclusion tag (or a view’s
get_context_data) to provide fields to a template, the encrypted
field has to be excluded from any auto-iteration over the model and
passed in explicitly:
...
# exclude the encrypted field from the auto-iteration: model_to_dict
# would only return the REDACTED placeholder for it
excluded_fields = ['user', 'id', 'api_key']
profile_data = model_to_dict(profile, exclude=excluded_fields)
context = {
'profile_data': profile_data, # dictionary without the excluded_fields
'api_key': profile.api_key, # direct attribute access -> plaintext
}
return context
Then in the template, render the encrypted field through the partial:
{% if api_key %}
{% include 'tom_common/partials/revealable_password_input.html' with value=api_key %}
{% else %}
(not set)
{% endif %}
The partial renders a masked input of fixed length; the real value is only injected into the DOM when the user clicks the reveal icon.
A worked example of this pattern lives in
tom_demoapp’s
demo_extras.py and profile_demo.html.
Editing the value in an UpdateView#
Include the EncryptedModelField in the fields list of your
ProfileUpdateView (or any other ModelForm-based view) and the
form renders the field automatically as a composite control with a masked
password input joined to a “Clear” checkbox in a single input-group.
class MyProfileUpdateView(UpdateView):
model = MyAppProfile
fields = ['api_key', 'display_name']
How the control behaves on submit:
A typed value replaces the stored value.
An empty input leaves the stored value unchanged. This protects the secret when a user edits an unrelated field on the same form and saves without retyping.
Checking Clear with an empty input clears the stored value (column becomes
NULL).If a typed value and the Clear checkbox are submitted together, the typed value wins and the checkbox is ignored — the more conservative choice, since “don’t lose what the user just typed” is safer than the alternative.
The stored value is never revealed by this toggle (or by anything else in the form), because the stored value is never rendered into the form’s HTML to begin with.
To bridge the resulting information gap (the form input is masked
whether or not a value is stored), the input’s placeholder is
state-aware: it reads (A stored value is hidden) — type to replace
when a value is stored, and (not set) — type to add when nothing
is stored. A hover tooltip on the control reinforces the same idea.
The actual stored value is not communicated through either channel;
to view it, follow the read-only display pattern above.
How encryption works#
Encrypted fields are protected by a single Fernet cipher derived from
settings.SECRET_KEY. For TOM administrator concerns (rotating
SECRET_KEY without losing data, etc.) see
Encryption and the SECRET_KEY.
Note
Encryption protects data from passive database exposure. It does NOT
protect against a server administrator with access to the
settings.SECRET_KEY. If you need user-level isolation from
administrators, the toolkit’s current scheme is not sufficient.
What you cannot do with an EncryptedModelField#
Filter on the value. Fernet is non-deterministic — every
encrypt() produces a different ciphertext for the same plaintext,
so database-level equality lookups can never match.
EncryptedModelField.get_lookup() raises FieldError rather
than silently returning empty querysets:
MyAppProfile.objects.filter(api_key='foo') # raises FieldError
If you need equality search on the encrypted column, store a companion HMAC-derived hash column alongside it and query the hash.
Round-trip via dumpdata / loaddata. Serialization paths emit a
placeholder (EncryptedModelField.REDACTED, currently
'******** (encrypted, not shown)') rather than the plaintext.
This keeps secrets out of fixture files, DRF API responses, and
admin history by default. As a consequence, attempting to load a
dumpdata fixture back fails loudly when
to_python() encounters the placeholder.
To migrate encrypted data between environments, copy the database
row directly (the ciphertext survives) or write a one-off
decrypt-and-re-encrypt script.
Direct attribute access (getattr(instance, field_name)) bypasses
the redaction and remains the only path to the plaintext — code that
legitimately needs the secret reads the attribute directly.
What happens on decryption failure#
If a stored ciphertext cannot be decrypted under any active key
(settings.SECRET_KEY plus any settings.SECRET_KEY_FALLBACKS),
EncryptedModelField.from_db_value() raises
cryptography.fernet.InvalidToken at row-load time. The most
common cause is a key removed from the rotation set before its data
was re-encrypted under a new primary. See
Encryption and the SECRET_KEY for the rotation procedure and the
rotate_encryption_key management command.
API reference#
EncryptedModelField(source)A
models.BinaryFieldsubclass that transparently encrypts on save and decrypts on load. See the class docstring intom_common/encryption.pyfor the full method-level contract.EncryptedFormField(source)The form-side companion. Handles the masked-input UX and the blank-submission-preserves-existing behavior.
ModelFormpicks it up automatically viaEncryptedModelField.formfield().