O que vamos fazer
- Cadastrar o autenticador:
db.auth.mfa.enroll({ factorType: 'totp' })devolve o QR code. - Confirmar o primeiro código com
db.auth.mfa.verify(). - Gerar 10 códigos de reserva, para quando o celular some.
- Listar e remover fatores.
- Pedir o código antes de uma ação sensível, com a conferência feita no servidor.
O que o MFA faz hoje, e o que ainda não faz
O login ainda não pede o segundo fator sozinho. Um usuário com autenticador cadastrado entra só com a senha, e o mfa.verify não muda a sessão: o token não ganha um nível "com MFA", então uma policy de RLS não tem como exigir o segundo fator. Hoje o MFA serve para confirmar ações sensíveis (trocar o e-mail, excluir a conta, aprovar um pagamento), com a conferência feita no seu servidor, como no passo 5.
| Recurso | Situação |
|---|---|
| Cadastrar autenticador (QR code) | ✅ mfa.enroll({ factorType: 'totp' }) |
| Conferir um código | ✅ mfa.verify({ factorType, code }) |
| Códigos de reserva | ✅ mfa.enroll({ factorType: 'backup' }) |
| Listar fatores | ✅ mfa.factors() |
| Remover um fator | ✅ pela API (DELETE /auth/v1/mfa/factors/:id); o SDK ainda não tem o método |
| O login exigir o código | ❌ ainda não |
| Nível de MFA no token, para usar no RLS | ❌ ainda não |
Pré-requisitos
- Usuário logado: todas as chamadas de MFA usam a sessão atual. Sem sessão, o SDK responde
no_session. @superdb/client0.2.2 ou mais novo, com o cliente criado com oproject.- Para desenhar o QR code, a biblioteca
qrcode(npm install qrcode) ou qualquer outra.
Passo 1: cadastrar o autenticador
enroll cria o fator e devolve três coisas: o factor_id, o segredo em base32 (para quem prefere digitar) e a uri otpauth://, que vira o QR code.
import QRCode from 'qrcode'
import { db } from '../lib/superdb'
export async function cadastrarAutenticador() {
const { data, error } = await db.auth.mfa.enroll({
factorType: 'totp',
label: 'Meu App', // aparece no autenticador, ao lado de "SuperDB"
})
if (error) throw new Error(error.message)
const { factor_id, secret, uri } = data as { factor_id: string; secret: string; uri: string }
const qrCode = await QRCode.toDataURL(uri) // <img src={qrCode} />
return { fatorId: factor_id, segredo: secret, qrCode }
}
O autenticador mostra SuperDB como emissor da conta. Use o label com o nome do seu app para o usuário reconhecer de qual serviço é o código.
Passo 2: confirmar o primeiro código
O fator já nasce ativo no enroll, sem etapa de confirmação no servidor. Por isso, peça o primeiro código na mesma tela: ele prova que o QR code foi lido. Se a pessoa desistir ou errar, remova o fator, para não ficar um autenticador que ninguém tem.
import { db } from '../lib/superdb'
import { removerFator } from './remover'
export async function confirmarCadastro(fatorId: string, codigo: string) {
const { error } = await db.auth.mfa.verify({ factorType: 'totp', code: codigo.trim() })
if (!error) return true
if (error.code === 'mfa_invalid_code') return false // deixe tentar de novo
await removerFator(fatorId) // qualquer outro erro: desfaz o cadastro
throw new Error(error.message)
}
Na tela, ofereça um botão "Cancelar" que também chama removerFator(fatorId).
Passo 3: códigos de reserva
Dez códigos de uso único, para quando o celular é perdido ou trocado. Mostre uma vez só, com um botão de copiar ou baixar: o SuperDB guarda apenas o hash, e não há como exibir de novo.
import { db } from '../lib/superdb'
export async function gerarCodigosDeReserva() {
const { data, error } = await db.auth.mfa.enroll({ factorType: 'backup' })
if (error) throw new Error(error.message)
return (data as { codes: string[] }).codes // 10 códigos
}
// Mais tarde, no lugar do código do autenticador:
export async function usarCodigoDeReserva(codigo: string) {
const { error } = await db.auth.mfa.verify({ factorType: 'backup', code: codigo.trim() })
return !error // cada código funciona uma vez
}
Gerar de novo invalida todos os códigos anteriores. É o que o usuário deve fazer depois de gastar alguns.
Passo 4: listar e remover fatores
import { db } from '../lib/superdb'
type Fator = { id: string; kind: 'totp' | 'backup'; label: string | null; enrolledAt: string; lastUsedAt: string | null }
export async function listarFatores(): Promise<Fator[]> {
const { data, error } = await db.auth.mfa.factors()
if (error) throw new Error(error.message)
return (data as { factors: Fator[] }).factors
}
// O SDK ainda não tem unenroll: a remoção é uma chamada direta à API de auth.
export async function removerFator(fatorId: string) {
const { data: { session } } = await db.auth.getSession()
if (!session) throw new Error('entre de novo')
const r = await fetch(`${import.meta.env.VITE_SUPERDB_URL}/auth/v1/mfa/factors/${fatorId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${session.access_token}`,
apikey: import.meta.env.VITE_SUPERDB_ANON_KEY,
'X-SuperDB-Project': import.meta.env.VITE_SUPERDB_PROJECT,
},
})
if (!r.ok) throw new Error('não deu para remover o fator')
}
Passo 5: pedir o código antes de uma ação sensível
Esconder um botão atrás de um campo de código no navegador não protege nada: quem tem a sessão chama a sua API direto. A conferência precisa acontecer no servidor que executa a ação. O navegador manda o código e o access_token; o servidor pergunta ao SuperDB se o código é daquele usuário e só então age.
const AUTH = process.env.SUPERDB_URL! // https://auth.superdb.com.br
const ANON = process.env.SUPERDB_ANON_KEY!
const PROJETO = process.env.SUPERDB_PROJECT!
const CARENCIA_MS = 24 * 60 * 60 * 1000
function noSuperDB(caminho: string, token: string, init: RequestInit = {}) {
return fetch(`${AUTH}${caminho}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
apikey: ANON,
'X-SuperDB-Project': PROJETO,
},
})
}
export async function POST(req: Request) {
const token = req.headers.get('authorization')?.replace(/^Bearer\s+/i, '')
const { code, tipo = 'totp' } = (await req.json().catch(() => ({}))) as { code?: string; tipo?: string }
if (!token || !code) return Response.json({ erro: 'faltou o código' }, { status: 400 })
if (tipo !== 'totp' && tipo !== 'backup') return Response.json({ erro: 'tipo inválido' }, { status: 400 })
// 1. Quem é o usuário? Pergunte ao SuperDB, nunca ao corpo da requisição.
const eu = await noSuperDB('/auth/v1/user', token)
if (!eu.ok) return Response.json({ erro: 'sessão inválida' }, { status: 401 })
const { user } = (await eu.json()) as { user: { id: string } }
// 2. Carência: quem tem a sessão também consegue cadastrar um autenticador.
// Um fator com menos de 24 horas não autoriza ação sensível.
const lista = await noSuperDB('/auth/v1/mfa/factors', token)
if (!lista.ok) return Response.json({ erro: 'sessão inválida' }, { status: 401 })
const { factors } = (await lista.json()) as { factors: { kind: string; enrolledAt: string }[] }
const doTipo = factors.filter((f) => f.kind === tipo)
const recente = doTipo.some((f) => Date.now() - Date.parse(f.enrolledAt) < CARENCIA_MS)
if (doTipo.length === 0 || recente) {
return Response.json({ erro: 'fator cadastrado há menos de 24 horas' }, { status: 403 })
}
// 3. O código é deste usuário? Limite: 10 tentativas por minuto.
const r = await noSuperDB('/auth/v1/mfa/verify', token, {
method: 'POST',
body: JSON.stringify({ factor_type: tipo, code }),
})
if (!r.ok) return Response.json({ erro: 'código inválido' }, { status: 401 })
// 4. Só agora, a ação sensível para user.id.
return Response.json({ ok: true })
}
const { data: { session } } = await db.auth.getSession()
const r = await fetch('/api/excluir-conta', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session!.access_token}` },
body: JSON.stringify({ code: codigoDigitado }),
})
Por que a carência de 24 horas. Cadastrar um autenticador, ou gerar códigos de reserva novos, também só pede a sessão. Sem a carência, alguém com a sessão de outra pessoa cadastraria o próprio celular e usaria o código dele na hora. Mostre a lista de fatores na tela de segurança do app, com a data de cadastro, para o usuário perceber um que não é dele.
Nos Hooks do painel, os eventos before-mfa e after-mfa avisam o seu servidor a cada conferência. O before-mfa pode até recusar (por exemplo, depois de um alerta de fraude na conta).
Erros comuns
mfa_invalid_code (401) com o código certo
Quase sempre é o relógio do celular adiantado ou atrasado: o código TOTP depende da hora. Peça para ligar a hora automática nos ajustes do aparelho. Se a pessoa digitou o código logo antes de ele trocar, basta usar o próximo.
rate_limited (429)
Mais de 10 conferências em um minuto para o mesmo usuário. O limite existe para que ninguém chute os 6 dígitos; espere um minuto.
no_session
As funções de MFA precisam de alguém logado. Confira se a sessão não venceu: ela dura 1 hora, e só o SDK 0.3 em diante renova sozinho (veja SDK JS → Auth).
O usuário perdeu o celular
Como o login ainda não pede o segundo fator, ele entra com a senha, remove o fator antigo (passo 4) e cadastra o novo celular. Enquanto a carência do celular novo não passa, os códigos de reserva confirmam as ações sensíveis: mande tipo: 'backup' para o servidor do passo 5.