# Como criptografar CPF no Laravel sem perder a busca: encrypted cast e blind index

> Cast encrypted protege o CPF no banco, mas impede WHERE e índice único. Veja como resolver com blind index via HMAC, chave separada e rotação sem downtime.

Por Lucas Roberto · Laravel · publicado em 2026-10-15 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/como-criptografar-cpf-no-laravel-sem-perder-a-busca-encrypted-cast-e-blind-index

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.

```php
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:

```php
$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.

```php
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:

```php
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:

```php
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:

```php
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](/blog/jobs-disparados-dentro-de-transacao-o-bug-silencioso-do-aftercommit-no-laravel).
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](/blog/prompt-injection-e-lgpd-em-agentes-de-ia-no-laravel-como-proteger-dados-e-acoes).
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](/blog/conciliacao-de-pagamentos-no-laravel-como-bater-o-extrato-do-provedor-com-o-seu-banco-todo-dia).

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:

```php
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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
