Implementar autenticação de dois fatores parece uma feature única, mas a forma como cada ecossistema resolve o assunto revela a personalidade de cada um. Neste estudo de caso prático montamos o mesmo recurso, TOTP com app autenticador mais OTP enviado por e-mail e códigos de recuperação, em três pilhas: Node com better-auth, Spring Boot com a biblioteca java-totp e Laravel com o Fortify. O objetivo não é eleger um vencedor universal, e sim entender o que você ganha e o que você assume em cada escolha.
Antes de codar: o que é TOTP e o que é OTP
Vale separar dois conceitos que costumam ser confundidos. TOTP, ou Time-based One-Time Password, é o código de seis dígitos que aparece no Google Authenticator, no Authy ou no 1Password. Ele é definido pela RFC 6238 e funciona sem rede: o servidor e o app compartilham um segredo no momento da ativação, e ambos geram o mesmo código a partir do horário atual, normalmente em janelas de trinta segundos. Nada trafega entre as partes após a configuração inicial, o que torna o TOTP resistente a interceptação.
Já o OTP por e-mail ou SMS é um código temporário que o servidor gera e envia por um canal externo. Ele depende de entrega, tem custo quando é SMS e é mais vulnerável a ataques de interceptação de mensagem, mas resolve o caso do usuário que não tem um app autenticador configurado. Na prática, uma implementação séria oferece os dois caminhos, além de códigos de recuperação para quando o usuário perde o telefone.
A maioria dos tutoriais cobre apenas o caminho feliz do TOTP e ignora recuperação de acesso, reenvio de código, limite de tentativas e dispositivos confiáveis. São justamente esses detalhes que diferenciam as três pilhas, então é por eles que vamos olhar.
O contrato comum: quatro momentos que toda implementação precisa resolver
Para comparar maçã com maçã, definimos quatro momentos que aparecem em qualquer 2FA, independente de linguagem. Vamos usar esses quatro pontos como eixo de avaliação ao longo do artigo.
Habilitar. Gerar o segredo do usuário e devolver um QR Code ou URI compatível com apps autenticadores. No caso do OTP por e-mail, configurar o canal de envio.
Confirmar a ativação. Exigir que o usuário digite um primeiro código válido antes de marcar o 2FA como ativo, evitando que alguém se tranque fora da conta por um QR lido errado.
Desafiar no login. Após validar a senha, interromper o fluxo e pedir o segundo fator antes de criar a sessão definitiva.
Recuperar acesso. Oferecer códigos de backup de uso único e, quando existir, OTP por e-mail como alternativa ao app perdido.
Node com better-auth: plugin-first, OTP nativo
O better-auth resolve 2FA através de um plugin dedicado. A filosofia do ecossistema fica clara logo na configuração: você declara o que quer e a biblioteca gera as tabelas, os campos extras no usuário e os endpoints. TOTP, OTP por e-mail e códigos de backup vivem no mesmo lugar.
Habilitar e confirmar
No servidor, você adiciona o plugin twoFactor e configura tanto o TOTP quanto o envio de OTP. O sendOTP recebe o usuário e o código, e cabe a você despachar o e-mail com a sua própria função de envio.
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
import { sendEmail } from "./email";
export const auth = betterAuth({
appName: "Minha App", // vira o issuer exibido no app autenticador
plugins: [
twoFactor({
totpOptions: {
digits: 6,
period: 30,
},
otpOptions: {
sendOTP: async ({ user, otp }) => {
await sendEmail({
to: user.email,
subject: "Seu codigo de verificacao",
text: `Seu codigo e: ${otp}`,
});
},
period: 5, // validade em minutos
digits: 6,
allowedAttempts: 5,
storeOTP: "encrypted",
},
backupCodeOptions: {
amount: 10,
},
}),
],
});No cliente, o fluxo de ativação pede a senha, retorna a URI do TOTP para gerar o QR Code e já entrega os códigos de backup. Repare que o 2FA só é marcado como ativo após a primeira verificação bem-sucedida, exatamente o segundo momento do nosso contrato.
import { createAuthClient } from "better-auth/client";
import { twoFactorClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = "/2fa";
},
}),
],
});
// Habilitar: devolve a URI do TOTP e os codigos de backup
const enable2FA = async (password) => {
const { data } = await authClient.twoFactor.enable({ password });
if (data) {
// data.totpURI -> gere o QR Code a partir disso
// data.backupCodes -> exiba ao usuario uma unica vez
}
};
// Confirmar a ativacao com o primeiro codigo do app
const verifyTotp = async (code) => {
await authClient.twoFactor.verifyTotp({ code, trustDevice: true });
};Desafiar e recuperar
No login, quando o usuário tem 2FA ativo, o better-auth dispara o redirecionamento configurado em onTwoFactorRedirect. Na tela de desafio você verifica o TOTP, ou dispara o OTP por e-mail como alternativa, ou aceita um código de backup. O parâmetro trustDevice cuida do dispositivo confiável, evitando pedir o segundo fator em todo acesso.
// Enviar o OTP por e-mail (alternativa ao app)
await authClient.twoFactor.sendOtp();
// Verificar o OTP recebido por e-mail
await authClient.twoFactor.verifyOtp({ code, trustDevice: true });O ponto forte do better-auth é a quantidade de coisa que você não precisa escrever: TOTP, OTP por e-mail, códigos de backup e dispositivos confiáveis vêm no mesmo pacote, com persistência automática. O custo é a inversão de controle. Você trabalha dentro das convenções do plugin, e personalizações mais profundas exigem entender como ele organiza tabelas e fluxos por baixo.
Java com Spring Boot: controle total, montagem manual
No mundo Spring, não existe um plugin único equivalente ao better-auth. A solução padrão de mercado é a biblioteca java-totp, publicada como dev.samstevens.totp, que oferece um starter dedicado ao Spring Boot. Ela cuida da parte criptográfica do TOTP, geração de segredo, montagem do QR Code e verificação do código, e deixa para você o fluxo de autenticação, a persistência e o envio de OTP. É o oposto da experiência plugin-first: mais verboso, porém com controle total de cada etapa.
Dependência e beans
O starter registra automaticamente os componentes principais como beans injetáveis, então você não precisa instanciá-los na mão.
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp-spring-boot-starter</artifactId>
<version>1.7.1</version>
</dependency>Habilitar e confirmar
O serviço de ativação gera o segredo, monta a URI do QR Code com o issuer da sua aplicação e produz a imagem em base64 pronta para embutir numa tag de imagem. O segredo precisa ser persistido no usuário, idealmente criptografado, junto de uma flag indicando se o 2FA já foi confirmado.
import dev.samstevens.totp.code.CodeVerifier;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.qr.QrGenerator;
import dev.samstevens.totp.secret.SecretGenerator;
import dev.samstevens.totp.util.Utils;
import org.springframework.stereotype.Service;
@Service
public class TwoFactorService {
private final SecretGenerator secretGenerator;
private final QrGenerator qrGenerator;
private final QrDataFactory qrDataFactory;
private final CodeVerifier codeVerifier;
public TwoFactorService(SecretGenerator secretGenerator,
QrGenerator qrGenerator,
QrDataFactory qrDataFactory,
CodeVerifier codeVerifier) {
this.secretGenerator = secretGenerator;
this.qrGenerator = qrGenerator;
this.qrDataFactory = qrDataFactory;
this.codeVerifier = codeVerifier;
}
// Gera o segredo que sera salvo no usuario
public String generateSecret() {
return secretGenerator.generate();
}
// Monta o QR Code como data URI para exibir no front
public String generateQrCode(String email, String secret) throws Exception {
QrData data = qrDataFactory.newBuilder()
.label(email)
.secret(secret)
.issuer("Minha App")
.build();
return Utils.getDataUriForImage(
qrGenerator.generate(data),
qrGenerator.getImageMimeType()
);
}
// Verifica o codigo digitado pelo usuario na ativacao e no login
public boolean verifyCode(String secret, String code) {
return codeVerifier.isValidCode(secret, code);
}
}Desafiar no login
Aqui está o trabalho que o Spring não entrega de graça. Após a validação de senha pelo Spring Security, você precisa interromper o fluxo e exigir o segundo fator antes de conceder a autenticação completa. Na prática isso vira um filtro ou um passo intermediário que checa a flag de 2FA do usuário e, se ativa, redireciona para a tela de desafio em vez de criar a sessão autenticada direto. O código digitado é validado com o mesmo verifyCode acima.
OTP por e-mail e recuperação
A biblioteca java-totp cobre apenas a parte TOTP. O OTP por e-mail é responsabilidade sua: você gera um código aleatório, guarda com prazo de expiração, dispara com o JavaMailSender do Spring e valida na volta. Os códigos de recuperação seguem a mesma lógica: gere uma lista de uso único, armazene o hash de cada um e marque como consumido ao usar.
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
@Service
public class EmailOtpService {
private final JavaMailSender mailSender;
private final SecureRandom random = new SecureRandom();
public EmailOtpService(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public String generateOtp() {
int code = random.nextInt(900000) + 100000; // seis digitos
return String.valueOf(code);
}
public void sendOtp(String to, String otp) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject("Seu codigo de verificacao");
message.setText("Seu codigo e: " + otp);
mailSender.send(message);
}
}O retrato do Spring Boot é claro: muito mais linhas, mais decisões nas suas mãos, persistência e fluxo de autenticação por sua conta. Em troca, você controla cada detalhe, encaixa o 2FA na arquitetura de segurança que já existe e não fica preso a convenções de um plugin. É a escolha natural de quem já vive dentro do Spring Security e precisa de integração fina.
PHP com Laravel Fortify: convenção pura, com um limite honesto
O Fortify é o backend de autenticação headless do Laravel. Ele entrega os endpoints de 2FA prontos, sem view, para você plugar em qualquer front. A experiência é a mais enxuta das três quando o assunto é TOTP, mas tem uma fronteira importante que precisa ser dita em voz alta.
Habilitar, confirmar e recuperar
Você ativa a feature no arquivo de configuração e adiciona a trait no modelo de usuário. A partir daí os endpoints existem: habilitar gera o segredo e o QR Code, confirmar valida o primeiro código, e os códigos de recuperação já vêm inclusos nativamente.
// config/fortify.php
use Laravel\Fortify\Features;
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::twoFactorAuthentication([
'confirmPassword' => true,
]),
],<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable
{
use TwoFactorAuthenticatable;
}Os endpoints expostos pelo Fortify seguem um contrato REST simples. Note que todos exigem confirmação de senha prévia, o que cobre o cuidado de segurança de operações sensíveis.
POST /user/two-factor-authenticationhabilita o 2FA e gera o segredo.POST /user/confirmed-two-factor-authenticationconfirma a ativação com o primeiro código TOTP.GET /user/two-factor-qr-coderetorna o QR Code para escanear no app.GET /user/two-factor-recovery-codeslista os códigos de recuperação.DELETE /user/two-factor-authenticationdesativa o 2FA.
Desafiar no login
Quando o usuário com 2FA ativo faz login, o Fortify intercepta e redireciona para o desafio. O endpoint /two-factor-challenge espera um campo code com um token TOTP válido ou um campo recovery_code com um dos códigos de recuperação. Em caso de sucesso numa requisição XHR, ele responde com status 204; em caso de falha, devolve os erros de validação com status 422. Todo o fluxo de sessão é tratado internamente.
O limite honesto: OTP por e-mail não é nativo
Aqui está a fronteira que muda a comparação. O Fortify cobre TOTP e códigos de recuperação de forma nativa, mas não oferece OTP por e-mail ou SMS embutido. Se o seu requisito inclui enviar código por e-mail como segundo fator alternativo, isso fica por sua conta. O caminho idiomático é escutar os eventos de autenticação do Laravel, gerar o código, guardar com expiração no cache e disparar uma Notification.
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class EmailOtpNotification extends Notification
{
public function __construct(private string $otp) {}
public function via($notifiable): array
{
return ['mail'];
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage)
->subject('Seu codigo de verificacao')
->line('Seu codigo e: ' . $this->otp);
}
}O retrato do Fortify é o de maior produtividade para o caminho TOTP, com endpoints prontos e códigos de recuperação de graça, ao custo de ter que complementar o OTP por e-mail manualmente. Para muita gente esse trade-off é ótimo, já que TOTP mais códigos de recuperação cobrem a maioria dos casos reais.
O comparativo técnico
Para sair da impressão e ir aos números, montei o mesmo 2FA completo nas três pilhas, com TOTP, OTP por e-mail e códigos de recuperação, e contei apenas as linhas que o desenvolvedor escreve, ignorando linhas em branco e o que a biblioteca já entrega pronta. A comparação se divide em três grupos: o que dá para medir, a facilidade de começar e os trade-offs qualitativos.
Verbosidade real: o que você escreve
O gráfico abaixo separa, em cada pilha, o código que é apenas configuração ou entregue pela biblioteca do código manual que você precisa escrever na mão. A diferença de escala é o ponto central do estudo.
Grupo 1: métricas mensuráveis
Métrica | Node | Java | PHP |
|---|---|---|---|
Linhas escritas pelo dev | 67 | 238 | 70 |
Linhas de plumbing manual | 0 | 197 | 48 |
Arquivos que você cria | 2 | 5 | 4 |
Dependências adicionadas | 1 | 1 | 1 |
Caso mínimo (só TOTP) | ~25 linhas | ~80 linhas | ~15 linhas |
Grupo 2: facilidade de iniciar
Critério | Node | Java | PHP |
|---|---|---|---|
Persistência do segredo | Automática | Manual (entidade e colunas) | Automática |
Fluxo de challenge no login | Automático | Manual (filtro ou handler) | Automático |
Expiração de OTP por e-mail | Nativa | Manual | Manual |
Precisa entender a engine de auth | Pouco, só plugar | Sim, integra ao Security | Pouco, só plugar |
Tempo até o primeiro TOTP validar | Curto | Longo | Muito curto |
Grupo 3: trade-offs qualitativos
Critério | Node | Java | PHP |
|---|---|---|---|
Controle sobre o fluxo | Médio | Total | Médio |
Acoplamento à convenção | Alto | Baixo | Alto |
Flexibilidade de customização | Média (opções do plugin) | Total | Média |
Cobertura nativa de recuperação | Backup codes e trusted devices | Nada nativo | Backup codes |
OTP por e-mail nativo | Sim | Não | Não |
Os números confirmam o que o código das seções anteriores já sugeria. O better-auth resolve o 2FA completo em 67 linhas de configuração, sem nenhuma linha de plumbing manual. O Laravel Fortify entrega TOTP e códigos de recuperação praticamente de graça, e as 48 linhas manuais são apenas o OTP por e-mail que ele não cobre nativamente. Já o Spring Boot chega a 238 linhas porque, fora as 41 da biblioteca java-totp, você monta na mão a persistência, o controller, o filtro de desafio, o OTP por e-mail e os códigos de backup. Mais linhas não significam pior: significam controle, e é isso que o próximo trecho discute.
Veredito sem torcida
As três pilhas chegam ao mesmo destino por caminhos que dizem muito sobre suas filosofias. O better-auth aposta em convenção e abrangência: é o único que entrega TOTP, OTP por e-mail, códigos de backup e dispositivos confiáveis no mesmo pacote, ideal para quem quer 2FA completo com pouco código e aceita trabalhar dentro das regras do plugin.
O Spring Boot com java-totp é o extremo do controle. Você escreve mais, decide mais e integra o 2FA na sua própria arquitetura de segurança. É a escolha de quem já vive no Spring Security e precisa de encaixe fino, não de uma caixa fechada.
O Laravel Fortify é o equilíbrio pragmático para o caminho TOTP: endpoints prontos, códigos de recuperação nativos e confirmação de senha embutida, com a ressalva honesta de que OTP por e-mail exige um complemento seu. Para a maioria dos produtos, TOTP mais recovery codes já resolvem, e o Fortify entrega isso rápido.
Não existe vencedor universal. Existe alinhamento entre a feature que você precisa e a filosofia do ecossistema onde você já trabalha. A boa notícia é que, em todos os três, 2FA seguro é uma realidade acessível em 2026, desde que você não pare no caminho feliz e trate recuperação, expiração e limite de tentativas com o mesmo cuidado.