Para criptografar CPF, CNPJ e outros dados sensíveis no Laravel sem perder a capacidade de buscar por eles, você combina duas colunas: uma com o valor cifrado pelo cast encrypted e outra com um blind index, um HMAC determinístico do valor normalizado. A busca e o índice único passam a usar o HMAC, e o valor original só é decifrado quando alguém realmente precisa lê-lo. Somando uma chave própria para dados pessoais e um plano de rotação, você protege o dado sem travar o produto.
Esse problema aparece cedo em qualquer fintech. Em conta digital PF e PJ você guarda CPF, CNPJ, data de nascimento, telefone, tokens de acesso a Open Finance e referências de KYC. Na primeira auditoria ou revisão de LGPD alguém pergunta: "se vazar um dump do PostgreSQL, o que fica exposto?". A resposta precisa ser "quase nada legível".
Por que o cast encrypted do Laravel quebra a busca por CPF?
O cast encrypted do Eloquent cifra o atributo com o Encrypter do Laravel. Por padrão ele usa AES-256-CBC com um IV (vetor de inicialização, o valor aleatório que torna cada cifragem única) gerado a cada gravação, mais um MAC. O resultado é uma string base64 diferente toda vez, mesmo para o mesmo CPF.
Isso é bom para segurança: quem olha o banco não consegue nem saber se dois clientes têm o mesmo documento. Mas tem três consequências práticas:
where('document', $cpf)nunca encontra nada, porque o texto cifrado gravado não é igual ao que você geraria agora.- Um índice
uniquena coluna não impede duplicidade. Dois cadastros com o mesmo CPF geram dois textos cifrados distintos. LIKE, ordenação e qualquer operação no banco sobre o valor deixam de fazer sentido.
Em um fluxo de onboarding, a regra "um CPF, uma conta" é básica. Então só ligar o cast e seguir em frente não serve.
Quais campos vale a pena criptografar em uma fintech?
Criptografar tudo parece mais seguro, mas custa CPU em cada leitura, impede relatórios no banco e complica o suporte. Eu separo por necessidade de busca:
| Campo | Criptografar? | Precisa buscar? | Estratégia |
|---|---|---|---|
| CPF / CNPJ | Sim | Igualdade exata | encrypted + blind index |
| Data de nascimento | Sim | Raramente | Só encrypted |
| Telefone | Sim | Igualdade (login, WhatsApp) | encrypted + blind index |
| Token de Open Finance (Pluggy) | Sim | Não | Só encrypted |
| Nome | Depende | Busca parcial no backoffice | Normalmente em claro, com controle de acesso |
| Status, valores, datas de transação | Não | Sim, agregações | Em claro |
O nome é o caso chato. Busca parcial em dado cifrado não tem solução boa sem infraestrutura extra, e o backoffice precisa achar "Maria Silva". Prefiro deixar em claro e investir em controle de acesso e auditoria de consultas.
O que é blind index e por que precisa ser HMAC, não SHA-256?
Blind index é uma coluna derivada que guarda um hash determinístico do valor sensível: o mesmo CPF sempre gera o mesmo hash, então dá para indexar e buscar por igualdade sem guardar o CPF legível.
O erro clássico é usar hash('sha256', $cpf). CPF tem 11 dígitos, dois deles verificadores. São cerca de um bilhão de combinações válidas, e qualquer GPU calcula SHA-256 disso tudo em pouco tempo. Com o dump do banco em mãos, um atacante reverte todos os hashes. Hash simples de CPF é só um atraso.
A saída é HMAC (hash com chave secreta) usando uma chave que não está no banco e não é a mesma que cifra o dado. Sem a chave, o atacante não consegue montar a tabela de pré-computação, mesmo conhecendo o espaço de valores.
namespace App\Support\Security; final class BlindIndex { public function __construct(private readonly string $key) {} public function for(#[\SensitiveParameter] string $value): string { return hash_hmac('sha256', self::normalize($value), $this->key); } public static function normalize(string $value): string { return preg_replace('/\D/', '', $value); } }
A normalização é obrigatória. 123.456.789-09 e 12345678909 precisam gerar o mesmo índice, senão a busca falha de forma intermitente e ninguém entende o motivo. O atributo #[\SensitiveParameter] (PHP 8.2) troca o valor por um placeholder em stack traces, o que evita CPF em log de exceção.
No AppServiceProvider, registro o serviço com uma chave dedicada:
$this->app->singleton(BlindIndex::class, fn () => new BlindIndex( base64_decode(config('security.blind_index_key')) ));
Como montar a tabela e o model com encrypted e blind index?
A coluna cifrada precisa ser text: o payload do Encrypter (IV, valor, MAC, tudo em JSON e base64) é bem maior que 11 caracteres.
Schema::create('customers', function (Blueprint $table) { $table->id(); $table->string('name'); $table->text('document'); $table->char('document_index', 64)->unique(); $table->char('document_last4', 4); $table->timestamps(); });
No model, o blind index é calculado sempre que o documento muda:
class Customer extends Model { protected $hidden = ['document', 'document_index']; protected function casts(): array { return ['document' => 'encrypted']; } protected static function booted(): void { static::saving(function (Customer $customer) { if (! $customer->isDirty('document')) { return; } $digits = BlindIndex::normalize($customer->document); $customer->document = $digits; $customer->document_index = app(BlindIndex::class)->for($digits); $customer->document_last4 = substr($digits, -4); }); } public function scopeWhereDocument(Builder $query, string $document): void { $query->where('document_index', app(BlindIndex::class)->for($document)); } }
Alguns detalhes que importam:
- O
isDirtyfunciona com castencryptedporque o Eloquent compara os valores decifrados, então reler e salvar o model sem mudar o CPF não recalcula nada. - O
uniqueemdocument_indexvolta a garantir "um CPF, uma conta" no banco, não só na validação. Na regra de validação, usoRule::unique('customers', 'document_index')passando o HMAC, ou faço a checagem no service de cadastro. $hiddenimpede que o documento vaze emtoArray(), em respostas JSON e em logs que serializam o model.document_last4em claro é uma concessão consciente para o suporte: "confirme os quatro últimos dígitos". É menos informação, mas é informação. Se não precisar, não crie.
A busca fica Customer::whereDocument($request->cpf)->first(), e o PostgreSQL usa o índice normalmente.
Devo usar a APP_KEY ou uma chave separada para dados pessoais?
Por padrão, o cast encrypted usa a APP_KEY, a mesma que cifra cookies e sessões. Isso acopla duas coisas com ciclos de vida bem diferentes: girar a chave de sessão é trivial (no máximo desloga todo mundo), girar a chave de dados pessoais exige recifrar a base inteira.
O Eloquent permite trocar o encrypter usado pelos casts com Model::encryptUsing(). Registro um encrypter próprio no container e uso o mesmo em todo lugar:
use Illuminate\Encryption\Encrypter; use Illuminate\Database\Eloquent\Model; $this->app->singleton('encrypter.pii', function () { $encrypter = new Encrypter( base64_decode(config('security.pii_key')), 'aes-256-gcm' ); return $encrypter->previousKeys(array_map( 'base64_decode', array_filter(explode(',', (string) config('security.pii_previous_keys'))) )); }); Model::encryptUsing($this->app->make('encrypter.pii'));
Fica uma chave para cada função:
APP_KEY: sessões, cookies eCrypt::genérico.PII_KEY: cifragem dos atributos de dados pessoais.BLIND_INDEX_KEY: HMAC dos índices de busca.
As três ficam fora do repositório e fora do banco, no gerenciador de segredos do provedor de nuvem ou no mínimo em variável de ambiente injetada no deploy. Se a chave estiver na mesma máquina do banco com o mesmo nível de acesso, a criptografia protege contra vazamento de dump e backup, não contra invasão do servidor. Ainda vale a pena, mas tenha clareza do modelo de ameaça.
Como rotacionar a chave de criptografia sem downtime?
O previousKeys() do Encrypter resolve metade do problema: o Laravel tenta decifrar com a chave atual e, se o MAC não bater, tenta as anteriores. Novas gravações já saem com a chave nova. Então o passo a passo é:
- Gere a nova chave e mova a atual para
PII_PREVIOUS_KEYS. - Faça o deploy. A aplicação lê os dados antigos e grava com a chave nova.
- Rode um comando que recifre os registros antigos em lotes.
- Quando terminar, remova a chave antiga da configuração.
O passo 3 tem uma pegadinha: $customer->save() não faz nada, porque o valor decifrado é o mesmo e o atributo não fica dirty. Escrevo direto pela query builder:
public function handle(): void { $encrypter = app('encrypter.pii'); Customer::query()->chunkById(500, function ($customers) use ($encrypter) { foreach ($customers as $customer) { DB::table('customers') ->where('id', $customer->id) ->update(['document' => $encrypter->encryptString($customer->document)]); } }); }
O cast encrypted cifra sem serializar, por isso encryptString gera exatamente o formato que o cast espera. chunkById em vez de chunk evita pular registros enquanto a tabela muda.
Rotacionar a chave do blind index é mais trabalhoso, porque não existe "chave anterior" para HMAC: os índices antigos simplesmente param de bater. Faço em duas fases: crio document_index_v2, preencho decifrando cada documento, passo o saving a gravar as duas colunas, troco o escopo de busca para a v2 e só então removo a coluna antiga. Como isso é caro, a chave do blind index eu só giro se houver suspeita de vazamento.
Onde o dado sensível ainda vaza mesmo com o banco cifrado?
Cifrar a coluna é a parte fácil. Os vazamentos reais costumam vir dos caminhos laterais:
- Payload de jobs. Se você passa o CPF como argumento de um job, ele vai em claro para o Redis e, se falhar, para a tabela
failed_jobs. Passar o model resolve em boa parte (oSerializesModelsguarda só o ID), mas quando o job precisa do valor implementeShouldBeEncrypted, que cifra o payload inteiro na fila. Esse cuidado conversa com outro problema de jobs que já descrevi em jobs disparados dentro de transação e o afterCommit. - Logs e APM.
Log::info('cadastro', $request->all())desfaz todo o trabalho. Filtre campos sensíveis no ponto de log e configure o scrubbing do Sentry ou equivalente. - Telescope e ferramentas de debug. Em produção, se estiver ligado, ele grava requests e queries. Use
Telescope::hideRequestParameters()ou deixe desligado. - Contexto enviado a LLM. Se você manda dados de cliente para um agente de IA, a cifragem no banco não ajuda nada depois que o texto saiu. Tratei disso em prompt injection e LGPD em agentes de IA no Laravel.
- Exports e relatórios. O CSV do backoffice decifra tudo. Restrinja por permissão e registre quem exportou.
Quanto custa em performance decifrar em toda leitura?
O cast decifra no acesso ao atributo. Em uma tela que lista 50 clientes e mostra o CPF mascarado, são 50 decifragens AES por request, o que é desprezível. O problema aparece em jobs batch que leem centenas de milhares de linhas e acessam o atributo sem necessidade, como uma conciliação noturna. Nesses casos selecione só as colunas necessárias e não toque no campo cifrado. Se o processo precisa casar registros por documento, use o document_index como chave de junção. É exatamente o tipo de cuidado que entra numa rotina de conciliação de pagamentos que bate o extrato do provedor com o banco.
Trocar CBC por GCM, como no exemplo, não muda muito a performance, mas dá cifragem autenticada nativa. Se trocar o cipher numa base existente, lembre que os dados antigos foram cifrados com outro algoritmo: o previousKeys usa o cipher do encrypter atual, então essa migração precisa de um encrypter dedicado para leitura no comando de recifragem.
Como testar que o dado está realmente cifrado?
Um teste que só faz assertEquals($cpf, $customer->document) passa com ou sem criptografia. O teste útil olha o banco cru:
it('grava o CPF cifrado e encontra pelo blind index', function () { $customer = Customer::create(['name' => 'Ana', 'document' => '123.456.789-09']); $raw = DB::table('customers')->where('id', $customer->id)->value('document'); expect($raw)->not->toContain('12345678909') ->and(Customer::whereDocument('12345678909')->first()->id)->toBe($customer->id) ->and(fn () => Customer::create(['name' => 'Outra', 'document' => '12345678909'])) ->toThrow(UniqueConstraintViolationException::class); });
Esse teste cobre as três garantias que importam: o valor não está legível no banco, a busca funciona com formatação diferente e o índice único continua barrando duplicidade.
Conclusão: por onde começar em um projeto que já está em produção
Se a base já tem CPF em claro, eu seguiria esta ordem: primeiro crie a chave de PII separada e o BlindIndex; depois adicione document_index como coluna nula, preencha em lotes e só então troque as buscas e o unique para ela. A cifragem da coluna original vem por último, com um comando parecido com o de rotação, convertendo a coluna para text antes. Em paralelo, faça uma passada em logs, payloads de fila e Telescope, porque é ali que o dado costuma escapar. E documente as três chaves e o processo de rotação no runbook antes de precisar dele: rotação de chave feita às pressas, depois de um incidente, é onde se perde dado de verdade.