← Voltar ao blog

Validar CPF em Java Spring Boot: algoritmo mod-11 e JUnit

·12 min de leitura

A Receita Federal define o CPF como uma sequência de 11 dígitos onde os dois últimos são verificadores calculados por mod-11. Qualquer validação que não reimplemente esse cálculo falha em produção quando recebe CPFs mascarados, strings Unicode ou sequências repetidas. Este tutorial cobre o algoritmo completo, a integração com Bean Validation no Spring Boot e a cobertura de testes com JUnit 5.

O algoritmo mod-11 do CPF: como a Receita Federal calcula os dígitos verificadores

Estrutura de um CPF: 9 dígitos base + 2 verificadores

Um CPF tem formato NNN.NNN.NNN-DD, onde os 9 primeiros dígitos são o número base e DD são os dois dígitos verificadores. A validação acontece em dois passos independentes, cada um gerando um verificador.

Primeiro dígito: pesos de 10 a 2

Multiplique cada um dos 9 dígitos base pelos pesos de 10 a 2 (em ordem). Some os produtos. Calcule resto = soma % 11. Se resto < 2, o primeiro verificador é 0. Caso contrário, é 11 - resto.

Segundo dígito: pesos de 11 a 2 (inclui o primeiro verificador)

Repita o processo usando os 9 dígitos base mais o primeiro verificador já calculado, com pesos de 11 a 2. O segundo verificador segue a mesma regra do resto.

Regra do resto menor que 2 (resultado = 0)

Essa regra existe porque a Receita Federal optou por representar restos 0 e 1 como o dígito 0, evitando verificadores negativos ou em dois dígitos. É a fonte mais comum de bugs em implementações de terceiros.

CPFs com todos os dígitos iguais: inválidos por definição, não pelo mod-11

111.111.111-11 passa no algoritmo matemático. A Receita Federal os rejeita por definição administrativa. Seu código precisa checar isso explicitamente antes de rodar o mod-11.

Implementando o validador CPF puro em Java (sem frameworks)

Método isValidCpf(String cpf): limpeza, tamanho, dígitos iguais, dois loops

public final class CpfValidator {

    private CpfValidator() {}

    public static boolean isValidCpf(String cpf) {
        if (cpf == null) return false;

        String digits = cpf.replaceAll("[^0-9]", "");

        if (digits.length() != 11) return false;

        // Rejeita sequências uniformes (000...000, 111...111, etc.)
        if (digits.chars().distinct().count() == 1) return false;

        return checkDigit(digits, 10) && checkDigit(digits, 11);
    }

    private static boolean checkDigit(String digits, int weight) {
        int sum = 0;
        int limit = weight - 1; // 9 ou 10 dígitos a processar
        for (int i = 0; i < limit; i++) {
            sum += Character.getNumericValue(digits.charAt(i)) * (weight - i);
        }
        int remainder = sum % 11;
        int expected = (remainder < 2) ? 0 : 11 - remainder;
        return expected == Character.getNumericValue(digits.charAt(limit));
    }
}

Tratando entradas com máscara (000.000.000-00) e sem máscara

O replaceAll("[^0-9]", "") remove pontos, traços e qualquer outro caractere não numérico antes de qualquer cálculo. Isso cobre CPFs mascarados e não mascarados sem lógica extra.

Por que não usar Long.parseLong para CPF (zero à esquerda)

CPFs iniciados em zero, como 001.234.567-89, perdem o zero se convertidos para long. Trabalhe sempre com String e acesse cada caractere individualmente via charAt ou getNumericValue.

Cobertura de testes com JUnit 5 antes de integrar ao Spring

Partições de equivalência: válidos, inválidos, nulos, vazios, mascarados

Antes de qualquer integração, o algoritmo puro precisa passar em todas as partições de equivalência. Não há valor em subir o Spring para testar uma função que só recebe String.

CPFs "famosos" de teste

444.444.444-44 é inválido (sequência uniforme). Para testes, use CPFs gerados deterministicamente. O gerador de CPF do FakeForge gera CPFs válidos que você pode usar como fixtures confiáveis.

Anotação @ParameterizedTest com @CsvSource para 12 casos em 8 linhas

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class CpfValidatorTest {

    @ParameterizedTest
    @CsvSource({
        "529.982.247-25, true",   // CPF válido com máscara
        "52998224725,    true",   // CPF válido sem máscara
        "111.111.111-11, false",  // sequência uniforme
        "000.000.000-00, false",  // todos zeros
        "123.456.789-09, false",  // dígito verificador errado
        "529.982.247-26, false",  // último dígito errado por 1
        ",               false",  // null (CSV trata como null)
        "'',             false",  // string vazia
        "529.982.247,    false",  // 10 dígitos apenas
        "5299822472599,  false",  // 13 dígitos
        "529.982.247-2A, false",  // letra no verificador
        "529 982 247 25, true"    // espaços como separador
    })
    void validaCpf(String cpf, boolean expected) {
        assertEquals(expected, CpfValidator.isValidCpf(cpf));
    }
}

Gerador de CPF de teste: /gerador-cpf como fonte de fixtures confiáveis

Para fixtures em quantidade, use a API REST do FakeForge:

curl "https://fakeforge.com.br/api/generate?type=cpf&quantity=20&format=json"

Os CPFs retornados passam no algoritmo mod-11. Você pode validar que o seu próprio isValidCpf concorda com a geração, criando um teste de sanidade cruzado.

Criando a anotação @CPF personalizada com Bean Validation

Dependência jakarta.validation-api no pom.xml

<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>3.0.2</version>
</dependency>
<!-- Hibernate Validator como implementação (já incluído via Spring Boot Starter Web) -->

Interface @CPF e classe CpfValidator

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = CpfConstraintValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CPF {
    String message() default "{br.com.suaapp.validation.cpf}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class CpfConstraintValidator implements ConstraintValidator<CPF, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        if (value == null || value.isBlank()) return false;
        return CpfValidator.isValidCpf(value);
    }
}

Retornando mensagens de erro localizadas via messages.properties

Crie src/main/resources/ValidationMessages.properties:

br.com.suaapp.validation.cpf=CPF invalido. Informe um CPF com 11 digitos validos.
DICA: Nomeie a chave com o pacote completo da aplicação para evitar colisão com outras dependências que usem o mesmo arquivo de mensagens.

Integrando a anotação ao Spring Boot: DTO, Controller e tratamento de erros

DTO, Controller e @ControllerAdvice

// DTO
public record CadastroRequest(
    @CPF
    @NotBlank
    String cpf,

    @NotBlank
    String nome
) {}

// Controller
@RestController
@RequestMapping("/api/cadastro")
public class CadastroController {

    @PostMapping
    public ResponseEntity<Void> cadastrar(@Valid @RequestBody CadastroRequest req) {
        // processamento
        return ResponseEntity.ok().build();
    }
}

// ControllerAdvice
@RestControllerAdvice
public class ValidationAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    public Map<String, Object> handleValidation(MethodArgumentNotValidException ex) {
        List<Map<String, String>> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(fe -> Map.of(
                "campo", fe.getField(),
                "mensagem", fe.getDefaultMessage()
            ))
            .toList();

        return Map.of(
            "status", 422,
            "erros", errors
        );
    }
}

O payload de erro retorna HTTP 422 com campo e mensagem, compatível com a maioria dos clientes frontend sem acoplamento ao formato de exceção do Spring.

Testando a integração com MockMvc

@WebMvcTest vs @SpringBootTest: qual usar para validação

@WebMvcTest carrega apenas a camada web (controllers, filters, advice). Para testar se a anotação @CPF dispara a mensagem certa e o status correto, @WebMvcTest é suficiente e mais rápido que @SpringBootTest.

@WebMvcTest(CadastroController.class)
class CadastroControllerTest {

    @Autowired
    MockMvc mvc;

    @Autowired
    ObjectMapper mapper;

    @Test
    void cpfInvalido_retorna422() throws Exception {
        var body = Map.of("cpf", "111.111.111-11", "nome", "Teste");

        mvc.perform(post("/api/cadastro")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(body)))
            .andExpect(status().isUnprocessableEntity())
            .andExpect(jsonPath("$.erros[0].campo").value("cpf"));
    }

    @Test
    void cpfValido_retorna200() throws Exception {
        var body = Map.of("cpf", "529.982.247-25", "nome", "Joao");

        mvc.perform(post("/api/cadastro")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(body)))
            .andExpect(status().isOk());
    }

    @Test
    void cpfComSqlInjection_retorna422SemException() throws Exception {
        var body = Map.of("cpf", "'; DROP TABLE usuarios; --", "nome", "Hack");

        mvc.perform(post("/api/cadastro")
                .contentType(MediaType.APPLICATION_JSON)
                .content(mapper.writeValueAsString(body)))
            .andExpect(status().isUnprocessableEntity());
    }
}

O teste de SQL injection confirma que strings maliciosas não geram exceção inesperada: o replaceAll("[^0-9]", "") reduz a string a dígitos antes de qualquer cálculo.

Edge cases que quebram implementações ingênuas

EntradaComportamento esperadoMotivo
000.000.000-00inválidoTodos zeros, regra administrativa
111.111.111-11 até 999.999.999-99inválidoSequências uniformes
529.982.247-25válidoPassa nos dois verificadores
\u0030\u0031\u0032...dependeCharacter.isDigit aceita dígitos Unicode
529 982 247 25válido ou rejeitarDecisão de negócio, documentar antes do deploy
529.982.247-2AinválidoLetra no campo numérico
AVISO: Character.isDigit retorna true para dígitos de outros scripts Unicode (árabe-indic, devanágari). O replaceAll("[^0-9]", "") usa apenas ASCII 0-9, então é seguro. Não substitua por Character.isDigit sem adicionar um filtro de range.

Performance: quando a validação vira gargalo

O algoritmo é O(1) por CPF

Dois loops de tamanho fixo (9 e 10 iterações). O gargalo em endpoints de lote não é a matemática: é o parsing de JSON e a alocação de strings.

Cache com ConcurrentHashMap para CPFs repetidos em lote

Em cadastros de lote onde o mesmo CPF aparece várias vezes:

private static final Map<String, Boolean> CACHE =
    new ConcurrentHashMap<>(1024);

public static boolean isValidCpfCached(String cpf) {
    return CACHE.computeIfAbsent(cpf, CpfValidator::isValidCpf);
}

Limite o tamanho do cache com Caffeine ou Guava em produção para evitar crescimento ilimitado.

Benchmark com JMH

Em hardware moderno, o algoritmo puro sem cache atinge facilmente 2 milhões de validações por segundo em single-thread. O custo real em produção é o overhead de serialização e rede, não o mod-11.

Validação server-side não substitui dados de teste válidos

Por que usar CPFs reais em testes é risco LGPD

A LGPD Art. 46 (Lei 13.709/2018) exige que dados pessoais sejam protegidos por medidas técnicas e administrativas. CPFs reais em repositórios, logs de teste ou bancos de desenvolvimento violam esse princípio mesmo que o titular tenha consentido para outra finalidade (LGPD Art. 7º, III).

Gerando CPFs válidos deterministicamente no setup do JUnit com seed fixo

Para suítes determinísticas sem dependência de rede, implemente um gerador com seed fixo:

@BeforeAll
static void geraFixtures() {
    // Gerador determinístico com seed fixo para reprodutibilidade
    Random rng = new Random(42L);
    fixtures = IntStream.range(0, 50)
        .mapToObj(i -> gerarCpfAleatorio(rng))
        .filter(CpfValidator::isValidCpf)
        .toList();
}

/api/generate?type=cpf&quantity=50&format=json como fonte de fixtures

Para fixtures em CI/CD sem lógica de geração no código de teste:

curl "https://fakeforge.com.br/api/generate?type=cpf&quantity=50&format=json" \
  -H "x-api-key: SUA_CHAVE"

O plano Dev (R$29/mês) cobre 10.000 chamadas diárias, suficiente para pipelines de CI com múltiplos jobs paralelos.

Validando que os fixtures do FakeForge passam no seu próprio isValidCpf

@Test
void fixturesFakeForgePassamNaValidacaoLocal() {
    fixtures.forEach(cpf ->
        assertTrue(CpfValidator.isValidCpf(cpf),
            "CPF do FakeForge falhou na validacao local: " + cpf)
    );
}

Esse teste cria um contrato bidirecional: se a API do FakeForge e o seu algoritmo discordarem, o teste falha imediatamente.

CNPJ segue a mesma lógica: reutilizando a estrutura

Diferenças: 14 dígitos, pesos diferentes, dois loops distintos

O CNPJ usa pesos [5,4,3,2,9,8,7,6,5,4,3,2] no primeiro loop e [6,5,4,3,2,9,8,7,6,5,4,3,2] no segundo. A estrutura do método checkDigit é reutilizável: basta parametrizar os pesos como array.

CNPJ Alfanumérico a partir de 01/07/2026

A Receita Federal publicou em 2024 a extensão do CNPJ para aceitar letras nas posições 1 a 12. O algoritmo mod-11 permanece, mas Character.getNumericValue já suporta letras A-Z como valores 10-35. Use o gerador de CNPJ alfanumérico para fixtures do novo formato antes de ajustar seu validador.

/gerador-cnpj para fixtures do formato atual

O gerador de CNPJ gera CNPJs no formato atual (14 dígitos numéricos) com verificadores corretos, prontos para uso em testes de cadastro de pessoa jurídica.

Resumo

  • Implemente isValidCpf com replaceAll("[^0-9]", ""), cheque sequências uniformes antes do mod-11 e use dois loops com pesos fixos. Não use Long.parseLong.
  • Cubra as 12 partições de equivalência com @ParameterizedTest e @CsvSource antes de subir o Spring.
  • Crie a anotação @CPF com ConstraintValidator e configure mensagens em ValidationMessages.properties. O @ControllerAdvice deve retornar HTTP 422 com campo e mensagem.
  • Use @WebMvcTest para testar a camada de validação. Inclua o caso de SQL injection para confirmar que o endpoint retorna 422 sem estourar exceção.
  • Nunca use CPFs reais em testes. Use o gerador de CPF ou a API REST para fixtures válidos e LGPD-safe.
  • O próximo passo natural é o validador de CNPJ com os mesmos pesos parametrizados e, a partir de julho de 2026, suporte ao CNPJ alfanumérico.

Perguntas frequentes

Devo validar CPF no frontend (JavaScript) ou apenas no backend?+

Frontend acelera UX com feedback imediato, bom para experiência. Implemente o mod-11 em JavaScript para isso. Porém, nunca confie na validação do cliente. Backend sempre revalida. Backend é a fonte da verdade. Qualquer request direto à API bypassa frontend, então validação no cliente é inútil para segurança. Use frontend para UX, backend para segurança.

Como logar CPFs rejeitados sem violar LGPD?+

Nunca logue o CPF na íntegra em logs. Logue apenas hash SHA-256 ou últimos 2 dígitos. Exemplo: `cpf_last_2: "25", cpf_hash: "abc123...def"`. Assim rastreia rejeições sem expor PII em logs, stack traces ou agregadores. Importante: destrua os hashes após 30 dias (retenção mínima para compliance).

Preciso cachear validações de CPF em lote com 10K itens?+

Sem cache, 10K CPFs em ~5ms (2M ops/seg). Gargalo real é I/O de banco. Cache (Caffeine) ajuda se o mesmo CPF aparece 10+ vezes no lote. Comece sem cache, meça latência com `System.nanoTime()`. Se > 50ms, adicione cache com TTL de 5 min. Benchmark real antes de otimizar é a regra.

Posso reusar a lógica de mod-11 para validar RG ou CNH?+

RG não tem algoritmo Federal padrão (varia por estado). CNH usa mod-11 como CPF, mas com 12 dígitos e pesos distintos (DENATRAN define). Parametrize `checkDigit()` para aceitar array de pesos. Documente qual documento usa qual sequência. Use enums: `enum DocType { CPF(9), CNH(12), ...}`. Assim escala sem duplicação.

Como gerar CPFs válidos para testes sem depender de API externa?+

Implemente gerador com seed fixo em `@BeforeAll`: `new Random(42L)` gera mesma sequência toda execução, determinística. Ou chame `/api/generate?type=cpf&quantity=50` antes do suite rodar. Cache a resposta em arquivo `fixtures.json`. Terceira opção: use [/gerador-cpf](/gerador-cpf) para 50 CPFs manualmente, commit no repo (LGPD-safe porque são sintéticos).