← Voltar ao blog

Validar CPF em Ruby on Rails: mod-11 e RSpec do zero

·12 min de leitura

O CPF tem 11 dígitos e dois dígitos verificadores calculados via módulo 11. Sem entender essa mecânica, qualquer implementação de validação é frágil. Este artigo parte do algoritmo e chega em produção: validator ActiveRecord, testes RSpec completos e coluna criptografada com LGPD em mente.

O algoritmo mod-11 define o que é um CPF válido

Estrutura do CPF: 9 dígitos base + 2 dígitos verificadores

Um CPF formatado segue o padrão XXX.XXX.XXX-DD, onde os 9 primeiros dígitos são o número base e DD são os dois dígitos verificadores. A Receita Federal define essa estrutura na Instrução Normativa RFB 1.548/2015.

Cálculo do primeiro dígito verificador (pesos 10..2)

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

Posiçãod1d2d3d4d5d6d7d8d9
Peso1098765432

Cálculo do segundo dígito verificador (pesos 11..2)

O segundo dígito usa os 9 dígitos base mais o primeiro verificador, com pesos de 11 a 2. A mesma regra do resto se aplica: resto menor que 2 resulta em 0, caso contrário 11 - resto.

Regra de rejeição: CPFs com todos os dígitos iguais

000.000.000-00, 111.111.111-11 e variantes passam no cálculo mod-11 matematicamente, mas são rejeitados pela Receita Federal como inválidos. Seu validador precisa checar isso antes de calcular os dígitos verificadores.

Implementação em Ruby puro antes de tocar no Rails

Método valid_cpf?(cpf) sem dependências externas

def valid_cpf?(cpf)
  digits = cpf.to_s.gsub(/\D/, '')

  return false unless digits.length == 11
  return false if digits.chars.uniq.length == 1

  calc_digit = lambda do |slice, weight_start|
    sum = slice.chars.each_with_index.sum do |d, i|
      d.to_i * (weight_start - i)
    end
    remainder = sum % 11
    remainder < 2 ? 0 : 11 - remainder
  end

  first  = calc_digit.call(digits[0..8], 10)
  second = calc_digit.call(digits[0..9], 11)

  digits[9].to_i == first && digits[10].to_i == second
end

Normalização da entrada: strip, remover pontuação, garantir 11 chars

A linha digits = cpf.to_s.gsub(/\D/, '') cobre todos os casos: nil vira string vazia, pontuação é removida, espaços também. to_s evita NoMethodError sem precisar tratar nil separadamente.

Retorno explícito de true/false sem exceptions no caminho feliz

O método retorna false para entradas inválidas, nunca levanta exceção. Exceptions ficam reservadas para erros inesperados de sistema. Validação de formato é fluxo normal, não excepcional.

Cobertura com RSpec: casos que derrubam implementações ingênuas

CPFs inválidos: dígito errado, sequência repetida, tamanho errado

RSpec.describe '#valid_cpf?' do
  shared_examples 'rejeita cpf invalido' do |cpf|
    it "rejeita #{cpf.inspect}" do
      expect(valid_cpf?(cpf)).to be false
    end
  end

  context 'entradas validas' do
    it 'aceita CPF com pontuacao' do
      expect(valid_cpf?('529.982.247-25')).to be true
    end

    it 'aceita CPF sem pontuacao' do
      expect(valid_cpf?('52998224725')).to be true
    end
  end

  context 'sequencias repetidas' do
    %w[00000000000 11111111111 99999999999].each do |cpf|
      include_examples 'rejeita cpf invalido', cpf
    end
  end

  context 'digito verificador errado' do
    include_examples 'rejeita cpf invalido', '529.982.247-26'
  end

  context 'tamanho errado' do
    include_examples 'rejeita cpf invalido', '5299822472'
    include_examples 'rejeita cpf invalido', '529982247250'
  end

  context 'entradas sujas' do
    include_examples 'rejeita cpf invalido', nil
    include_examples 'rejeita cpf invalido', ''
    include_examples 'rejeita cpf invalido', '   '
    include_examples 'rejeita cpf invalido', '123.456.789-09'
  end
end

Estrutura de shared_examples para reutilizar em validadores futuros

O shared_examples 'rejeita cpf invalido' pode ser incluído no spec do validador de CNPJ quando você implementar validação de CNPJ. A estrutura mod-11 é a mesma, só os pesos mudam.

Integrar o validador como ActiveModel::EachValidator

Criar app/validators/cpf_validator.rb

class CpfValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if valid_cpf?(value)

    record.errors.add(attribute, :invalid_cpf)
  end

  private

  def valid_cpf?(cpf)
    digits = cpf.to_s.gsub(/\D/, '')
    return false unless digits.length == 11
    return false if digits.chars.uniq.length == 1

    calc_digit = lambda do |slice, weight_start|
      sum = slice.chars.each_with_index.sum { |d, i| d.to_i * (weight_start - i) }
      remainder = sum % 11
      remainder < 2 ? 0 : 11 - remainder
    end

    first  = calc_digit.call(digits[0..8], 10)
    second = calc_digit.call(digits[0..9], 11)
    digits[9].to_i == first && digits[10].to_i == second
  end
end

Usar validates :cpf, cpf: true no model

class User < ApplicationRecord
  validates :cpf, presence: true, cpf: true
end

Rails infere CpfValidator a partir do símbolo :cpf. Não precisa de require explícito se o arquivo estiver em app/validators/.

Mensagem de erro localizada via config/locales/pt-BR.yml

pt-BR:
  errors:
    messages:
      invalid_cpf: "não é um CPF válido"
  activerecord:
    attributes:
      user:
        cpf: "CPF"

Testar o validator no nível de model com RSpec + FactoryBot

Factory com CPF válido gerado sinteticamente

FactoryBot.define do
  factory :user do
    name  { Faker::Name.name }
    email { Faker::Internet.email }
    cpf   { CpfFactory.generate }
  end
end

CpfFactory.generate é implementado na seção de geração sintética abaixo. Não dependa de dados externos ou serviços em factories.

Teste de persistência: create(:user, cpf: cpf_invalido) deve falhar

RSpec.describe User, type: :model do
  it 'é invalido com CPF incorreto' do
    user = build(:user, cpf: '111.111.111-11')
    expect(user).not_to be_valid
    expect(user.errors[:cpf]).to include('não é um CPF válido')
  end

  it 'nao persiste com CPF invalido' do
    expect { create(:user, cpf: '000.000.000-00') }.to raise_error(ActiveRecord::RecordInvalid)
  end

  it 'persiste com CPF valido' do
    expect { create(:user) }.not_to raise_error
  end
end

Sanitização e normalização no before_validation

before_validation :normalize_cpf remove a máscara antes de validar

class User < ApplicationRecord
  before_validation :normalize_cpf
  validates :cpf, presence: true, cpf: true

  private

  def normalize_cpf
    self.cpf = cpf.to_s.gsub(/\D/, '') if cpf.present?
  end
end

Armazenar apenas os 11 dígitos no banco (sem pontuação)

Armazenar 52998224725 em vez de 529.982.247-25 simplifica consultas SQL, índices e comparações. A formatação é responsabilidade da camada de apresentação.

Apresentar formatado na view com helper cpf_format

module ApplicationHelper
  def cpf_format(raw)
    return '' if raw.blank?
    digits = raw.gsub(/\D/, '')
    return raw unless digits.length == 11
    "#{digits[0..2]}.#{digits[3..5]}.#{digits[6..8]}-#{digits[9..10]}"
  end
end

Na view: <%= cpf_format(user.cpf) %>.

Unicidade e índice no banco de dados

Migration com add_index :users, :cpf, unique: true

class AddUniqueIndexToUsersCpf < ActiveRecord::Migration[7.2]
  def change
    add_index :users, :cpf, unique: true
  end
end

Sem esse índice, validates :cpf, uniqueness: true faz um SELECT antes de cada INSERT, o que é lento e sujeito a race conditions em produção.

validates :cpf, uniqueness: true e race conditions

AVISO: validates :cpf, uniqueness: true protege contra duplicatas no nível da aplicação, mas não no banco. Com concorrência, dois processos podem passar pela validação simultaneamente antes de qualquer um persistir. O índice UNIQUE no banco é a única garantia real. Trate ActiveRecord::RecordNotUnique no controller.

validates :cpf, uniqueness: { scope: :tenant_id } em apps multi-tenant

Em SaaS multi-tenant, o mesmo CPF pode existir em tenants diferentes. O scope garante unicidade dentro de um tenant. O índice correspondente fica em add_index :users, [:cpf, :tenant_id], unique: true.

Considerações LGPD ao armazenar CPF

CPF é dado pessoal sob LGPD Art. 5º, I

O CPF identifica uma pessoa natural de forma direta. Isso o classifica como dado pessoal na Lei 13.709/2018 (LGPD), Art. 5º, I. O tratamento exige base legal explícita: Art. 7º, V (execução de contrato) ou Art. 7º, IX (legítimo interesse, desde que documentado).

Criptografar em repouso: attr_encrypted ou coluna cifrada no PostgreSQL 16

# Gemfile
gem 'attr_encrypted', '~> 4.0'

# model
class User < ApplicationRecord
  attr_encrypted :cpf,
                 key: Rails.application.credentials.cpf_encryption_key,
                 encode: true,
                 encode_iv: true
end

PostgreSQL 16 também suporta colunas bytea com criptografia via pgcrypto. A escolha depende de onde você prefere gerenciar chaves.

Nunca logar CPF em texto plano

# config/application.rb
config.filter_parameters += [:cpf, :cpf_confirmacao]

Isso garante que o CPF apareça como [FILTERED] nos logs do Rails. Revise também qualquer gem de APM (Datadog, New Relic, Sentry) que capture parâmetros de request.

Gerar CPFs sintéticos para o ambiente de testes

Por que não usar CPFs reais em fixtures

Usar CPFs reais em seeds, factories ou commits de teste viola o princípio de minimização de dados da LGPD Art. 6º, III. Além disso, repositórios públicos com dados pessoais reais expõem a empresa a notificações da ANPD.

Implementar CpfFactory.generate em Ruby

module CpfFactory
  def self.generate
    base = Array.new(9) { rand(0..9) }

    calc = lambda do |digits, weight_start|
      sum = digits.each_with_index.sum { |d, i| d * (weight_start - i) }
      remainder = sum % 11
      remainder < 2 ? 0 : 11 - remainder
    end

    first  = calc.call(base, 10)
    second = calc.call(base + [first], 11)

    (base + [first, second]).join
  end
end

Alternativa via API REST do FakeForge

Para gerar lotes maiores sem escrever código, use o endpoint da API do FakeForge:

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

A resposta inclui 50 CPFs válidos prontos para seeds ou fixtures. O gerador de CPF na interface web cobre casos de uso manuais durante o desenvolvimento.

DICA: Se você precisar de CPFs correlacionados com nomes, endereços e e-mails para um seed mais realista, o tipo pessoa retorna todos esses campos em um único objeto coerente. Confira o gerador de pessoa para ver a estrutura de saída.

Benchmark: validação em lote sem degradar a requisição

Custo de valid_cpf? é O(1) -- seguro em callbacks

O algoritmo faz exatamente 20 multiplicações e 2 divisões, independente da entrada. A validação fica abaixo de 1 microssegundo por chamada em hardware moderno. Usar em before_validation ou validate não introduz latência mensurável em requisições web normais.

Quando mover validação para job: importação CSV com 10k+ registros

Se você importa planilhas de clientes com dezenas de milhares de linhas, valide fora da requisição HTTP. Processe em um ActiveJob, retorne um relatório de erros assíncrono e evite timeouts do Puma.

Padrão com Dry::Validation para pipelines mais complexos

require 'dry-validation'

class UserContract < Dry::Validation::Contract
  params do
    required(:cpf).filled(:string)
  end

  rule(:cpf) do
    key.failure('não é um CPF válido') unless valid_cpf?(values[:cpf])
  end
end

Dry::Validation separa contratos de dados dos models ActiveRecord, o que facilita reuso em workers, importadores e APIs externas que não passam pelo fluxo Rails MVC.

Resumo

  • Implemente valid_cpf? em Ruby puro sem gems: normalize com gsub(/\D/, ''), rejeite sequências repetidas e calcule os dois dígitos verificadores via mod-11 com os pesos corretos.
  • Crie app/validators/cpf_validator.rb herdando de ActiveModel::EachValidator, use validates :cpf, cpf: true no model e adicione before_validation :normalize_cpf para remover pontuação antes de validar.
  • Coloque add_index :users, :cpf, unique: true na migration e trate ActiveRecord::RecordNotUnique no controller. A validação do Rails não previne race conditions, o índice previne.
  • Configure config.filter_parameters += [:cpf] e documente a base legal (LGPD Art. 7º, V ou IX) antes de ir para produção. Considere criptografia em repouso com attr_encrypted ou pgcrypto.
  • Use CpfFactory.generate ou a API do FakeForge para fixtures sintéticas. Nunca use CPFs reais em código de teste -- isso viola LGPD Art. 6º, III e expõe o repositório.
  • O próximo passo natural é aplicar a mesma estrutura para validar CNPJ: o algoritmo mod-11 é idêntico, com dois ciclos de pesos em vez de um.

Perguntas frequentes

Como capturar e comunicar erro quando um CPF duplicado é enviado?+

Use `rescue ActiveRecord::RecordNotUnique` no controller. O índice `UNIQUE` levanta essa exceção antes do Rails acabar a transação. Retorne HTTP 422 com mensagem clara: `{ error: "CPF já cadastrado" }`. Isso protege contra race conditions onde dois requests simultâneos passam pela validação Rails mas só um consegue persistir.

Tenho 100k usuários sem validação. Como adiciono validação de CPF sem quebrar?+

Crie uma migration que executa `UPDATE users SET cpf = TRIM(cpf)` e remove duplicatas mantendo o registro mais antigo. Depois adicione o validador com `validates :cpf, cpf: true, allow_blank: true`. Rode em background job para não travar. Monitore erros, corrija CPFs malformados manualmente, depois mude para `allow_blank: false`.

Na API, devo sempre normalizar CPF ou deixar o cliente fazer?+

Normalize no servidor via `before_validation`. Clientes sempre enviam formatos sujos: pontos, hífens, espaços. Tratá-los no model garante consistência independente da origem (API, form web, import CSV). Armazene normalizado no banco. Na view/resposta JSON, formate com helper: `cpf_format(user.cpf)`.

Posso usar o mesmo CpfValidator em User, Customer e Supplier?+

Sim. O validador é agnóstico ao model — só testa o formato do CPF. Se cada model tem `validates :cpf, cpf: true`, reutiliza `CpfValidator` automaticamente. Para lógica mais específica (ex: scoped por tenant), deixe no model com `before_validation`. O validator fica simples, focado no algoritmo mod-11.

Validar CPF contra o servidor da Receita Federal depois de passar na validação local?+

Não faça rotineiramente — a Receita Federal não expõe API pública de validação. CPFs válidos localmente (mod-11 + rejeição de sequências repetidas) são suficientes para testes. Para onboarding real com compliance, integre com serviços third-party como Serpro ou Brunnsys, mas fora do ciclo de requisição HTTP. Cache o resultado por 24h.