A autenticação de dois fatores deixou de ser um extra opcional. Em 2026, qualquer aplicação web que guarde dados sensíveis precisa de uma segunda barreira para lá da palavra-passe. A Microsoft repete há anos que mais de 99,9% das contas comprometidas não tinham MFA ativada, um número que resume bem porque é que este passo importa. Neste tutorial implementamos, do zero, autenticação de dois fatores baseada em TOTP (Time-based One-Time Password) numa API Node.js, com código de seis dígitos compatível com o Google Authenticator, Microsoft Authenticator e Authy.

No final terá um projeto funcional completo: geração do segredo partilhado, código QR para registo, verificação do código de seis dígitos, códigos de recuperação, limitação de tentativas e integração no fluxo de login com JWT. São 12 passos, cerca de 30 minutos de trabalho, e tudo assente nas normas RFC 6238 e RFC 4226. Nenhuma dependência paga, nenhum serviço externo obrigatório.

Autenticação de dois fatores: o que muda em 2026

A autenticação de dois fatores combina algo que o utilizador sabe (a palavra-passe) com algo que possui (o telemóvel com a app de códigos). Mesmo que um atacante roube a palavra-passe num ataque de phishing ou numa fuga de dados, não consegue entrar sem o código que muda a cada 30 segundos no dispositivo da vítima.

Em 2026, a paisagem mudou em três pontos concretos. Primeiro, o SMS perdeu credibilidade: o envio de códigos por mensagem continua vulnerável ao SIM swap, em que o atacante transfere o número da vítima para um cartão SIM controlado por si. O TOTP, por gerar os códigos localmente no dispositivo, elimina esse risco. Segundo, as passkeys (FIDO2/WebAuthn) consolidaram-se como o fator mais resistente a phishing, porque a credencial fica ligada à origem do site e não pode ser reutilizada num domínio falso. Terceiro, mesmo com as passkeys em ascensão, o TOTP mantém-se como o método de compatibilidade universal, suportado por todas as apps de autenticação e por milhões de contas existentes.

A escolha prática para a maioria das equipas em 2026 é clara: oferecer TOTP como segundo fator universal hoje, e planear passkeys como caminho a seguir. Este tutorial foca o TOTP porque é o que pode implementar numa tarde, sem depender do suporte de hardware do cliente. Convém saber, desde já, a sua principal limitação: o TOTP não é resistente a phishing em tempo real. Um atacante que monte um proxy reverso entre o utilizador e o site verdadeiro pode capturar e reencaminhar o código dentro da janela de 30 segundos. Mitigamos isso mais à frente com limitação de tentativas e boas práticas de sessão.

Como funciona o TOTP (RFC 6238 e RFC 4226)

O TOTP é definido na RFC 6238 e assenta sobre o HOTP, descrito na RFC 4226. A ideia é simples e elegante. O servidor e a app de autenticação partilham um segredo único, gerado no momento do registo. A partir daí, ambos calculam o mesmo código de seis dígitos usando dois ingredientes: esse segredo partilhado e a hora atual.

O cálculo divide o tempo em janelas (por norma de 30 segundos), conta quantas janelas passaram desde 1 de janeiro de 1970, e aplica um HMAC-SHA1 sobre esse contador usando o segredo como chave. Do resultado extrai-se um número de seis dígitos. Como o servidor e a app têm o mesmo segredo e o mesmo relógio, chegam ao mesmo código de forma independente, sem nunca trocarem mensagens depois do registo inicial. Por isso é que o TOTP funciona mesmo com o telemóvel em modo de avião.

Os parâmetros são quase sempre os mesmos em todas as aplicações populares. A tabela abaixo mostra os valores padrão que vamos respeitar para garantir compatibilidade total.

ParâmetroValor padrãoGoogle AuthenticatorMicrosoft AuthenticatorAuthy
Período (janela)30 segundos30 s30 s30 s
Dígitos6666
AlgoritmoHMAC-SHA1SHA1SHA1SHA1
Codificação do segredoBase32Base32Base32Base32
NormaRFC 6238RFC 6238RFC 6238RFC 6238
Parâmetros TOTP padrão suportados pelas principais apps de autenticação.

Repare numa decisão deliberada: o HMAC-SHA1. Apesar de o SHA-1 estar obsoleto para assinaturas digitais (recorde a colisão SHATTERED), no contexto do HMAC-TOTP mantém-se seguro e, mais importante, é o único algoritmo que todas as apps leem de forma fiável. Alterar para SHA-256 partiria a compatibilidade com muitos leitores de QR. Mantemos o SHA1.

Pré-requisitos e versões

Antes de escrever código, confirme o ambiente. Este tutorial foi testado com as versões da tabela seguinte. Pode usar versões mais recentes, mas evite versões anteriores do Node.js, que não suportam algumas funcionalidades do Express 5.

FerramentaVersão usadaFunção no projeto
Node.js22 LTS ou 24 LTSAmbiente de execução
npm10 ou superiorGestor de pacotes
otplib13.4.1Geração e verificação de TOTP
qrcode1.5.4Gerar o código QR de registo
express5.2.1Servidor HTTP da API
jsonwebtoken9.xEmitir o token de sessão
Pré-requisitos e versões de referência para o projeto.

Confirme as versões instaladas antes de avançar. Uma incompatibilidade de Node.js é a causa número um de erros logo no primeiro passo.

node --version
# v22.x.x ou v24.x.x

npm --version
# 10.x.x ou superior

Precisa também de uma app de autenticação no telemóvel para testar. Qualquer uma serve: Google Authenticator, Microsoft Authenticator, Authy, ou alternativas de código aberto como o Aegis (Android) ou o Raivo (iOS). Conhecimentos básicos de JavaScript assíncrono (async/await) e de pedidos HTTP são suficientes para acompanhar.

Passo 1: Inicializar o projeto Node.js

Crie uma pasta para o projeto e inicialize-o. Vamos usar módulos ES (import/export), por isso definimos o tipo do pacote logo de início.

mkdir 2fa-nodejs && cd 2fa-nodejs
npm init -y
npm pkg set type="module"
npm pkg set engines.node=">=22"

O comando npm pkg set type="module" evita o aviso recorrente sobre sintaxe de importação e permite usar import sem extensões de ficheiro estranhas. A definição de engines.node documenta a versão mínima e avisa quem instalar o projeto num ambiente antigo.

Passo 2: Instalar as dependências (otplib, qrcode, express)

Instale as quatro bibliotecas centrais. O otplib trata da geração e verificação dos códigos TOTP, o qrcode desenha o código QR, o express serve a API e o jsonwebtoken emite o token de sessão após a verificação bem-sucedida.

npm install [email protected] [email protected] [email protected] jsonwebtoken@9
npm install --save-dev nodemon

Escolhemos o otplib em vez do speakeasy (versão 2.0.0, sem atualizações recentes) porque o otplib é mantido ativamente, tem API moderna baseada em classes e separa de forma limpa o TOTP do HOTP. Adicione um script de arranque ao package.json para reiniciar o servidor durante o desenvolvimento.

npm pkg set scripts.dev="nodemon server.js"
npm pkg set scripts.start="node server.js"

Passo 3: Gerar o segredo partilhado do utilizador

O segredo partilhado é o coração da autenticação de dois fatores. É gerado uma única vez, no momento em que o utilizador ativa o 2FA, e nunca mais é mostrado em texto simples. Crie um ficheiro totp.js com as funções utilitárias.

// totp.js
import { authenticator } from 'otplib';

// Parametros explicitos para garantir compatibilidade total
authenticator.options = {
  digits: 6,
  step: 30,        // janela de 30 segundos
  window: 1,       // tolera 1 janela de deriva de relogio
  algorithm: 'sha1',
};

// Gera um segredo Base32 novo para um utilizador
export function gerarSegredo() {
  return authenticator.generateSecret(); // 32 caracteres Base32
}

// Cria a URI otpauth:// que a app de autenticacao le
export function gerarOtpauthUri(email, segredo) {
  const emissor = 'ShatteredApp';
  return authenticator.keyuri(email, emissor, segredo);
}

A função keyuri produz uma string no formato otpauth://totp/ShatteredApp:[email protected]?secret=...&issuer=ShatteredApp. É esta URI que transformamos em código QR no passo seguinte. O campo emissor aparece como nome da conta dentro da app do utilizador, por isso use o nome real do seu serviço.

Passo 4: Criar o código QR para o authenticator

O utilizador não vai escrever 32 caracteres à mão. Convertemos a URI otpauth num código QR em formato Data URL, que o frontend mostra como imagem. Acrescente ao totp.js:

// totp.js (continuacao)
import QRCode from 'qrcode';

// Devolve um Data URL PNG pronto a usar num elemento de imagem
export async function gerarQrCode(otpauthUri) {
  try {
    return await QRCode.toDataURL(otpauthUri, {
      errorCorrectionLevel: 'M',
      margin: 2,
      width: 240,
    });
  } catch (erro) {
    throw new Error('Falha ao gerar o codigo QR: ' + erro.message);
  }
}

O nível de correção de erros M (15%) é o equilíbrio certo entre densidade e fiabilidade de leitura. Não suba para H sem motivo: aumenta a densidade do QR e dificulta a leitura em ecrãs pequenos. Forneça sempre, como alternativa, o segredo em texto para introdução manual, para o caso de a câmara do utilizador falhar.

Passo 5: Guardar o segredo cifrado na base de dados

Erro grave e frequente: guardar o segredo TOTP em texto simples. Se a base de dados vazar, todos os segundos fatores ficam comprometidos de uma vez. O segredo deve ser cifrado em repouso com uma chave que viva fora da base de dados (numa variável de ambiente ou num cofre de segredos). Aqui usamos AES-256-GCM, o padrão recomendado para cifra autenticada.

// cripto.js
import crypto from 'node:crypto';

// Chave de 32 bytes em hex, definida em variavel de ambiente
const CHAVE = Buffer.from(process.env.TOTP_ENC_KEY, 'hex');

export function cifrar(textoSimples) {
  const iv = crypto.randomBytes(12); // GCM usa IV de 12 bytes
  const cipher = crypto.createCipheriv('aes-256-gcm', CHAVE, iv);
  const cifrado = Buffer.concat([
    cipher.update(textoSimples, 'utf8'),
    cipher.final(),
  ]);
  const tag = cipher.getAuthTag();
  // Formato armazenado: iv:tag:cifrado (tudo em hex)
  return [iv.toString('hex'), tag.toString('hex'), cifrado.toString('hex')].join(':');
}

export function decifrar(armazenado) {
  const [ivHex, tagHex, dadosHex] = armazenado.split(':');
  const decipher = crypto.createDecipheriv('aes-256-gcm', CHAVE, Buffer.from(ivHex, 'hex'));
  decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
  return Buffer.concat([
    decipher.update(Buffer.from(dadosHex, 'hex')),
    decipher.final(),
  ]).toString('utf8');
}

Gere a chave de cifra uma vez com openssl rand -hex 32 e guarde-a numa variável de ambiente TOTP_ENC_KEY. Nunca a coloque no repositório de código. Se quiser aprofundar a cifra simétrica, veja o nosso guia dedicado ao AES-256, ligado na secção de cobertura relacionada.

Passo 6: Verificar o código de seis dígitos

Chegamos à validação. Quando o utilizador introduz o código de seis dígitos, o servidor recalcula o código esperado a partir do segredo decifrado e compara. O otplib faz a comparação em tempo constante, evitando fugas por timing. Acrescente ao totp.js:

// totp.js (continuacao)
// Devolve true se o codigo corresponder a janela atual (ou adjacente)
export function verificarCodigo(codigo, segredo) {
  // Normaliza: remove espacos que apps por vezes inserem
  const limpo = String(codigo).replace(/\s+/g, '');
  if (!/^\d{6}$/.test(limpo)) return false;
  try {
    return authenticator.check(limpo, segredo);
  } catch {
    return false;
  }
}

Repare em duas defesas. Primeiro, validamos o formato (exatamente seis dígitos) antes de chamar o otplib, o que descarta de imediato entradas inválidas. Segundo, envolvemos a verificação num try/catch porque um segredo malformado faz o otplib lançar uma exceção, e queremos devolver false em vez de derrubar o servidor. O método check respeita a opção window: 1, aceitando o código da janela anterior e da seguinte para tolerar pequena deriva de relógio.

Passo 7: Gerar e validar códigos de recuperação

O que acontece se o utilizador perder o telemóvel? Sem plano B, fica trancado para fora da conta. Os códigos de recuperação resolvem isto: um conjunto de códigos de uso único, gerados no registo, que o utilizador guarda em local seguro. Guardamo-los com hash, nunca em texto simples, exatamente como faríamos com palavras-passe.

// recuperacao.js
import crypto from 'node:crypto';

// Gera 10 codigos de recuperacao legiveis
export function gerarCodigosRecuperacao(quantidade = 10) {
  const codigos = [];
  for (let i = 0; i < quantidade; i++) {
    // 5 bytes -> 10 caracteres hex, agrupados em xxxxx-xxxxx
    const bruto = crypto.randomBytes(5).toString('hex');
    codigos.push(bruto.slice(0, 5) + '-' + bruto.slice(5));
  }
  return codigos;
}

// Guarda apenas o hash SHA-256 de cada codigo
export function fazerHashCodigos(codigos) {
  return codigos.map((c) =>
    crypto.createHash('sha256').update(c).digest('hex')
  );
}

// Verifica um codigo contra a lista de hashes guardada
export function validarCodigoRecuperacao(codigo, hashesGuardados) {
  const hash = crypto.createHash('sha256').update(codigo.trim()).digest('hex');
  return hashesGuardados.indexOf(hash); // -1 se invalido; senao, o indice
}

Quando um código de recuperação é usado com sucesso, remova-o (ou marque-o como consumido) na base de dados a partir do índice devolvido. Cada código serve uma única vez. Mostre os dez códigos ao utilizador apenas uma vez, no ecrã de ativação, e avise para os guardar antes de continuar.

Passo 8: Integrar o 2FA no fluxo de login com JWT

A autenticação de dois fatores encaixa-se a meio do login, não no início. O fluxo correto tem dois andares: primeiro a palavra-passe, depois o segundo fator. Só após ambos é que emitimos o token de sessão completo. Para a fase intermédia usamos um token temporário, de curta duração, que prova que a palavra-passe já passou mas que ainda falta o segundo fator.

// auth.js
import jwt from 'jsonwebtoken';

const SEGREDO_JWT = process.env.JWT_SECRET;

// Token intermedio: palavra-passe correta, falta o 2FA
export function emitirTokenParcial(userId) {
  return jwt.sign({ sub: userId, mfa: 'pendente' }, SEGREDO_JWT, {
    expiresIn: '5m',
  });
}

// Token de sessao completo: ambos os fatores validados
export function emitirTokenCompleto(userId) {
  return jwt.sign({ sub: userId, mfa: 'ok' }, SEGREDO_JWT, {
    expiresIn: '1h',
  });
}

// Middleware que exige sessao totalmente autenticada
export function exigirMfaCompleto(req, res, next) {
  const cabecalho = req.headers.authorization || '';
  const token = cabecalho.replace('Bearer ', '');
  try {
    const dados = jwt.verify(token, SEGREDO_JWT);
    if (dados.mfa !== 'ok') {
      return res.status(403).json({ erro: 'Segundo fator em falta' });
    }
    req.userId = dados.sub;
    next();
  } catch {
    return res.status(401).json({ erro: 'Token invalido' });
  }
}

Esta separação evita um erro clássico: emitir a sessão completa logo após a palavra-passe e tratar o 2FA como mera formalidade ignorável. Com o token parcial, qualquer endpoint protegido recusa o acesso até o segundo fator estar validado. Para mais detalhe sobre tokens de sessão, veja o nosso tutorial de autenticação JWT, ligado em baixo.

Passo 9: Lidar com a deriva de relógio (window)

O TOTP depende de relógios sincronizados. Se o relógio do telemóvel do utilizador estiver atrasado ou adiantado alguns segundos, o código que ele vê pode não coincidir com o que o servidor calcula nesse instante. A opção window resolve a maior parte destes casos ao aceitar também os códigos das janelas vizinhas.

Valor de windowJanelas aceitesTolerância totalRecomendação
0Só a atualAté 30 sDemasiado rígido
1Anterior, atual, seguinteCerca de 90 sEquilíbrio recomendado
2Duas para cada ladoCerca de 150 sSó com relógios pouco fiáveis
4 ou maisQuatro ou maisMais de 4 minRisco de segurança, evitar
Efeito do parâmetro window na tolerância e na segurança.

Não caia na tentação de subir o window para reduzir queixas de suporte. Cada janela extra alarga a janela de oportunidade para um atacante reutilizar um código capturado. O valor 1 que definimos no passo 3 cobre a esmagadora maioria dos casos reais. Garanta antes que o relógio do seu servidor está sincronizado via NTP, porque a causa mais comum de falhas não é o telemóvel do utilizador, é o servidor com a hora errada.

Passo 10: Proteger contra força bruta (rate limiting)

Um código de seis dígitos tem apenas um milhão de combinações. Sem proteção, um atacante com a palavra-passe correta poderia tentar todos os códigos por força bruta até acertar dentro da janela de tolerância. A defesa é limitar as tentativas por conta e bloquear temporariamente após um número pequeno de falhas.

// limitador.js
// Limitador simples em memoria (use Redis em producao)
const tentativas = new Map();

const MAX_FALHAS = 5;
const JANELA_MS = 15 * 60 * 1000; // 15 minutos

export function podeTentar(userId) {
  const registo = tentativas.get(userId);
  if (!registo) return true;
  if (Date.now() > registo.ate) {
    tentativas.delete(userId);
    return true;
  }
  return registo.falhas < MAX_FALHAS;
}

export function registarFalha(userId) {
  const registo = tentativas.get(userId) || { falhas: 0, ate: Date.now() + JANELA_MS };
  registo.falhas += 1;
  registo.ate = Date.now() + JANELA_MS;
  tentativas.set(userId, registo);
}

export function limparTentativas(userId) {
  tentativas.delete(userId);
}

Em produção, troque o Map em memória por Redis ou outra cache partilhada, caso contrário o limite não funciona com vários processos ou servidores. Limpe o contador após uma verificação bem-sucedida com limparTentativas, para que um utilizador legítimo que erre o código uma vez não fique penalizado depois de acertar.

Passo 11: Testar o fluxo completo

Antes de montar o servidor final, valide a lógica de ponta a ponta com um pequeno script. Isto confirma que a geração do segredo, a criação do código e a verificação encaixam, sem precisar de telemóvel.

// teste.js
import { authenticator } from 'otplib';
import { gerarSegredo, verificarCodigo } from './totp.js';

const segredo = gerarSegredo();
console.log('Segredo Base32:', segredo);

// Simula a app: gera o codigo atual a partir do mesmo segredo
const codigoAtual = authenticator.generate(segredo);
console.log('Codigo gerado:', codigoAtual);

console.log('Verificacao valida:', verificarCodigo(codigoAtual, segredo));
console.log('Verificacao invalida:', verificarCodigo('000000', segredo));

Execute com node teste.js. A saída esperada confirma que o código gerado passa e que um código aleatório falha:

Segredo Base32: KZXW6YTBOI5XW4TBORSXG===
Codigo gerado: 482915
Verificacao valida: true
Verificacao invalida: false

Se a verificação válida devolver false, o problema quase sempre é o relógio do sistema. Sincronize-o e repita. Quando este teste passa, a base criptográfica está correta e pode confiar nela no servidor.

Passo 12: Projeto completo funcional (server.js)

Juntamos tudo num servidor Express com os três endpoints essenciais: ativar o 2FA (devolve o QR), confirmar a ativação, e fazer login com o segundo fator. Para manter o exemplo focado, usamos um objeto em memória como base de dados; substitua-o pela sua camada de persistência real.

// server.js
import express from 'express';
import { gerarSegredo, gerarOtpauthUri, gerarQrCode, verificarCodigo } from './totp.js';
import { cifrar, decifrar } from './cripto.js';
import { gerarCodigosRecuperacao, fazerHashCodigos } from './recuperacao.js';
import { emitirTokenCompleto, exigirMfaCompleto } from './auth.js';
import { podeTentar, registarFalha, limparTentativas } from './limitador.js';

const app = express();
app.use(express.json());

// "Base de dados" em memoria apenas para demonstracao
const utilizadores = new Map();
utilizadores.set('[email protected]', { id: 'u1', mfaAtivo: false, segredo: null });

// 1) Iniciar ativacao do 2FA: devolve QR e segredo
app.post('/2fa/ativar', async (req, res) => {
  const { email } = req.body;
  const u = utilizadores.get(email);
  if (!u) return res.status(404).json({ erro: 'Utilizador nao encontrado' });

  const segredo = gerarSegredo();
  const uri = gerarOtpauthUri(email, segredo);
  const qr = await gerarQrCode(uri);

  // Guarda o segredo cifrado, ainda como pendente
  u.segredoPendente = cifrar(segredo);
  res.json({ qr, segredoManual: segredo });
});

// 2) Confirmar ativacao: o utilizador introduz o primeiro codigo
app.post('/2fa/confirmar', (req, res) => {
  const { email, codigo } = req.body;
  const u = utilizadores.get(email);
  if (!u || !u.segredoPendente) return res.status(400).json({ erro: 'Sem ativacao pendente' });

  const segredo = decifrar(u.segredoPendente);
  if (!verificarCodigo(codigo, segredo)) {
    return res.status(401).json({ erro: 'Codigo invalido' });
  }

  u.segredo = u.segredoPendente;
  u.mfaAtivo = true;
  delete u.segredoPendente;

  const recuperacao = gerarCodigosRecuperacao();
  u.recuperacaoHashes = fazerHashCodigos(recuperacao);
  res.json({ ativo: true, codigosRecuperacao: recuperacao });
});

// 3) Login com segundo fator (palavra-passe ja validada antes)
app.post('/login/2fa', (req, res) => {
  const { email, codigo } = req.body;
  const u = utilizadores.get(email);
  if (!u || !u.mfaAtivo) return res.status(400).json({ erro: '2FA nao ativo' });

  if (!podeTentar(u.id)) {
    return res.status(429).json({ erro: 'Demasiadas tentativas. Tente mais tarde.' });
  }

  const segredo = decifrar(u.segredo);
  if (!verificarCodigo(codigo, segredo)) {
    registarFalha(u.id);
    return res.status(401).json({ erro: 'Codigo invalido' });
  }

  limparTentativas(u.id);
  res.json({ token: emitirTokenCompleto(u.id) });
});

// Endpoint protegido de exemplo
app.get('/perfil', exigirMfaCompleto, (req, res) => {
  res.json({ mensagem: 'Acesso concedido', userId: req.userId });
});

app.listen(3000, () => console.log('API 2FA a correr em http://localhost:3000'));

Arranque com npm run dev. Teste a ativação com um pedido ao endpoint /2fa/ativar, leia o QR com a sua app, e confirme com /2fa/confirmar. A partir daí, cada login passa por /login/2fa. Tem agora um sistema de autenticação de dois fatores funcional, com segredo cifrado, códigos de recuperação e limitação de tentativas.

Erros comuns ao implementar 2FA

Cinco armadilhas aparecem repetidamente em implementações reais. Evitá-las desde o início poupa horas de depuração e fecha buracos de segurança.

  • Guardar o segredo em texto simples. Se a base de dados vazar, todos os segundos fatores caem de uma vez. Cifre sempre em repouso, como no passo 5.
  • Não confirmar a ativação. Ativar o 2FA sem pedir um primeiro código válido tranca utilizadores que leram mal o QR. Exija sempre um código de confirmação antes de marcar o 2FA como ativo.
  • Esquecer os códigos de recuperação. Sem plano B, um telemóvel perdido é uma conta perdida. Gere os códigos no registo e guarde apenas o hash.
  • Abrir demasiado o window. Subir a tolerância para calar queixas de suporte alarga a janela de ataque. Sincronize o relógio do servidor em vez de relaxar a verificação.
  • Não limitar tentativas. Seis dígitos são um milhão de hipóteses. Sem rate limiting, a força bruta torna-se viável. Bloqueie após poucas falhas.

Resolução de problemas (troubleshooting)

Os oito problemas seguintes cobrem quase todos os pedidos de ajuda que surgem ao implementar TOTP em Node.js. A tabela liga sintoma a causa e solução.

SintomaCausa provávelSolução
Código sempre inválidoRelógio do servidor dessincronizadoAtivar NTP no servidor; testar com data correta
App não lê o QRURI otpauth malformada ou emissor com carateres especiaisValidar a URI; usar emissor alfanumérico simples
otplib lança exceçãoSegredo não está em Base32 válidoGerar o segredo só com generateSecret; não inventar
Código válido na app, falha no servidorAlgoritmo ou dígitos diferentes do padrãoFixar digits 6, step 30, algorithm sha1
Erro ao decifrar o segredoTOTP_ENC_KEY mudou ou está em faltaGarantir chave estável e com 64 carateres hex
Login passa sem 2FAEndpoint protegido aceita token parcialVerificar mfa igual a ok no middleware
Erro de import/módulotype não definido como moduleDefinir type module no package.json
Express 5 dá erro de rotaSintaxe de rota antiga incompatívelAtualizar padrões de rota ou usar Node 22+
Oito problemas comuns de TOTP em Node.js, com causa e solução.

Para diagnosticar o problema mais frequente, o relógio, gere no servidor e no telemóvel o código no mesmo segredo e compare. Se diferem, a hora é a culpada. Sincronize via NTP e o sintoma desaparece quase sempre.

Dicas avançadas e o caminho para as passkeys

Com o sistema base a funcionar, há melhorias que elevam a robustez sem grande esforço. A primeira é guardar a última janela TOTP usada por cada utilizador e recusar a sua reutilização. Isto impede o ataque em que um código capturado é reenviado dentro da mesma janela de 30 segundos, fechando parcialmente a brecha de phishing em tempo real do TOTP.

A segunda é forçar HTTPS e cookies seguros em todo o fluxo, com a flag HttpOnly e SameSite=Strict nos cookies de sessão. O 2FA protege o login, mas não serve de nada se a sessão for roubada por um canal inseguro depois. A terceira é registar eventos de segurança: ativações de 2FA, falhas repetidas, uso de códigos de recuperação. Estes registos alimentam alertas e ajudam na resposta a incidentes.

Olhando para a frente, o destino são as passkeys. A norma FIDO2/WebAuthn liga a credencial à origem do site, o que torna o phishing praticamente inútil: uma passkey criada para o seu domínio não funciona num domínio falso, ao contrário de um código TOTP que o utilizador pode ser enganado a escrever em qualquer lado. A estratégia sensata em 2026 é oferecer TOTP hoje, como segundo fator universal, e adicionar passkeys como opção preferencial, mantendo o TOTP como alternativa de compatibilidade. Para o enquadramento de boas práticas de MFA, a folha de dicas de MFA da OWASP é a referência a consultar.

Comparação: TOTP, SMS e passkeys

Para escolher bem o segundo fator, convém ter os três métodos lado a lado. A tabela resume as diferenças que mais pesam na decisão.

CritérioSMSTOTP (este tutorial)Passkey (FIDO2)
Resistente a SIM swapNãoSimSim
Resistente a phishingNãoParcialSim
Funciona offlineNãoSimSim
Custo por códigoPago (gateway SMS)ZeroZero
Suporte universal de appsTotalTotalA crescer
Esforço de implementaçãoBaixoMédioMédio a alto
Comparação dos três segundos fatores mais comuns em 2026.

A leitura é clara. O SMS está em fim de vida como fator sério por causa do SIM swap e do custo. O TOTP é o equilíbrio prático para implementar já, sem custos e com suporte universal. A passkey é o futuro resistente a phishing, mas ainda exige mais trabalho e suporte do lado do cliente. Implementar TOTP hoje não fecha portas: é o degrau intermédio certo no caminho para as passkeys.

Perguntas frequentes

O TOTP é mesmo seguro em 2026?

Sim, para a maioria das aplicações. O TOTP elimina o risco de SIM swap do SMS e funciona offline. A sua única fraqueza séria é o phishing em tempo real, mitigado com limitação de tentativas, recusa de reutilização de códigos e, idealmente, com a adoção futura de passkeys. Para um serviço comum, o TOTP bem implementado é uma melhoria enorme face a só palavra-passe.

Devo usar otplib ou speakeasy?

Recomendamos o otplib (13.4.1). É mantido ativamente, tem API moderna e separa TOTP de HOTP de forma limpa. O speakeasy (2.0.0) ainda funciona, mas não recebe atualizações há bastante tempo. Para projetos novos em 2026, o otplib é a escolha mais segura a longo prazo.

Porque é que o código do utilizador às vezes falha?

Na esmagadora maioria dos casos, é deriva de relógio. Se o servidor não estiver sincronizado por NTP, calcula códigos para um instante ligeiramente diferente do telemóvel. A opção window: 1 tolera pequenas diferenças, mas a solução de raiz é manter a hora do servidor correta.

Preciso de cifrar o segredo na base de dados?

Sim, sem exceção. O segredo TOTP é equivalente a uma palavra-passe permanente: quem o tiver gera códigos válidos para sempre. Cifre-o em repouso com AES-256-GCM e uma chave guardada fora da base de dados, como mostrámos no passo 5.

Quantos códigos de recuperação devo gerar?

Dez é o padrão da indústria e o valor que usámos. Cada um serve uma única vez. Guarde apenas o hash de cada código e mostre os códigos em texto ao utilizador só uma vez, no momento da ativação, com aviso claro para os guardar em local seguro.

O TOTP substitui a palavra-passe?

Não. O TOTP é um segundo fator, não o primeiro. Combina-se com a palavra-passe para formar a autenticação de dois fatores. Para substituir totalmente a palavra-passe precisaria de passkeys, que podem funcionar como fator único resistente a phishing. Até lá, palavra-passe forte mais TOTP é a base recomendada.

Posso usar SHA-256 em vez de SHA-1 no TOTP?

Tecnicamente sim, mas perde compatibilidade. Muitas apps de autenticação assumem HMAC-SHA1 e ignoram o parâmetro de algoritmo na URI otpauth. No contexto do HMAC-TOTP, o SHA-1 mantém-se seguro, por isso a recomendação prática é fixar SHA1 para garantir que todas as apps leem corretamente.

Cobertura relacionada

Fontes técnicas e normas: RFC 6238 (TOTP), RFC 4226 (HOTP), OWASP MFA Cheat Sheet, FIDO Alliance (passkeys) e Node.js (versões LTS).