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:

  1. where('document', $cpf) nunca encontra nada, porque o texto cifrado gravado não é igual ao que você geraria agora.
  2. Um índice unique na coluna não impede duplicidade. Dois cadastros com o mesmo CPF geram dois textos cifrados distintos.
  3. 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 isDirty funciona com cast encrypted porque o Eloquent compara os valores decifrados, então reler e salvar o model sem mudar o CPF não recalcula nada.
  • O unique em document_index volta a garantir "um CPF, uma conta" no banco, não só na validação. Na regra de validação, uso Rule::unique('customers', 'document_index') passando o HMAC, ou faço a checagem no service de cadastro.
  • $hidden impede que o documento vaze em toArray(), em respostas JSON e em logs que serializam o model.
  • document_last4 em 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:

  1. APP_KEY: sessões, cookies e Crypt:: genérico.
  2. PII_KEY: cifragem dos atributos de dados pessoais.
  3. 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 é:

  1. Gere a nova chave e mova a atual para PII_PREVIOUS_KEYS.
  2. Faça o deploy. A aplicação lê os dados antigos e grava com a chave nova.
  3. Rode um comando que recifre os registros antigos em lotes.
  4. 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:

  1. 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 (o SerializesModels guarda só o ID), mas quando o job precisa do valor implemente ShouldBeEncrypted, 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.
  2. 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.
  3. Telescope e ferramentas de debug. Em produção, se estiver ligado, ele grava requests e queries. Use Telescope::hideRequestParameters() ou deixe desligado.
  4. 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.
  5. 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.