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.

  1. 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.

  2. 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.

  3. Desafiar no login. Após validar a senha, interromper o fluxo e pedir o segundo fator antes de criar a sessão definitiva.

  4. 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.

TYPESCRIPT
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.

TYPESCRIPT
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.

TYPESCRIPT
// 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.

XML
<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.

JAVA
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.

JAVA
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.

PHP
// config/fortify.php
use Laravel\Fortify\Features;

'features' => [
    Features::registration(),
    Features::resetPasswords(),
    Features::twoFactorAuthentication([
        'confirmPassword' => true,
    ]),
],
PHP
<?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-authentication habilita o 2FA e gera o segredo.

  • POST /user/confirmed-two-factor-authentication confirma a ativação com o primeiro código TOTP.

  • GET /user/two-factor-qr-code retorna o QR Code para escanear no app.

  • GET /user/two-factor-recovery-codes lista os códigos de recuperação.

  • DELETE /user/two-factor-authentication desativa 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
<?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.