Safely Storing Secret Keys in Django Models (Fernet Edition)

Security is a developer’s responsibility – never store raw secrets in the database.

1. Why Encryption Matters



  • API keys, secrets, OAuth tokens are critical. If one leaks, the entire service is compromised.
  • Hard‑coding them in settings.py exposes them in Git, deployment artifacts, and developers’ laptops.
  • Even if you keep them in environment variables, storing the plain value in the database means the secret is exposed as soon as the DB is compromised.

A key point to remember:

  • Passwords – the server never needs the plain text. Use a hash (BCrypt, Argon2).
  • API keys, secrets, tokens – the server must use the original value. Use reversible encryption.

Summary

  • Data that will be used again → Encrypt before storing
  • Data that won’t be reused (e.g., passwords) → Hash before storing

2. What is Fernet?

cryptography.fernet.Fernet is a high‑level API that bundles several security primitives:

  • Uses a secure algorithm internally (AES + HMAC, etc.)
  • Handles random IV, timestamps, and integrity checks automatically
  • The developer only needs to keep a single key
from cryptography.fernet import Fernet

key = Fernet.generate_key()      # generate a key
f = Fernet(key)

token = f.encrypt(b"hello")      # encrypt
plain = f.decrypt(token)         # decrypt

With this understanding, you’re ready to use it.

What we need to manage:

  1. Key management (environment variables, Secret Manager, etc.)
  2. Automatic encrypt/decrypt in Django when reading/writing

3. Generating a Fernet Key and Configuring Django



3‑1. Generate a Fernet Key

# Generate one key (Base64 string)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Example output:

twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY=

Store this string in a server environment variable.

export DJANGO_FERNET_KEY="twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY="

3‑2. Prepare a Fernet instance in settings.py

# settings.py
import os
from cryptography.fernet import Fernet

DJANGO_FERNET_KEY = os.environ["DJANGO_FERNET_KEY"]  # string
FERNET = Fernet(DJANGO_FERNET_KEY.encode("utf-8"))

Now anywhere in Django you can do:

from django.conf import settings
settings.FERNET.encrypt(...)
settings.FERNET.decrypt(...)

4. Adding a Fernet‑Encrypted Field to a Django Model

Instead of building a full block‑cipher implementation, a single custom field is enough.

4‑1. Design Considerations

  • Store only the encrypted token string in the database.
  • The application code always sees the decrypted plain text.
  • Use a _secret column for the raw token and a secret property for the logical field.

4‑2. Implementing EncryptedTextField

# myapp/utils/encrypted_field.py
from django.db import models
from django.conf import settings


class EncryptedTextField(models.TextField):
    """Fernet‑based encrypted TextField.
    - On save: plain text → Fernet token
    - On load: Fernet token → plain text
    """

    description = "Fernet encrypted text field"

    def get_prep_value(self, value):
        if value is None:
            return None
        if isinstance(value, str):
            if value == "":
                return ""
            token = settings.FERNET.encrypt(value.encode("utf-8"))
            return token.decode("utf-8")
        return value

    def from_db_value(self, value, expression, connection):
        if value is None:
            return None
        try:
            token = value.encode("utf-8")
            decrypted = settings.FERNET.decrypt(token)
            return decrypted.decode("utf-8")
        except Exception:
            return value

    def to_python(self, value):
        return value

Practical tip

If you have legacy plain text in the DB, let from_db_value return the plain text on decryption failure and gradually re‑encrypt on subsequent saves.


5. Applying the Field to a Model

# myapp/models.py
from django.db import models
from .utils.encrypted_field import EncryptedTextField


class MyModel(models.Model):
    name = models.CharField(max_length=100)
    _secret = EncryptedTextField()   # actual DB column (Fernet token)

    @property
    def secret(self):
        """Always expose the plain text."""
        return self._secret

    @secret.setter
    def secret(self, value: str):
        """Assign plain text; it will be encrypted on save."""
        self._secret = value
  • _secret – the real DB column storing the encrypted token.
  • secret – the logical field used in code, always plain text.

This pattern makes the intent clear to anyone reading the model.


6. Usage Examples

6‑1. Django Shell

>>> from myapp.models import MyModel

# Create a new instance
>>> obj = MyModel(name="Test")
>>> obj.secret = "my-very-secret-key"   # plain text assignment
>>> obj.save()

# The actual DB column contains an encrypted token
>>> MyModel.objects.get(id=obj.id)._secret
'gAAAAABm...4xO8Uu1f0L3K0Ty6fX5y6Zt3_k6D7eE-'

# In code, you get the plain text
>>> obj.secret
'my-very-secret-key'

6‑2. API Usage

In practice, you rarely expose the secret to the client. Here’s a minimal example:

# myapp/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import MyModel


class SecretView(APIView):
    def get(self, request, pk):
        obj = MyModel.objects.get(pk=pk)
        return Response({"secret": obj.secret})

In production, you’d typically use obj.secret only for internal calls and never return it to the client.


7. Things to Keep in Mind When Using Fernet

7‑1. Search / Filtering Limitations

Fernet produces a different ciphertext each time, even for the same plaintext.

obj1.secret = "abc"
obj2.secret = "abc"

Both store distinct _secret values, so queries like filter(_secret="abc") are meaningless. If you need to search or sort by the plain value, consider:

  • A separate searchable column (hash, prefix, etc.)
  • Re‑evaluate whether the field should be encrypted.

7‑2. Key Rotation Strategy

Don’t rely on a single key forever. A simple approach:

  1. Start with one key (DJANGO_FERNET_KEY).
  2. Later, introduce OLD_KEY and NEW_KEY. * Encrypt with NEW_KEY only. * Decrypt with either key. * Gradually re‑encrypt data with NEW_KEY and drop OLD_KEY.

This concept is enough for most use cases.


8. Security Checklist (Fernet Edition)

Item Check
Key Management Keep DJANGO_FERNET_KEY out of Git; load from Secrets Manager, Parameter Store, Vault, etc.
Access Separation Inject the key only on production; use a different key locally.
Encryption Scope Encrypt only truly sensitive values (API keys, tokens). Keep other data in plain columns.
Logging Never log plain text, tokens, or keys (especially in DEBUG mode).
Backups Backups are useless without the key; ensure the key is protected.
Testing Verify encrypt → decrypt round‑trip and migration of legacy plain data.

9. Wrap‑Up

Low‑level AES implementations are error‑prone and hard to maintain. Using Fernet gives you:

  • A proven, secure algorithm combination
  • Automatic IV, timestamp, and integrity checks
  • A simple API

In Django, a custom EncryptedTextField plus the _secret/secret pattern lets developers work with plain text while the database stores only encrypted values.

Bottom line

  • Always encrypt sensitive data before storing.
  • Leave the heavy lifting to well‑tested libraries. Following these two principles significantly raises your Django service’s security posture. 🔐