O que vamos fazer
- Input único que aceita CPF ou CNPJ (detecta pelo número de dígitos)
- Mask automática (
000.000.000-00ou00.000.000/0000-00) - Validação client-side dos dígitos verificadores (sem chamada de rede)
- Pra CNPJ, lookup na Receita Federal: razão social, situação cadastral, endereço, CNAE
- Persistência segura: o SuperDB cifra CPF em
auth.users.encrypted_piiautomaticamente
Pré-requisitos
- SuperDB Free serve (a feature de PII encryption está em todos os planos desde Sprint 5)
@superdb/supabase-compat≥ 0.4 (ou@superdb/auth-js≥ 0.4)- Plano Pro+ pra usar o endpoint
db.auth.lookupCNPJ()(Free pode usar API pública da Receita, mas com rate limit baixo)
Passo 1 — Validação client (dígitos verificadores)
O algoritmo dos DVs é determinístico — não precisa de rede. Cole essas duas funções num lib/cpf-cnpj.ts:
export function validateCPF(value: string): boolean {
const cpf = value.replace(/\D/g, '')
if (cpf.length !== 11) return false
if (/^(\d)\1{10}$/.test(cpf)) return false // 111.111.111-11 etc
const calc = (slice: string, factor: number) => {
let sum = 0
for (const d of slice) sum += parseInt(d) * factor--
const r = (sum * 10) % 11
return r === 10 ? 0 : r
}
return calc(cpf.slice(0, 9), 10) === parseInt(cpf[9])
&& calc(cpf.slice(0, 10), 11) === parseInt(cpf[10])
}
export function validateCNPJ(value: string): boolean {
const cnpj = value.replace(/\D/g, '')
if (cnpj.length !== 14) return false
if (/^(\d)\1{13}$/.test(cnpj)) return false
const calc = (slice: string, factors: number[]) => {
let sum = 0
for (let i = 0; i < slice.length; i++) sum += parseInt(slice[i]) * factors[i]
const r = sum % 11
return r < 2 ? 0 : 11 - r
}
const f1 = [5,4,3,2,9,8,7,6,5,4,3,2]
const f2 = [6,5,4,3,2,9,8,7,6,5,4,3,2]
return calc(cnpj.slice(0, 12), f1) === parseInt(cnpj[12])
&& calc(cnpj.slice(0, 13), f2) === parseInt(cnpj[13])
}
export function maskBR(value: string) {
const d = value.replace(/\D/g, '')
if (d.length <= 11) {
// CPF: 000.000.000-00
return d
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1-$2')
}
// CNPJ: 00.000.000/0000-00
return d
.replace(/(\d{2})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1/$2')
.replace(/(\d{4})(\d)/, '$1-$2')
}
Passo 2 — Formulário de signup
Input único, valida ao perder foco, mostra check verde:
'use client'
import { useState } from 'react'
import { validateCPF, validateCNPJ, maskBR } from '@/lib/cpf-cnpj'
export function IdentidadeInput() {
const [valor, setValor] = useState('')
const [erro, setErro] = useState<string | null>(null)
const [ok, setOk] = useState(false)
function onBlur() {
const digits = valor.replace(/\D/g, '')
if (digits.length === 11) {
if (!validateCPF(valor)) return setErro('CPF inválido')
} else if (digits.length === 14) {
if (!validateCNPJ(valor)) return setErro('CNPJ inválido')
} else {
return setErro('Digite 11 dígitos (CPF) ou 14 (CNPJ)')
}
setErro(null)
setOk(true)
}
return (
<div>
<input
value={valor}
onChange={(e) => { setValor(maskBR(e.target.value)); setOk(false) }}
onBlur={onBlur}
placeholder="CPF ou CNPJ"
/>
{ok && <span>✓</span>}
{erro && <span style={{color: 'red'}}>{erro}</span>}
</div>
)
}
Passo 3 — Server revalida e persiste
Nunca confie só na validação client. O SuperDB já tem validação no signUp — basta passar o CPF em data:
'use server'
import { createServerClient } from '@superdb/supabase-compat/ssr'
import { cookies } from 'next/headers'
import { validateCPF } from '@/lib/cpf-cnpj'
export async function signupAction(formData: FormData) {
const email = formData.get('email') as string
const cpf = (formData.get('cpf') as string).replace(/\D/g, '')
// Server revalida — defesa em profundidade
if (!validateCPF(cpf)) {
return { erro: 'CPF inválido' }
}
const cookieStore = await cookies()
const db = createServerClient(/* ... */)
const { error } = await db.auth.signUp({
email,
password: formData.get('password') as string,
options: {
data: { cpf }, // SuperDB cifra automaticamente
},
})
return error ? { erro: error.message } : { ok: true }
}
Dica: o CPF/CNPJ entra em raw_user_meta_data, mas o SuperDB detecta o campo e cifra em encrypted_pii (AES-256-GCM) automaticamente. Quando você ler via db.auth.getUser(), ele decifra na hora pro user autenticado. Outros users não conseguem ver mesmo com SQL direto — a chave fica fora do banco.
Passo 4 — Lookup CNPJ na Receita
Quando o input tem 14 dígitos válidos, busque dados públicos da empresa. Útil pra pré-preencher razão social, validar se a empresa está ativa, mostrar endereço:
import { createBrowserClient } from '@superdb/supabase-compat'
const db = createBrowserClient(
process.env.NEXT_PUBLIC_SUPERDB_URL!,
process.env.NEXT_PUBLIC_SUPERDB_ANON_KEY!
)
const { data, error } = await db.auth.lookupCNPJ('00.000.000/0001-91')
// data = {
// razao_social: 'BANCO DO BRASIL SA',
// nome_fantasia: 'BB',
// situacao: 'ATIVA',
// data_situacao: '2005-11-03',
// endereco: { logradouro, numero, bairro, municipio, uf, cep },
// atividade_principal: { codigo: '6422100', descricao: 'Bancos múltiplos...' },
// cached_at: '2026-05-11T14:00:00Z', // cache 24h
// }
const { data, error } = await db.auth.lookupCNPJ('00.000.000/0001-91')
console.log(data.razao_social) // 'BANCO DO BRASIL SA'
curl "$SUPERDB_URL/auth/v1/cnpj/00000000000191" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $USER_JWT"
Use no form pra pré-preencher campos depois do lookup:
async function buscarReceita() {
setBuscando(true)
const { data, error } = await db.auth.lookupCNPJ(cnpj)
setBuscando(false)
if (error) return setErro('CNPJ não encontrado na Receita')
setRazaoSocial(data.razao_social)
setEndereco(data.endereco.logradouro + ', ' + data.endereco.numero)
setCidade(data.endereco.municipio)
setUf(data.endereco.uf)
if (data.situacao !== 'ATIVA') {
setAviso(`Atenção: empresa em situação ${data.situacao}`)
}
}
Resultado
Signup com identidade brasileira validada localmente em milissegundos + lookup completo da empresa em ~400ms (cache 24h). Reduz fraude e melhora UX porque user não precisa digitar razão social.
Variações
Apenas CPF (B2C)
Se seu produto é só pra pessoa física, simplifique pra um input fixo de 11 dígitos:
const digits = valor.replace(/\D/g, '')
if (digits.length !== 11 || !validateCPF(digits)) {
return setErro('CPF inválido')
}
Apenas CNPJ (B2B)
SaaS pra empresas: aceite só CNPJ e force o lookup na Receita como parte do onboarding. Bloqueia signups fraudulentos.
Radio button PF / PJ
Mostra ao usuário a escolha explícita antes de pedir o documento. UX mais limpa que detectar automaticamente:
<label><input type="radio" value="pf" /> Pessoa Física</label>
<label><input type="radio" value="pj" /> Pessoa Jurídica</label>
{tipo === 'pf' && <CpfInput />}
{tipo === 'pj' && <CnpjInput onLookup={preencherEmpresa} />}
Erros comuns
CPFs de teste passam no algoritmo
123.456.789-09 e outros "famosos" passam nos dígitos verificadores. Bloqueie via blacklist explícita pra evitar signup de fraude:
const CPFS_BLOQUEADOS = new Set([
'12345678909',
'98765432100',
'11122233344',
// ... popule com os ~50 CPFs mais usados em testes
])
export function isCpfReal(cpf: string) {
return validateCPF(cpf) && !CPFS_BLOQUEADOS.has(cpf.replace(/\D/g,''))
}
CNPJ inativo na Receita
Empresa baixada, suspensa ou inapta retorna situacao !== 'ATIVA'. Decida regra: alertar (B2C com checkout) ou bloquear (B2B com contratos). Não ignore — empresa inativa não emite NF-e.
Cuidado: a API de lookup tem rate limit (10 req/min por usuário autenticado, 100/min por projeto). Em telas de "validar em tempo real" enquanto digita, debouncing é obrigatório — espere o user terminar de digitar 14 dígitos válidos antes de chamar. Senão, vai esgotar o limite em 1 user que está apagando e digitando.
Dados cacheados desatualizados
O cache da Receita é de 24h. Em casos raros (mudança de razão social, baixa recente), os dados podem estar desatualizados. Solução: ofereça botão "atualizar dados da Receita" que força refresh — chame db.auth.lookupCNPJ(cnpj, { force: true }).
Sobre LGPD: CPF é dado pessoal sensível. O SuperDB cifra em repouso (AES-256-GCM) e mascara em logs automaticamente. CNPJ é dado público (consultável na Receita) e não tem o mesmo tratamento — fica em claro. Configure o que é PII em Auth → PII Fields se quiser cifrar campos próprios também.