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
perfissua, 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
// 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.
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):
-- 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_idcomdefault auth.uid(): o app nunca manda o id, e a policy recusa gravar no nome de outra pessoa.- O
CHECKexige 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:
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.