Por que HTTPS não basta: field-level encryption no Django
Criptografia em nível de coluna
Tem uma resposta que aparece toda vez que alguém pergunta "como você protege os dados dos seus usuários?": "uso HTTPS". E dependendo de quem pergunta, isso satisfaz. Mas quando você trabalha com dados médicos — prontuários, CPFs, diagnósticos — em algum momento você percebe que HTTPS e disco criptografado estão protegendo as coisas erradas.
Esse artigo é sobre o que acontece quando você para de confiar que "o banco está atrás de um firewall" é suficiente. Vou falar de field-level encryption com Django usando a lib django-fernet-encrypted-fields, como ela funciona por baixo, quais problemas ela cria (porque cria), e quando faz sentido usar.
O contexto: estou desenvolvendo o ByDoctor, um sistema de gestão para clínicas médicas. Prontuários, prescrições, dados de pacientes. O tipo de sistema onde um backup vazado não é um problema de reputação — é uma violação séria de privacidade de pessoas reais.
O que HTTPS protege e o que não protege
HTTPS criptografa os dados em trânsito. O request vai criptografado do browser até o servidor. Quando chega ao servidor, é decifrado. A partir daí, os dados trafegam em texto puro pela aplicação, chegam ao banco em texto puro, ficam armazenados em texto puro.
Full-disk encryption protege o disco físico quando o servidor está desligado. Quando o servidor está ligado, o SO monta o filesystem decifrado. O PostgreSQL lê tudo normalmente.
O que isso deixa vulnerável na prática:
Um dump de banco vazado por backup mal configurado — um bucket S3 sem ACL certa, um snapshot público por engano, uma exportação que foi parar no lugar errado. Um colaborador interno com acesso ao banco de dados. Um log de erro que capturou um stack trace com variáveis locais incluindo dados de paciente. O painel de admin da aplicação mostrando todos os campos de uma tabela de pacientes para qualquer usuário com acesso admin.
Field-level encryption é sobre esse conjunto específico de cenários: os dados ficam criptografados dentro do banco, e a chave nunca sai da aplicação.
Como funciona
A lib django-fernet-encrypted-fields (mantida pela Jazzband) adiciona tipos de campo ao ORM que criptografam antes de escrever e decifram ao ler. Do ponto de vista do código, o campo se comporta como CharField ou TextField normal. O banco de dados vê um blob opaco.
from encrypted_fields import EncryptedCharField, EncryptedTextField
class Customer(TenantModel):
name = models.CharField(max_length=255) # não criptografado — usado em buscas
cpf = EncryptedCharField(max_length=100) # criptografado
email = EncryptedCharField(max_length=100) # criptografado
birthday = EncryptedCharField(max_length=100) # criptografado
phone = EncryptedCharField(max_length=100) # criptografado
details = EncryptedTextField() # criptografado
No banco, cpf não contém "123.456.789-00". Contém algo como gAAAAABm...lVHQ8Kz3mNjmr5F0qR9==. Isso vale para backups, dumps, SELECT * direto no banco. Sem a chave da aplicação, é inútil.
O algoritmo: Fernet
O Fernet (da lib cryptography do Python) usa AES-128-CBC com padding PKCS7 para criptografar e HMAC-SHA256 para autenticar. Cada valor criptografado inclui um IV (vetor de inicialização) gerado aleatoriamente via os.urandom().
Uma mesma string criptografada duas vezes gera dois cifrextos completamente diferentes — porque o IV muda. Isso tem uma consequência que você precisa entender antes de sair criptografando campos.
O problema que a maioria não menciona
Quando um campo está criptografado, você não pode fazer:
Customer.objects.filter(email="paciente@exemplo.com")
Customer.objects.filter(cpf="123.456.789-00").exists()
Customer.objects.order_by("birthday")
O banco vê apenas ciphertext. Dois ciframentos do mesmo CPF produzem valores diferentes, então o banco não consegue comparar. Não tem WHERE cpf = ? que funcione.
Dependendo do que você precisa, isso vai de "ok, dá pra contornar" a "isso inviabiliza a feature". No meu caso, dados de saúde raramente são usados como filtro primário — você busca o paciente pelo nome, depois lê os dados do perfil. Mas se o produto precisasse de "buscar paciente por CPF" como funcionalidade central, eu teria que pensar diferente.
Uma abordagem possível é manter um hash determinístico separado do valor (algo como SHA-256(cpf) prefixado com um salt fixo) só para lookup. A lib tem suporte experimental a isso via campos paired. Não cheguei a usar porque no meu caso não precisei, mas existe.
A lição prática: criptografar tudo indiscriminadamente vai travar queries legítimas e não vai necessariamente aumentar a segurança proporcionalmente. Escolha campos específicos.
Quais campos criptografar
No ByDoctor, a divisão ficou assim:
Criptografados: CPF, RG, email, telefone, data de nascimento, endereço completo (logradouro, bairro, CEP, cidade, estado), texto de prontuário clínico, queixas do paciente, exame físico, conduta, conclusão clínica, transcrições de consultas geradas por IA, dados de responsável legal (para menores).
Não criptografados: nome do paciente (usado como critério de busca), status de agendamento (usado em dashboards), datas e horários (queries de calendário), tipo de consulta e plano de saúde (relatórios), IDs e foreign keys.
Não tem uma resposta universal. A pergunta é: se esse campo aparecer em texto puro num dump de banco, qual o impacto? Texto de uma consulta psiquiátrica tem impacto alto. Status "Confirmado" de uma consulta, impacto quase nulo.
Configuração e rotação de chaves
A lib usa a setting KEY. Você pode passar uma lista ordenada para suportar rotação:
# settings.py
SALT_KEY = [
os.getenv("KEY"), # chave atual — usada para cifrar novos valores
"chave-anterior-para-fallback", # tentada na decifração de registros antigos
]
A primeira chave cifra os novos valores. As demais são tentadas em sequência para decifrar — o que permite trocar a chave sem reencriptar tudo de uma vez. Você adiciona a nova chave no início, migra os registros gradualmente com um script de background, remove a antiga quando não tem mais registro antigo.
Vale separar bem: KEY é diferente do SECRET_KEY do Django. São segredos independentes. Se o SECRET_KEY vazar, você perde sessões e cookies. Se a KEY vazar, você perde o conteúdo de todos os campos criptografados. Trate com o mesmo cuidado.
Uma coisa que eu fiz de errado no começo: deixei uma chave de fallback estática no código-fonte como segundo elemento da lista. A ideia era facilitar o desenvolvimento local — mas essa chave ficou visível no repositório e, dependendo de quem tiver acesso, serve como chave de decifração alternativa. Estou removendo isso e usando apenas chaves vindas de variáveis de ambiente, inclusive em desenvolvimento.
Camada de transporte
Para completar o quadro: além dos dados em repouso, o que está configurado no transporte.
A API é servida exclusivamente via HTTPS, com certificado gerenciado pelo hosting e renovado automaticamente. Frontend e API se comunicam com CORS restritivo — apenas origens específicas na lista de permitidas. Autenticação via JWT com tempo de expiração curto, emitidos pelo Clerk.
Logs e monitoramento como vetor de vazamento
Esse ponto fica fora da discussão padrão, mas é relevante.
Quando uma exceção ocorre durante o processamento de um request com dados de paciente, o stack trace pode incluir variáveis locais com valores já decifrados. O Sentry, que uso para monitoramento de erros, tem send_default_pii = False — mas isso não remove variáveis locais de frames de exceção, que são capturadas separadamente.
A solução foi um hook before_send que remove as variáveis locais de todos os frames antes de enviar.
Você perde contexto de debugging — sem dúvida. Mas num sistema de saúde, prefiro investigar um bug com menos informação do que vazar dados de um paciente para um serviço terceiro de monitoramento.
Migrando dados existentes
Se você já tem dados em texto puro e precisa criptografá-los, a abordagem é uma data migration do Django que re-salva cada instância para que o ORM aplique a criptografia:
def encrypt_existing_data(apps, schema_editor):
MedicalRecord = apps.get_model("medical_records", "MedicalRecord")
for record in MedicalRecord.objects.all().iterator(chunk_size=200):
record.save()
class Migration(migrations.Migration):
operations = [
migrations.RunPython(encrypt_existing_data, migrations.RunPython.noop),
]
O problema: essa migration é efetivamente irreversível. Não tem como desfazer — uma vez que os registros estão criptografados, o noop na reversão não restaura nada. Se você precisar reverter por qualquer motivo, vai precisar de um backup anterior.
O que ainda não está resolvido
A rotação de chaves tem um plano mas não tem automação. A lista no SALT_KEY resolve o problema durante a transição gradual, mas o script que reencripta os registros antigos com a nova chave para poder remover a antiga ainda não existe. Está no backlog.
A busca por email e CPF. Hoje a busca de pacientes usa nome. Se alguém precisar buscar por CPF, o sistema não faz isso com eficiência — teria que carregar todos os registros da clinic e comparar na memória. Para um sistema com muitos pacientes, isso não escala.
Auditoria de acesso a campos decifrados. O django-simple-history loga alterações no ORM, mas não loga leituras. Não tenho como saber, por exemplo, quantas vezes o CPF de um determinado paciente foi acessado e por quem. Isso seria relevante para conformidade com a LGPD, mas implementar exige uma camada adicional.
Quando faz sentido implementar
Se você trabalha com dados de saúde, dados financeiros, ou qualquer dado que a LGPD classifica como sensível (saúde, biometria, orientação sexual, convicções religiosas, origem racial), field-level encryption tem custo de implementação baixo e benefício mensurável no caso de vazamento de dados em repouso.
Se você trabalha com dados menos sensíveis e o modelo de ameaça principal é invasão ativa, field-level encryption provavelmente não é o que vai te proteger — um atacante com acesso à aplicação em execução pode extrair dados decifrados pela API. Controles de acesso e autenticação são mais relevantes nesses casos.
Para o vetor específico de erro operacional — backup sem criptografia, snapshot público por descuido, dump para um banco de desenvolvimento, log verboso — field-level encryption é uma camada de defesa que vale o trade-off.
Referências
- django-fernet-encrypted-fields no GitHub (Jazzband)
- Especificação do Fernet — detalha o formato do token, o uso de AES-128-CBC e HMAC-SHA256
- LGPD Art. 11 — dados sensíveis
- AWS S3 Server-Side Encryption com KMS
Estou construindo o ByDoctor (bydoctor.com.br) — sistema de gestão para clínicas médicas com prontuário eletrônico, agendamento e prescrição digital. Se você trabalha com dados sensíveis em produção e tiver uma abordagem diferente ou quiser discutir as escolhas acima, deixa um comentário.