Pular para o conteúdo
📖 COOKBOOK

CPF e CNPJ validados no app e no banco.

O app confere os dígitos verificadores enquanto a pessoa digita, e o banco confere de novo num CHECK, que ninguém pula. Já aceita o CNPJ com letras que a Receita começou a emitir em julho de 2026. O documento fica numa tabela sua, protegida por RLS.

O que vamos fazer

  • Um campo único que aceita CPF ou CNPJ, com máscara enquanto a pessoa digita.
  • Validação dos dígitos verificadores no navegador, sem chamada de rede.
  • A mesma validação no Postgres, num CHECK: se o app for burlado, o banco recusa.
  • O documento numa tabela perfis sua, com RLS: cada pessoa lê e grava só o próprio.
ℹ️

O SuperDB não valida nem guarda CPF por conta própria. Não há campo de documento no cadastro, e o que vai em signUp({ options: { data } }) fica no user_metadata da conta, em texto aberto, e volta em todo getUser(). Não é lugar de documento: esta receita guarda numa tabela sua.

O CNPJ com letras (julho de 2026)

Desde julho de 2026, a Receita Federal emite CNPJ com letras nas 12 primeiras posições, como 12.ABC.345/01DE-35. Os dois dígitos verificadores continuam numéricos, e o cálculo continua o mesmo, com uma regra a mais: cada caractere vale o seu código ASCII menos 48. Assim 0 a 9 valem 0 a 9, como sempre, e A vale 17, B vale 18, até Z, que vale 42. Os CNPJs antigos, só com números, continuam válidos.

Validador que só aceita dígitos recusa as empresas novas. Os dois abaixo, em TypeScript e em SQL, já aceitam os dois formatos.

Passo 1: validar no app

src/lib/documento.ts
// Deixa só letras e dígitos, em maiúsculas: "12.abc.345/01de-35" vira "12ABC34501DE35".
export const normalizar = (valor: string) => valor.toUpperCase().replace(/[^0-9A-Z]/g, '')

export function cpfValido(valor: string): boolean {
  const d = normalizar(valor)
  if (!/^\d{11}$/.test(d) || /^(\d)\1{10}$/.test(d)) return false
  const dv = (n: number) => {
    let soma = 0
    for (let i = 0; i < n; i++) soma += Number(d[i]) * (n + 1 - i)
    return ((soma * 10) % 11) % 10
  }
  return dv(9) === Number(d[9]) && dv(10) === Number(d[10])
}

// CNPJ: 12 caracteres (letras ou dígitos) + 2 dígitos verificadores.
// Cada caractere vale o código ASCII menos 48: '0' vale 0, '9' vale 9, 'A' vale 17.
export function cnpjValido(valor: string): boolean {
  const c = normalizar(valor)
  if (!/^[0-9A-Z]{12}\d{2}$/.test(c) || /^(.)\1{13}$/.test(c)) return false
  const pesos = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
  const dv = (n: number) => {
    let soma = 0
    for (let i = 0; i < n; i++) soma += (c.charCodeAt(i) - 48) * pesos[i + 13 - n]
    const resto = soma % 11
    return resto < 2 ? 0 : 11 - resto
  }
  return dv(12) === Number(c[12]) && dv(13) === Number(c[13])
}

// Máscara enquanto digita: 000.000.000-00 ou 00.000.000/0000-00 (12.ABC.345/01DE-35).
export function formatar(valor: string): string {
  const c = normalizar(valor).slice(0, 14)
  const ehCpf = c.length <= 11 && /^\d*$/.test(c)
  const cortes = ehCpf ? [3, 6, 9, 11] : [2, 5, 8, 12, 14]
  const separadores = ehCpf ? ['.', '.', '-'] : ['.', '.', '/', '-']
  let saida = ''
  let inicio = 0
  cortes.forEach((fim, i) => {
    if (c.length > inicio) saida += (i ? separadores[i - 1] : '') + c.slice(inicio, fim)
    inicio = fim
  })
  return saida
}

O campo usa as três funções. O tipo sai do próprio valor: até 11 dígitos é CPF; passou disso, ou tem letra, é CNPJ.

src/CampoDocumento.tsx
import { useState, type FormEvent } from 'react'
import { cnpjValido, cpfValido, formatar, normalizar } from './lib/documento'
import { salvarDocumento } from './lib/perfil'

export function CampoDocumento() {
  const [valor, setValor] = useState('')
  const [tocado, setTocado] = useState(false)
  const [mensagem, setMensagem] = useState<string | null>(null)

  const doc = normalizar(valor)
  const tipo = /^\d{0,11}$/.test(doc) ? 'pf' : 'pj'
  const valido = tipo === 'pf' ? cpfValido(doc) : cnpjValido(doc)

  async function salvar(e: FormEvent) {
    e.preventDefault()
    const erro = await salvarDocumento(tipo, doc)
    setMensagem(erro ?? 'Documento salvo.')
  }

  return (
    <form onSubmit={salvar}>
      <label htmlFor="doc">CPF ou CNPJ</label>
      <input id="doc" value={valor} autoComplete="off" autoCapitalize="characters"
        onChange={(e) => { setValor(formatar(e.target.value)); setMensagem(null) }}
        onBlur={() => setTocado(true)} />
      {tocado && doc && !valido && <p role="alert">{tipo === 'pf' ? 'CPF' : 'CNPJ'} inválido</p>}
      <button disabled={!valido}>Salvar</button>
      {mensagem && <p>{mensagem}</p>}
    </form>
  )
}

Passo 2: validar no banco

Validação no navegador é conforto: quem quiser manda a requisição direto para a API. O que garante é o banco. Rode no SQL Editor do painel (pode rodar de novo sem medo):

superdb/perfis.sql
-- 1. Os validadores. Rodam no banco: o app pode ser burlado, o CHECK não.
create or replace function cpf_valido(doc text) returns boolean
language plpgsql immutable as $$
declare
  d text := regexp_replace(coalesce(doc, ''), '[^0-9]', '', 'g');
  soma int;
begin
  if length(d) <> 11 or d ~ '^(.)\1{10}$' then
    return false;
  end if;
  for n in 9..10 loop
    soma := 0;
    for i in 1..n loop
      soma := soma + substr(d, i, 1)::int * (n + 2 - i);
    end loop;
    if (soma * 10) % 11 % 10 <> substr(d, n + 1, 1)::int then
      return false;
    end if;
  end loop;
  return true;
end $$;

-- CNPJ: 12 caracteres (letras ou dígitos) + 2 dígitos verificadores.
-- Cada caractere vale o código ASCII menos 48: '0' vale 0, 'A' vale 17.
create or replace function cnpj_valido(doc text) returns boolean
language plpgsql immutable as $$
declare
  c text := upper(regexp_replace(coalesce(doc, ''), '[^0-9A-Za-z]', '', 'g'));
  pesos int[] := array[6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
  soma int;
  resto int;
begin
  if c !~ '^[0-9A-Z]{12}[0-9]{2}$' or c ~ '^(.)\1{13}$' then
    return false;
  end if;
  for n in 12..13 loop
    soma := 0;
    for i in 1..n loop
      soma := soma + (ascii(substr(c, i, 1)) - 48) * pesos[i + 13 - n];
    end loop;
    resto := soma % 11;
    if (case when resto < 2 then 0 else 11 - resto end) <> substr(c, n + 1, 1)::int then
      return false;
    end if;
  end loop;
  return true;
end $$;

-- 2. O documento mora numa tabela sua, com RLS: cada pessoa lê e grava só o próprio.
create table if not exists perfis (
  user_id uuid primary key default auth.uid(),
  tipo text not null check (tipo in ('pf', 'pj')),
  documento text not null,
  nome text,
  criado_em timestamptz not null default now(),
  constraint documento_valido check (
    (tipo = 'pf' and documento ~ '^[0-9]{11}$' and cpf_valido(documento))
    or (tipo = 'pj' and documento ~ '^[0-9A-Z]{12}[0-9]{2}$' and cnpj_valido(documento))
  )
);

-- Um documento, uma conta (leia o aviso sobre este índice logo abaixo).
create unique index if not exists perfis_documento_unico on perfis (documento);

alter table perfis enable row level security;
drop policy if exists perfis_do_dono on perfis;
create policy perfis_do_dono on perfis
  for all to authenticated
  using (user_id = auth.uid()) with check (user_id = auth.uid());
revoke all on perfis from anon;
grant select, insert, update on perfis to authenticated;
  • user_id com default auth.uid(): o app nunca manda o id, e a policy recusa gravar no nome de outra pessoa.
  • O CHECK exige o documento normalizado (só dígitos e letras maiúsculas). Grave sem pontos, barra e traço; a máscara é coisa da tela.
  • Tabela nova no SuperDB nasce fechada (RLS ligado, sem policy). As últimas linhas abrem só para o dono de cada linha, e a chave anon continua sem ler nada.
⚠️

O índice único tem um preço. Ele impede duas contas com o mesmo documento, mas o erro de duplicidade também conta a quem tenta que aquele CPF já tem conta no seu produto. Se isso for sensível no seu caso, tire o índice e trate a duplicidade no atendimento.

Passo 3: gravar depois do cadastro

O signUp já devolve a sessão, então logo depois dele o app grava o perfil. Os dois erros que o banco pode devolver têm código próprio:

src/lib/perfil.ts
import { db } from './superdb'
import { cnpjValido, cpfValido, normalizar } from './documento'

/** Devolve null quando salvou, ou a mensagem para mostrar. */
export async function salvarDocumento(tipo: 'pf' | 'pj', valor: string, nome?: string): Promise<string | null> {
  const documento = normalizar(valor)
  const valido = tipo === 'pf' ? cpfValido(documento) : cnpjValido(documento)
  if (!valido) return tipo === 'pf' ? 'CPF inválido.' : 'CNPJ inválido.'

  const { error } = await db.from('perfis').insert({ tipo, documento, nome })
  if (!error) return null
  if (error.code === '23505') return 'Já existe uma conta com este documento.'
  if (error.code === '23514') return 'O documento não passou na validação.'
  return 'Não deu para salvar agora. Tente de novo.'
}

E a consulta na Receita?

O SuperDB não consulta a Receita Federal. Os dígitos verificadores dizem que o número é bem formado, não que a pessoa ou a empresa existe. Para trazer razão social e situação cadastral de um CNPJ, chame uma API pública de CNPJ a partir do seu servidor (nunca do navegador, para não esbarrar no limite dela com o IP dos seus usuários) e guarde o resultado na sua tabela.

LGPD

  • CPF é dado pessoal e pede base legal, finalidade e cuidado. Ele não está na lista de dados sensíveis do art. 5º, II (saúde, religião, biometria e afins), mas vaza com facilidade e abre caminho para golpe.
  • Peça só se precisar (nota fiscal, contrato, verificação de identidade) e diga na tela para que serve.
  • Mostre mascarado depois de salvo, como ***.982.247-**, e não mande o documento para ferramentas de analytics ou logs.
  • CNPJ é dado público da empresa; o CPF de um sócio, não.

Erros comuns

23514 ao gravar um documento que o app aceitou

O app mandou o valor com máscara. Grave o normalizar(valor), como no passo 3: o CHECK só aceita dígitos e letras maiúsculas.

23505 ao gravar

O documento já está em outra conta (é o índice único). Ofereça recuperar o acesso da conta existente, em vez de outro cadastro.

O validador antigo recusa um CNPJ novo

Validadores escritos antes de 2026 tiram tudo que não é dígito e rejeitam 12.ABC.345/01DE-35. Troque pelas funções desta página, no app e no banco, e confira se as colunas e integrações também aceitam letras.

CPFs de teste passam no cálculo

123.456.789-09 tem dígitos verificadores corretos e aparece em todo formulário de teste. Se isso incomoda no seu produto, recuse uma lista curta de números conhecidos, no app e num CHECK a mais.

Essa página ajudou?