Almacenar claves secretas de forma segura en modelos de Django (versión Fernet)

La seguridad es responsabilidad del desarrollador – no se debe guardar el texto plano en la base de datos.

1. ¿Por qué se necesita cifrado?



  • API Key / Secret / OAuth token son valores que, una vez filtrados, equivalen a una brecha total del servicio.
  • Si se hardcodean en settings.py, pueden quedar expuestos en el repositorio Git, artefactos de despliegue o en la máquina del desarrollador.
  • Incluso si se guardan en variables de entorno, al almacenarlos tal cual en la BD, el momento en que la BD se vea comprometida, el texto plano queda expuesto.

Otro punto clave:

  • Contraseñas
  • El servidor no necesita conocer el texto plano → hash (BCrypt, Argon2)
  • API Key / Secret / token
  • El servidor necesita usar el texto plano → cifrado reversible

Resumen

  • Datos sensibles que se vuelven a usarcifrar antes de guardar
  • Datos que no se vuelven a usar (contraseñas, etc.)hashear antes de guardar

2. ¿Qué es Fernet?

cryptography.fernet.Fernet es una API de alto nivel que proporciona todo de una vez.

  • Usa algoritmos seguros internamente (AES + HMAC, etc.)
  • Gestiona IV aleatorio, timestamp y verificación de integridad (HMAC) automáticamente
  • El desarrollador solo necesita mantener una clave segura y
  • encrypt(plaintext) → token string
  • decrypt(token) → plaintext

Ejemplo rápido:

from cryptography.fernet import Fernet

key = Fernet.generate_key()      # generar clave
f = Fernet(key)

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

Con esto basta para usarlo.

Lo que debemos cuidar:

  1. Gestión de claves (variables de entorno, Secret Manager, etc.)
  2. Hacer que Django cifre/descifre automáticamente al leer/escribir

3. Generar clave Fernet y configurar Django



3-1. Generar clave Fernet

# Generar una clave Fernet (se muestra como cadena Base64)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Ejemplo de salida:

twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY=

Coloca esta cadena en una variable de entorno del servidor.

export DJANGO_FERNET_KEY="twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY="

3-2. Preparar instancia Fernet en settings.py

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

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

Ahora en cualquier parte de Django puedes hacer:

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

4. Añadir campo cifrado Fernet a un modelo de Django

En lugar de implementar un cifrado complejo, basta con un solo campo personalizado.

4-1. Puntos de diseño

  • En la BD solo se guarda el token cifrado
  • En el código siempre se trabaja con el texto plano descifrado
  • Se separa la columna real (_secret) del significado lógico (secret)

4-2. Implementación de EncryptedTextField

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


class EncryptedTextField(models.TextField):
    """Campo de texto cifrado con Fernet."""

    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

Consejo práctico

Si la BD ya contiene datos en texto plano, from_db_value puede devolver el valor tal cual y, con cada lectura, volver a guardarlo cifrado.


5. Aplicar el campo al modelo

# 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()   # columna real (token Fernet)

    @property
    def secret(self):
        """Acceso en texto plano."""
        return self._secret

    @secret.setter
    def secret(self, value: str):
        self._secret = value
  • _secret → nombre real de la columna en la BD (token cifrado)
  • secret → campo lógico que siempre devuelve el texto plano

Con este patrón, quien vea el modelo puede distinguir claramente:

obj.secret   # texto plano
obj._secret  # token cifrado

6. Ejemplos de uso

6-1. Django shell

>>> from myapp.models import MyModel

# Crear objeto
>>> obj = MyModel(name="Test")
>>> obj.secret = "my-very-secret-key"   # asignar texto plano
>>> obj.save()

# En la BD se guarda el token cifrado
>>> MyModel.objects.get(id=obj.id)._secret
'gAAAAABm...4xO8Uu1f0L3K0Ty6fX5y6Zt3_k6D7eE-'

# En el modelo se ve el texto plano
>>> obj.secret
'my-very-secret-key'

6-2. Uso en una API

# 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})

En producción, normalmente se no expone el secret al cliente, sino que se usa internamente.


7. Cosas a tener en cuenta con Fernet

7-1. Limitaciones de búsqueda/filtrado

Fernet produce un token diferente cada vez, incluso con el mismo texto plano.

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

Aunque ambos son "abc", los tokens en _secret son totalmente distintos.

Por eso, consultas como:

MyModel.objects.filter(_secret="abc")
MyModel.objects.filter(_secret=obj._secret)

no funcionan como se esperaría. Si necesitas buscar por el valor en texto plano, considera:

  • una columna de búsqueda (hash, prefijo, etc.)
  • o re-evaluar si ese campo debe cifrarse.

7-2. Rotación de claves

Es recomendable no usar la misma clave indefinidamente.

  • Inicio sencillo: solo DJANGO_FERNET_KEY
  • Avanzado: mantener OLD_KEY y NEW_KEY
  • cifrar con NEW_KEY
  • descifrar intentando con ambas
  • gradualmente migrar datos a NEW_KEY

8. Checklist de seguridad (versión Fernet)

Elemento Verificación
Gestión de claves No subir DJANGO_FERNET_KEY a Git; usar Secrets Manager, Parameter Store, Vault, etc.
Separación de permisos Solo el servidor de producción recibe la clave; entornos de desarrollo usan claves distintas
Objetos cifrados Solo cifrar API Key, Secret, token; el resto permanece en columnas normales
Logs Nunca registrar texto plano, token ni clave (especialmente en DEBUG)
Backups Los backups son inútiles sin la clave; asegúrate de proteger la clave
Pruebas Verificar que encrypt -> decrypt sea consistente y que migraciones de datos en texto plano funcionen

9. Conclusión

Implementar cifrado de bajo nivel con AES es complejo y propenso a errores. Con Fernet obtienes:

  • Algoritmos seguros combinados
  • IV aleatorio, timestamp y verificación de integridad
  • API sencilla

En Django, usar un campo personalizado como EncryptedTextField junto con la convención _secret / secret permite que el código trabaje siempre con texto plano, mientras la BD almacena únicamente valores cifrados.

Resumen

  • Datos sensibles = siempre cifrar
  • Algoritmos complejos = delegar a bibliotecas Así, la seguridad de tu servicio Django mejora significativamente. 🔐

image