O que vamos fazer
- Tela de enrollment com QR code pro usuário escanear no app autenticador
- Input dos 6 dígitos pra confirmar que o fator foi configurado certo
- Em logins futuros, depois da senha, exigir o TOTP (AAL2)
- Trusted devices: salvar device_id criptografado em cookie pra pular TOTP por 30 dias
- Recovery codes: 10 códigos pra emergência (mostrados UMA vez)
Pré-requisitos
- Usuário já autenticado (precisa estar logado pra fazer enroll de fator MFA)
@superdb/supabase-compat≥ 0.4 (ou@superdb/auth-js≥ 0.4)- Provider de autenticador (Google Authenticator, Authy, 1Password, Bitwarden — qualquer um que faça TOTP RFC 6238)
Passo 1 — Enroll do fator
Geração do secret + QR code. O QR é um otpauth:// URI, encodado como SVG:
'use client'
import { useState } from 'react'
import { createBrowserClient } from '@superdb/supabase-compat'
export function EnrollMFA() {
const [qr, setQr] = useState<string | null>(null)
const [factorId, setFactorId] = useState<string | null>(null)
const [code, setCode] = useState('')
const [ativo, setAtivo] = useState(false)
const db = createBrowserClient(
process.env.NEXT_PUBLIC_SUPERDB_URL!,
process.env.NEXT_PUBLIC_SUPERDB_ANON_KEY!
)
async function iniciar() {
const { data, error } = await db.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Meu telefone',
})
if (error) return alert(error.message)
setQr(data.totp.qr_code) // SVG inline
setFactorId(data.id)
}
async function confirmar() {
if (!factorId) return
const { error } = await db.auth.mfa.challengeAndVerify({ factorId, code })
if (error) return alert('Código inválido')
setAtivo(true)
}
if (ativo) return <p>✓ MFA ativo na sua conta</p>
return (
<div>
{!qr && <button onClick={iniciar}>Ativar MFA</button>}
{qr && (
<>
<div dangerouslySetInnerHTML={{ __html: qr }} />
<input value={code} onChange={(e) => setCode(e.target.value)}
placeholder="000000" maxLength={6} />
<button onClick={confirmar}>Confirmar</button>
</>
)}
</div>
)
}
Atenção: entre enroll e challengeAndVerify, o fator está em estado unverified. Se o usuário fechar o navegador antes de confirmar, ele fica órfão. Tenha um botão "Cancelar" que chama db.auth.mfa.unenroll(factorId) pra limpar.
Passo 2 — Verificar AAL no login subsequente
Depois do signIn (password ou OAuth), confira o assurance level. Se o usuário tem MFA ativo, o nível necessário sobe pra aal2:
const { data: aal } = await db.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal.currentLevel === 'aal1' && aal.nextLevel === 'aal2') {
// Pede o TOTP pro usuário
const { data: factors } = await db.auth.mfa.listFactors()
const totpFactor = factors.totp[0]
const { data: challenge } = await db.auth.mfa.challenge({
factorId: totpFactor.id,
})
// ... mostra input, usuário digita 6 dígitos ...
await db.auth.mfa.verify({
factorId: totpFactor.id,
challengeId: challenge.id,
code: '123456',
})
}
Passo 3 — Trusted devices
Não force TOTP em todo login se o dispositivo já é conhecido. Salve um device_id criptografado em cookie httpOnly. TTL 30 dias.
import { cookies } from 'next/headers'
import { randomBytes, createHmac } from 'crypto'
const SECRET = process.env.TRUSTED_DEVICE_SECRET! // 32 bytes random
export async function POST(req: Request) {
const { userId } = await req.json()
// Gera device_id atrelado ao user
const deviceId = randomBytes(16).toString('hex')
const signature = createHmac('sha256', SECRET)
.update(`${userId}:${deviceId}`)
.digest('hex')
const cookieStore = await cookies()
cookieStore.set('trusted_device', `${deviceId}.${signature}`, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 dias
path: '/',
})
// Salva no banco pra você poder revogar
// INSERT INTO trusted_devices (user_id, device_id, expires_at) VALUES (...)
return new Response('ok')
}
No login, antes de pedir TOTP, valide o cookie. Se bate com o user e ainda está no banco → pula MFA:
const trusted = cookieStore.get('trusted_device')?.value
if (trusted) {
const [deviceId, sig] = trusted.split('.')
const expected = createHmac('sha256', SECRET)
.update(`${user.id}:${deviceId}`)
.digest('hex')
if (sig === expected && await deviceAindaValido(deviceId)) {
return // Pula TOTP, eleva AAL via API admin
}
}
Passo 4 — Recovery codes
Ao confirmar enrollment, gere 10 códigos de 8 caracteres. Mostre UMA vez — depois disso ficam só hashes no banco:
import { randomBytes, createHash } from 'crypto'
export async function gerarRecoveryCodes(userId: string) {
const codes = Array.from({ length: 10 }, () =>
randomBytes(5).toString('hex').toUpperCase().match(/.{4}/g)!.join('-')
)
// ex: 'A3F2-9B41'
const hashes = codes.map((c) =>
createHash('sha256').update(c).digest('hex')
)
// Salve hashes no banco (tabela mfa_recovery_codes)
await dbAdmin.from('mfa_recovery_codes').insert(
hashes.map((hash) => ({ user_id: userId, code_hash: hash, used: false }))
)
return codes // Mostre UMA vez pro user salvar
}
Pra usar um recovery code (cenário: perdeu o telefone):
async function usarRecoveryCode(userId: string, code: string) {
const hash = createHash('sha256').update(code).digest('hex')
const { data } = await dbAdmin
.from('mfa_recovery_codes')
.select('id')
.eq('user_id', userId)
.eq('code_hash', hash)
.eq('used', false)
.single()
if (!data) throw new Error('Código inválido')
// Marca como usado (one-time)
await dbAdmin.from('mfa_recovery_codes')
.update({ used: true, used_at: new Date() })
.eq('id', data.id)
// Eleva sessão pra AAL2
}
Resultado
App com MFA real: enrollment com QR, validação subsequente, trusted devices que reduzem fricção, recovery codes pra emergência. Pronto pra compliance básica (SOC 2, ISO).
Variações
WebAuthn / Passkeys (em breve)
SuperDB tem WebAuthn no roadmap pra Sprint 9. Quando sair, a API vai ser idêntica — só muda o factorType de 'totp' pra 'webauthn'. Você não vai precisar reescrever a UI de enroll.
SMS OTP como segundo fator
Já disponível. Use factorType: 'phone' e passe o número:
await db.auth.mfa.enroll({
factorType: 'phone',
friendlyName: 'iPhone Felipe',
phone: '+5511999998888',
})
// SuperDB envia OTP via SMS; user digita pra confirmar
Dica: TOTP é mais seguro que SMS (SIM swap é vetor real). Use SMS só como fallback ou pra públicos que não vão instalar app autenticador.
Forçar MFA por role
Em apps B2B é comum exigir MFA pra admins. Cheque o role + AAL no middleware:
const { data: { user } } = await db.auth.getUser()
const isAdmin = user?.app_metadata?.role === 'admin'
const { data: aal } = await db.auth.mfa.getAuthenticatorAssuranceLevel()
if (isAdmin && aal.currentLevel !== 'aal2') {
return NextResponse.redirect(new URL('/seguranca/enroll-mfa', request.url))
}
Erros comuns
Time drift no dispositivo do usuário
TOTP depende do relógio. Se o telefone do user está adiantado/atrasado mais de ~30s, todos os códigos falham. Solução: o SuperDB já aceita janela de ±30s. Se ainda assim falhar, oriente o user a habilitar "Sincronização automática de hora" nas configurações.
Perder o secret antes do verify
Se o user fechar a aba depois do enroll e antes de confirmar, ele fica num limbo: o fator existe (unverified), mas ele não tem mais acesso ao QR. Solução: detecte fatores unverified e ofereça "Refazer enrollment" (chama unenroll + enroll de novo).
Brute force no código
5 tentativas erradas seguidas → o SuperDB retorna 429 e bloqueia o factor por 15min. Não tente bypass — é proteção contra brute force. Na UI, mostre tentativas restantes e ofereça recovery code depois da 3ª falha.
Sobre compliance: ter MFA ativa por padrão pra contas admin já cobre parte significativa do controle CC6.1 da SOC 2. Não substitui audit log e gestão de chaves, mas é o primeiro item da lista.