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âmetro | Valor padrão | Google Authenticator | Microsoft Authenticator | Authy |
|---|---|---|---|---|
| Período (janela) | 30 segundos | 30 s | 30 s | 30 s |
| Dígitos | 6 | 6 | 6 | 6 |
| Algoritmo | HMAC-SHA1 | SHA1 | SHA1 | SHA1 |
| Codificação do segredo | Base32 | Base32 | Base32 | Base32 |
| Norma | RFC 6238 | RFC 6238 | RFC 6238 | RFC 6238 |
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.
| Ferramenta | Versão usada | Função no projeto |
|---|---|---|
| Node.js | 22 LTS ou 24 LTS | Ambiente de execução |
| npm | 10 ou superior | Gestor de pacotes |
| otplib | 13.4.1 | Geração e verificação de TOTP |
| qrcode | 1.5.4 | Gerar o código QR de registo |
| express | 5.2.1 | Servidor HTTP da API |
| jsonwebtoken | 9.x | Emitir o token de sessão |
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 window | Janelas aceites | Tolerância total | Recomendação |
|---|---|---|---|
| 0 | Só a atual | Até 30 s | Demasiado rígido |
| 1 | Anterior, atual, seguinte | Cerca de 90 s | Equilíbrio recomendado |
| 2 | Duas para cada lado | Cerca de 150 s | Só com relógios pouco fiáveis |
| 4 ou mais | Quatro ou mais | Mais de 4 min | Risco de segurança, evitar |
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.
| Sintoma | Causa provável | Solução |
|---|---|---|
| Código sempre inválido | Relógio do servidor dessincronizado | Ativar NTP no servidor; testar com data correta |
| App não lê o QR | URI otpauth malformada ou emissor com carateres especiais | Validar a URI; usar emissor alfanumérico simples |
| otplib lança exceção | Segredo não está em Base32 válido | Gerar o segredo só com generateSecret; não inventar |
| Código válido na app, falha no servidor | Algoritmo ou dígitos diferentes do padrão | Fixar digits 6, step 30, algorithm sha1 |
| Erro ao decifrar o segredo | TOTP_ENC_KEY mudou ou está em falta | Garantir chave estável e com 64 carateres hex |
| Login passa sem 2FA | Endpoint protegido aceita token parcial | Verificar mfa igual a ok no middleware |
| Erro de import/módulo | type não definido como module | Definir type module no package.json |
| Express 5 dá erro de rota | Sintaxe de rota antiga incompatível | Atualizar padrões de rota ou usar Node 22+ |
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ério | SMS | TOTP (este tutorial) | Passkey (FIDO2) |
|---|---|---|---|
| Resistente a SIM swap | Não | Sim | Sim |
| Resistente a phishing | Não | Parcial | Sim |
| Funciona offline | Não | Sim | Sim |
| Custo por código | Pago (gateway SMS) | Zero | Zero |
| Suporte universal de apps | Total | Total | A crescer |
| Esforço de implementação | Baixo | Médio | Médio a alto |
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
- Autenticação JWT em Node.js: 12 passos
- Argon2 para hashing de palavras-passe em Node.js
- Cifra AES-256 em Node.js, passo a passo
- Segurança de palavras-passe: o que protege mesmo as contas
- Ataques de phishing: como reconhecer e evitar
- Guia prático de segurança online (página pilar)
Fontes técnicas e normas: RFC 6238 (TOTP), RFC 4226 (HOTP), OWASP MFA Cheat Sheet, FIDO Alliance (passkeys) e Node.js (versões LTS).




