As passkeys eliminam palavras-passe e resistem a ataques de phishing por design. Com o WebAuthn, a autenticação baseia-se em criptografia de chave pública: a chave privada nunca abandona o dispositivo do utilizador. Este tutorial mostra como implementar WebAuthn em Node.js com a biblioteca SimpleWebAuthn 13.3 em 12 passos práticos, cobrindo registo, autenticação e boas práticas para produção.
O Que é WebAuthn e Porque Importa em 2026
WebAuthn (Web Authentication API) é o padrão W3C que define como aplicações web comunicam com autenticadores de hardware e software para autenticação sem palavras-passe. Faz parte do ecossistema FIDO2, que combina o protocolo CTAP2 (para comunicação com autenticadores físicos como YubiKeys) com a API do browser.
A adoção cresceu significativamente em 2025 e 2026. A Google, Apple e Microsoft ativaram suporte a passkeys em todos os seus sistemas operativos. O Chrome, Safari e Firefox suportam a WebAuthn API nos seus browsers mais recentes. Para um developer Node.js em Portugal, implementar passkeys em 2026 é uma opção madura e recomendada para novos projetos.
O mecanismo central funciona assim: o servidor gera um challenge aleatório, o browser invoca o autenticador do utilizador (biometria, PIN, chave de hardware), o autenticador assina o challenge com a chave privada, e o servidor verifica a assinatura com a chave pública previamente registada. A chave privada nunca viaja pela rede.
Em termos práticos, isto elimina três vetores de ataque frequentes: roubo de base de dados de passwords com hash, phishing de credenciais (a assinatura é vinculada à origem), e ataques de força bruta. A segurança é estrutural, não dependente da qualidade da palavra-passe escolhida pelo utilizador.
O FIDO2 é um conjunto de dois padrões abertos: o protocolo CTAP2 (Client to Authenticator Protocol) gere a comunicação entre o browser e o autenticador, enquanto o WebAuthn gere a comunicação entre o browser e o servidor. Esta separação de responsabilidades torna o sistema extensível, permitindo suporte tanto a autenticadores incorporados no dispositivo (Touch ID, Windows Hello) como a chaves de hardware físicas (YubiKey, Google Titan).
Passkeys vs Autenticação Tradicional: Comparação Técnica
Antes de começar a implementar, é útil ter uma visão clara das diferenças entre autenticação com palavras-passe e WebAuthn. A tabela abaixo resume os pontos críticos para a decisão de arquitetura:
| Critério | Password + bcrypt | WebAuthn / Passkey |
|---|---|---|
| Armazenamento no servidor | Hash da palavra-passe | Chave pública + contador |
| Resistência a phishing | Nenhuma | Total (vinculado à origem) |
| Exposição por breach | Hashes expõem credenciais | Chave pública não é utilizável |
| Experiência do utilizador | Digitar palavra-passe | Biometria / PIN (menos de 3 segundos) |
| Suporte browsers 2026 | Universal | Chrome, Safari, Firefox, Edge |
| Requisito HTTPS | Recomendado | Obrigatório |
| Complexidade servidor | Baixa | Média (verificação criptográfica) |
| Suporte multi-dispositivo | Imediato | Requer sincronização (iCloud, Google) |
| Recuperação de conta | Reset por email | Chave de recuperação ou método alternativo |
| Custo de implementação | 1 a 2 dias | 3 a 5 dias (com biblioteca) |
Uma passkey pode ser de plataforma (armazenada no dispositivo e sincronizada via iCloud Keychain ou Google Password Manager) ou de roaming (chave de hardware como YubiKey). Para a maioria das aplicações web em 2026, as passkeys de plataforma cobrem a esmagadora maioria dos casos de uso dos utilizadores.
Pré-requisitos
Antes de começar, verifica se tens o seguinte instalado e configurado:
- Node.js 20.x ou superior (obrigatório para
@simplewebauthn/[email protected]) - npm 10.x ou pnpm/yarn equivalente
- Express 5.x para o servidor HTTP
- express-session 1.19.x para gestão de sessões
- Um domínio com HTTPS configurado ou localhost para desenvolvimento
- Conhecimento básico de Node.js e HTTP/REST
- Browser com suporte a WebAuthn: Chrome 120+, Firefox 120+, Safari 17+, Edge 120+
- Dispositivo com autenticador: Windows Hello, Touch ID, Face ID, YubiKey, ou equivalente
Para desenvolvimento local, o WebAuthn funciona em localhost sem HTTPS. Em produção, HTTPS é obrigatório pelo standard. Caso estejas a testar num servidor remoto, configura um certificado Let’s Encrypt antes de avançar.
Verifica as versões instaladas antes de começar:
node --version # deve retornar v20.x.x ou superior
npm --version # deve retornar 10.x.x ou superior
Passo 1: Criar o Projeto Node.js
Começa por criar a estrutura do projeto. Vamos construir uma aplicação Express com suporte a WebAuthn que inclui endpoints de registo e autenticação, uma base de dados em memória substituível por PostgreSQL ou MongoDB, e uma interface frontend simples para testar o fluxo completo.
mkdir webauthn-demo && cd webauthn-demo
npm init -y
mkdir -p src public src/routes src/middleware
Edita o package.json para adicionar o campo type e o script de arranque:
{
"name": "webauthn-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
}
}
O campo "type": "module" é necessário porque o @simplewebauthn/server 13.x é um pacote ESM puro. Se precisares de CommonJS, usa a versão 9.x que ainda suporta require(), mas recomendamos ESM para novos projetos em 2026.
Passo 2: Instalar as Dependências
Instala os pacotes necessários para o servidor e para o cliente:
npm install express@5 [email protected] @simplewebauthn/server@13 @simplewebauthn/browser@13
O que cada pacote faz:
@simplewebauthn/[email protected]: Gera as opções de registo/autenticação e verifica as respostas dos autenticadores. O núcleo da implementação no lado servidor.@simplewebauthn/[email protected]: Simplifica as chamadas à WebAuthn API do browser. Usa esta no frontend.[email protected]: Framework HTTP. A versão 5 tem melhor suporte aasync/awaitsem necessitar de wrappers para capturar erros.[email protected]: Gestão de sessões do lado servidor, necessária para armazenar o challenge temporariamente entre requests.
Opcionalmente, para produção com base de dados real e armazenamento de sessões persistente:
# Para PostgreSQL
npm install pg
# Para armazenar sessões em Redis (recomendado em produção)
npm install connect-redis redis
# Para variáveis de ambiente
npm install dotenv
Passo 3: Configurar o Servidor Express
Cria o ficheiro src/server.js com a configuração base do servidor. Os valores RP_ID e ORIGIN são os dois parâmetros mais críticos de toda a implementação WebAuthn:
import express from 'express';
import session from 'express-session';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;
// RP (Relying Party) - configuração obrigatória do WebAuthn
export const RP_NAME = process.env.RP_NAME || 'WebAuthn Demo';
export const RP_ID = process.env.RP_ID || 'localhost';
export const ORIGIN = process.env.ORIGIN || `http://localhost:${PORT}`;
app.use(express.json());
app.use(express.static(join(__dirname, '../public')));
app.use(session({
secret: process.env.SESSION_SECRET || 'muda-este-segredo-em-producao-minimo-32-chars',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'strict',
maxAge: 5 * 60 * 1000 // challenge expira em 5 minutos
}
}));
// Importar rotas
import { registerRoutes } from './routes/register.js';
import { authRoutes } from './routes/auth.js';
app.use('/api/register', registerRoutes);
app.use('/api/auth', authRoutes);
app.listen(PORT, () => {
console.log(`Servidor WebAuthn em http://localhost:${PORT}`);
console.log(`RP_ID: ${RP_ID} | ORIGIN: ${ORIGIN}`);
});
export default app;
Dois pontos críticos desta configuração: o RP_ID deve corresponder ao domínio da aplicação (sem porta, sem protocolo), e o ORIGIN deve incluir o protocolo e a porta. Uma discrepância entre estes valores é a causa mais comum de falhas na verificação WebAuthn com a mensagem “Expected RP_ID to be X, got Y”.
Passo 4: Configurar a Base de Dados
Para este tutorial, usamos uma base de dados em memória para simplificar. Em produção, substitui por PostgreSQL ou MongoDB. Cria o ficheiro src/db.js:
// Base de dados em memória - substituir por PostgreSQL/MongoDB em produção
const users = new Map(); // userId -> { id, username, credentials[] }
const credentials = new Map(); // credentialId -> credentialData
export function getUserByUsername(username) {
for (const user of users.values()) {
if (user.username === username) return user;
}
return null;
}
export function getUserById(userId) {
return users.get(userId) || null;
}
export function createUser(userId, username) {
const user = { id: userId, username, credentials: [] };
users.set(userId, user);
return user;
}
export function saveCredential(userId, credentialData) {
const user = users.get(userId);
if (!user) throw new Error('Utilizador não encontrado');
// credentialData contém: id, publicKey, counter, transports, aaguid
const credId = Buffer.from(credentialData.id).toString('base64url');
const cred = {
...credentialData,
userId,
createdAt: new Date().toISOString(),
lastUsed: null,
};
credentials.set(credId, cred);
user.credentials.push(credId);
return credId;
}
export function getCredential(credentialId) {
return credentials.get(credentialId) || null;
}
export function updateCredentialCounter(credentialId, newCounter) {
const cred = credentials.get(credentialId);
if (cred) {
cred.counter = newCounter;
cred.lastUsed = new Date().toISOString();
credentials.set(credentialId, cred);
}
}
export function getUserCredentials(userId) {
const user = users.get(userId);
if (!user) return [];
return user.credentials.map(id => credentials.get(id)).filter(Boolean);
}
export function removeCredential(userId, credentialId) {
const user = users.get(userId);
if (!user) return;
user.credentials = user.credentials.filter(id => id !== credentialId);
credentials.delete(credentialId);
}
Em produção com PostgreSQL, o esquema da tabela de credenciais deve incluir as colunas: credential_id (VARCHAR PRIMARY KEY), user_id (FK), public_key (BYTEA), counter (BIGINT), transports (TEXT[]), aaguid (UUID), device_type (VARCHAR), backed_up (BOOLEAN), created_at e last_used_at (TIMESTAMP WITH TIME ZONE).
Passo 5: Implementar o Registo WebAuthn (Gerar Opções)
O fluxo de registo tem duas fases: gerar as opções e verificar a resposta. Cria o ficheiro src/routes/register.js:
import { Router } from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {
getUserByUsername,
createUser,
getUserCredentials,
saveCredential,
} from '../db.js';
import { RP_NAME, RP_ID, ORIGIN } from '../server.js';
import { randomBytes } from 'crypto';
export const registerRoutes = Router();
// Fase 1 do registo: gerar opções para o browser
registerRoutes.post('/generate-options', async (req, res) => {
const { username } = req.body;
if (!username || typeof username !== 'string' || username.trim().length < 3) {
return res.status(400).json({ error: 'Nome de utilizador inválido (mínimo 3 caracteres)' });
}
const cleanUsername = username.trim().toLowerCase();
let user = getUserByUsername(cleanUsername);
if (!user) {
const userId = randomBytes(16).toString('hex');
user = createUser(userId, cleanUsername);
}
const existingCredentials = getUserCredentials(user.id);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userName: cleanUsername,
userDisplayName: cleanUsername,
// Impede registo duplicado do mesmo autenticador
excludeCredentials: existingCredentials.map(cred => ({
id: cred.id,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: 'preferred', // permite passkeys sincronizadas
userVerification: 'preferred',
},
timeout: 60000,
});
// Armazenar challenge na sessão (TTL de 5 minutos via cookie maxAge)
req.session.registrationChallenge = options.challenge;
req.session.pendingUserId = user.id;
res.json(options);
});
A opção residentKey: 'preferred' instrui o autenticador a criar uma passkey descobrível (discoverable credential), que permite ao utilizador autenticar-se sem introduzir primeiro o nome de utilizador. Com residentKey: 'required', o autenticador é obrigado a suportar este modo, o que pode excluir alguns autenticadores mais antigos.
Passo 6: Verificar o Registo no Servidor
Adiciona o endpoint de verificação ao mesmo ficheiro src/routes/register.js. Esta é a fase mais importante: o servidor valida criptograficamente a resposta do autenticador:
// Fase 2 do registo: verificar resposta do autenticador
registerRoutes.post('/verify', async (req, res) => {
const { body } = req;
const expectedChallenge = req.session.registrationChallenge;
const userId = req.session.pendingUserId;
if (!expectedChallenge || !userId) {
return res.status(400).json({ error: 'Sessão de registo inválida ou expirada. Tenta novamente.' });
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: true,
});
} catch (error) {
console.error('Erro na verificação do registo:', error.message);
return res.status(400).json({ error: error.message });
}
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return res.status(400).json({ error: 'Verificação criptográfica falhou' });
}
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
// Guardar credencial na base de dados
saveCredential(userId, {
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: body.response.transports || [],
aaguid: registrationInfo.aaguid,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
// Limpar challenge da sessão (uso único obrigatório)
delete req.session.registrationChallenge;
delete req.session.pendingUserId;
// Autenticar o utilizador após registo bem-sucedido
req.session.userId = userId;
res.json({
verified: true,
message: 'Passkey registada com sucesso',
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
});
O campo credentialBackedUp indica se a passkey está sincronizada na nuvem (iCloud Keychain, Google Password Manager). Esta informação é útil para exibir ao utilizador se a sua passkey está protegida contra perda de dispositivo, e para tomar decisões sobre quantas passkeys são necessárias para uma conta segura.
Passo 7: Implementar a Autenticação WebAuthn
O fluxo de autenticação segue a mesma estrutura de duas fases. Cria src/routes/auth.js:
import { Router } from 'express';
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import {
getUserByUsername,
getUserCredentials,
getCredential,
updateCredentialCounter,
} from '../db.js';
import { RP_ID, ORIGIN } from '../server.js';
export const authRoutes = Router();
// Fase 1 da autenticação: gerar opções e challenge
authRoutes.post('/generate-options', async (req, res) => {
const { username } = req.body;
const user = getUserByUsername(username?.trim().toLowerCase());
if (!user) {
return res.status(400).json({ error: 'Utilizador não encontrado ou sem passkeys registadas' });
}
const userCredentials = getUserCredentials(user.id);
if (userCredentials.length === 0) {
return res.status(400).json({ error: 'Nenhuma passkey registada para este utilizador' });
}
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: userCredentials.map(cred => ({
id: cred.id,
transports: cred.transports,
})),
userVerification: 'preferred',
timeout: 60000,
});
req.session.authChallenge = options.challenge;
req.session.authUserId = user.id;
res.json(options);
});
// Fase 2 da autenticação: verificar assinatura criptográfica
authRoutes.post('/verify', async (req, res) => {
const { body } = req;
const expectedChallenge = req.session.authChallenge;
const userId = req.session.authUserId;
if (!expectedChallenge || !userId) {
return res.status(400).json({ error: 'Sessão de autenticação inválida ou expirada' });
}
// Encontrar a credencial específica usada pelo utilizador
const credentialId = body.id;
const credential = getCredential(credentialId);
if (!credential || credential.userId !== userId) {
return res.status(400).json({ error: 'Credencial não encontrada ou não pertence a este utilizador' });
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
requireUserVerification: true,
});
} catch (error) {
console.error('Erro na verificação da autenticação:', error.message);
return res.status(400).json({ error: error.message });
}
const { verified, authenticationInfo } = verification;
if (!verified) {
return res.status(400).json({ error: 'Autenticação falhou: assinatura inválida' });
}
// Atualizar contador para detetar clonagem de credenciais
updateCredentialCounter(credentialId, authenticationInfo.newCounter);
// Limpar challenge (uso único) e estabelecer sessão
delete req.session.authChallenge;
delete req.session.authUserId;
req.session.userId = userId;
res.json({ verified: true, message: 'Autenticação bem-sucedida' });
});
Passo 8: Criar o Frontend HTML e JavaScript
Cria o ficheiro public/index.html com a interface de utilizador completa. O SimpleWebAuthn Browser abstrai as chamadas nativas da WebAuthn API e trata automaticamente da serialização/deserialização de ArrayBuffers:
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebAuthn Demo</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 48px auto; padding: 24px; }
h1 { font-size: 1.5rem; margin-bottom: 24px; }
input { padding: 10px 14px; font-size: 16px; border: 1px solid #d1d5db; border-radius: 8px; width: 100%; box-sizing: border-box; margin-bottom: 12px; }
.btn { padding: 12px 20px; margin: 6px 4px; font-size: 15px; cursor: pointer; border-radius: 8px; border: none; font-weight: 500; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-success { background: #059669; color: #fff; }
.btn-danger { background: #dc2626; color: #fff; }
#status { margin-top: 20px; padding: 14px; border-radius: 8px; display: none; font-size: 15px; }
.success { background: #d1fae5; color: #065f46; }
.error { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<h1>WebAuthn / Passkeys Demo</h1>
<input type="text" id="username" placeholder="Nome de utilizador (min. 3 caracteres)" autocomplete="username webauthn">
<div>
<button class="btn btn-primary" id="register-btn">Registar Passkey</button>
<button class="btn btn-success" id="login-btn">Entrar com Passkey</button>
</div>
<div id="status"></div>
<script type="module">
import {
startRegistration,
startAuthentication,
} from 'https://unpkg.com/@simplewebauthn/browser@13/dist/bundle/index.esm.js';
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = type;
el.style.display = 'block';
}
async function apiPost(url, data) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
const json = await resp.json();
if (!resp.ok) throw new Error(json.error || 'Erro desconhecido');
return json;
}
document.getElementById('register-btn').addEventListener('click', async () => {
const username = document.getElementById('username').value.trim();
if (!username || username.length < 3) return showStatus('Introduz um nome de utilizador (mínimo 3 caracteres)', 'error');
try {
showStatus('A comunicar com o autenticador...', 'success');
const options = await apiPost('/api/register/generate-options', { username });
const attResp = await startRegistration({ optionsJSON: options });
const result = await apiPost('/api/register/verify', attResp);
showStatus(`Passkey registada com sucesso! Tipo: ${result.deviceType || 'desconhecido'}. Sincronizada: ${result.backedUp ? 'Sim' : 'Não'}`, 'success');
} catch (err) {
if (err.name === 'InvalidStateError') {
showStatus('Este autenticador já tem uma passkey registada para esta conta.', 'error');
} else if (err.name === 'NotAllowedError') {
showStatus('Operação cancelada ou sem permissão.', 'error');
} else {
showStatus('Erro: ' + err.message, 'error');
}
}
});
document.getElementById('login-btn').addEventListener('click', async () => {
const username = document.getElementById('username').value.trim();
if (!username) return showStatus('Introduz o nome de utilizador', 'error');
try {
showStatus('A verificar a tua identidade...', 'success');
const options = await apiPost('/api/auth/generate-options', { username });
const authResp = await startAuthentication({ optionsJSON: options });
const result = await apiPost('/api/auth/verify', authResp);
showStatus(result.message || 'Autenticação bem-sucedida!', 'success');
} catch (err) {
if (err.name === 'NotAllowedError') {
showStatus('Autenticação cancelada ou sem permissão.', 'error');
} else {
showStatus('Erro: ' + err.message, 'error');
}
}
});
</script>
</body>
</html>
O atributo autocomplete="username webauthn" no campo de input é a sinalização HTML necessária para que o browser ative a UI de autofill de passkeys (Conditional UI). Sem este atributo, o browser não sugere automaticamente passkeys disponíveis.
Passo 9: Testar o Fluxo Completo
Arranca o servidor e verifica o output inicial:
# Arrancar o servidor em modo desenvolvimento com hot-reload
node --watch src/server.js
# Output esperado:
# Servidor WebAuthn em http://localhost:3000
# RP_ID: localhost | ORIGIN: http://localhost:3000
Abre http://localhost:3000 no browser. O fluxo de teste deve seguir estes passos:
- Introduz um nome de utilizador (mínimo 3 caracteres) no campo de input
- Clica em Registar Passkey
- O browser abre o diálogo do autenticador (Touch ID, Windows Hello, PIN, YubiKey)
- Confirma a tua identidade com o método disponível no dispositivo
- Vês a mensagem “Passkey registada com sucesso!” com informação sobre o tipo de dispositivo
- Clica em Entrar com Passkey com o mesmo nome de utilizador
- O browser invoca a passkey para assinar o novo challenge
- Vês a mensagem “Autenticação bem-sucedida!”
Se o browser não abre o diálogo de autenticação, verifica se estás exatamente em http://localhost:3000 (não em http://127.0.0.1:3000) e se o RP_ID e ORIGIN estão corretamente configurados. O RP_ID localhost só funciona com ORIGIN http://localhost:3000.
Passo 10: Implementar Middleware de Autorização
Adiciona um middleware para proteger rotas que requerem autenticação e endpoints para gerir passkeys. Cria src/middleware/auth.js:
export function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({
error: 'Autenticação necessária',
loginUrl: '/'
});
}
next();
}
// Usar nas rotas protegidas (em server.js):
// import { requireAuth } from './middleware/auth.js';
//
// app.get('/api/profile', requireAuth, async (req, res) => {
// const user = getUserById(req.session.userId);
// res.json({ username: user.username, credentialsCount: user.credentials.length });
// });
//
// app.delete('/api/session', requireAuth, (req, res) => {
// req.session.destroy(() => res.json({ message: 'Sessão terminada' }));
// });
Passo 11: Gerir Múltiplas Passkeys
Um sistema de produção tem de suportar múltiplas passkeys por utilizador, o que é essencial para recuperação de conta (utilizador pode ter passkey no iPhone e no laptop). Adiciona estes endpoints ao ficheiro src/server.js:
import { requireAuth } from './middleware/auth.js';
import { getUserById, getUserCredentials, removeCredential } from './db.js';
// Listar passkeys do utilizador autenticado
app.get('/api/credentials', requireAuth, (req, res) => {
const creds = getUserCredentials(req.session.userId);
res.json(creds.map(c => ({
id: Buffer.from(c.id).toString('base64url'),
deviceType: c.deviceType,
backedUp: c.backedUp,
transports: c.transports,
lastUsed: c.lastUsed,
createdAt: c.createdAt,
})));
});
// Remover uma passkey com proteção de conta
app.delete('/api/credentials/:credId', requireAuth, (req, res) => {
const userId = req.session.userId;
const user = getUserById(userId);
if (!user) return res.status(404).json({ error: 'Utilizador não encontrado' });
if (user.credentials.length <= 1) {
return res.status(400).json({
error: 'Não podes remover a última passkey. Regista outra passkey primeiro ou configura recuperação de conta.'
});
}
removeCredential(userId, req.params.credId);
res.json({ message: 'Passkey removida com sucesso' });
});
Passo 12: Configurar para Produção
Antes de colocar em produção, são necessários vários ajustes críticos. Cria um ficheiro .env (nunca incluir no git):
# .env - NUNCA comprometer no git
NODE_ENV=production
PORT=3000
SESSION_SECRET=gera-com-node-e-crypto-randomBytes-32-hex
RP_NAME=A Minha Aplicação
RP_ID=teudominio.pt
ORIGIN=https://teudominio.pt
DATABASE_URL=postgresql://user:pass@localhost:5432/webauthn_db
REDIS_URL=redis://localhost:6379
Atualiza a configuração da sessão para usar Redis e atributos de cookie seguros:
import { createClient } from 'redis';
import RedisStore from 'connect-redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS obrigatório em produção
httpOnly: true, // inacessível via JavaScript
sameSite: 'strict', // proteção CSRF
maxAge: 5 * 60 * 1000, // 5 minutos para o challenge
},
name: '__Host-session', // prefixo __Host- para segurança adicional
}));
Adiciona os cabeçalhos de segurança HTTP com o Helmet.js para proteção completa da aplicação em produção:
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://unpkg.com"],
connectSrc: ["'self'"],
}
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
}
}));
Erros Comuns e Troubleshooting
Os seguintes erros aparecem com frequência durante a implementação de WebAuthn em Node.js. A tabela cobre os 10 problemas mais comuns com as respetivas causas e soluções:
| Erro | Causa | Solução |
|---|---|---|
InvalidStateError no browser | Credencial já registada para este autenticador neste site | Verificar lista em excludeCredentials ou limpar passkeys de teste no browser |
Expected RP_ID to be X, got Y | RP_ID não corresponde ao domínio real da aplicação | RP_ID deve ser o domínio sem protocolo e sem porta (ex: teudominio.pt) |
Challenge is not one-time | Challenge reutilizado ou sessão partilhada entre requests | Apagar challenge da sessão após uso; usar Redis para sessões em produção |
NotAllowedError: The operation either timed out | Utilizador cancelou o diálogo ou timeout expirou (padrão: 60 segundos) | Aumentar timeout nas opções; mostrar instrução clara ao utilizador |
Counter has not increased | Potencial clonagem de credencial ou autenticador de plataforma com counter=0 | Registar o evento; revogar credencial se suspeito; aceitar counter=0 para passkeys sincronizadas |
TypeError: Cannot read properties | Pacote não é ESM; require() usado em vez de import | Adicionar "type":"module" ao package.json ou usar @simplewebauthn/[email protected] (CommonJS) |
Challenge undefined na sessão | Sessão não persiste entre requests; cookie de sessão não enviado | Verificar credentials: 'include' no fetch; verificar sameSite e secure do cookie |
| Erro de CORS no browser | Origin não permitida para requests com credenciais | Configurar CORS com credentials: true e origin específica no servidor |
AbortError na verificação | Pedido cancelado por signal ou outro diálogo WebAuthn ativo | Implementar retry com mensagem clara; cancelar operação anterior antes de iniciar nova |
| Browser não mostra diálogo de autenticação | ORIGIN incorreta; não estás em localhost ou HTTPS | Verificar que acedes via http://localhost (não 127.0.0.1); em produção, HTTPS obrigatório |
Diagnóstico com Logs Estruturados
Adiciona logging estruturado para facilitar o diagnóstico em produção. Cada evento de autenticação deve ser registado com os campos suficientes para análise forense sem comprometer a privacidade:
// Middleware de logging para eventos WebAuthn
function logWebAuthnEvent(event, userId, credentialId, success, error = null) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
event, // 'register' ou 'authenticate'
userId,
credentialId, // ID da credencial, não a chave pública
success,
error: error?.message || null,
}));
}
// Usar nos endpoints após verificação:
// logWebAuthnEvent('authenticate', userId, credentialId, true);
// logWebAuthnEvent('authenticate', userId, credentialId, false, error);
Dicas Avançadas para Produção
Conditional UI: Autofill de Passkeys
A API WebAuthn suporta mediation: 'conditional', que permite ao browser mostrar passkeys disponíveis no campo de username como sugestões de autofill, sem que o utilizador precise de clicar num botão dedicado. O SimpleWebAuthn trata disto com a opção useBrowserAutofill: true:
// No frontend, ativar Conditional UI ao carregar a página:
async function initConditionalUI() {
if (!PublicKeyCredential.isConditionalMediationAvailable ||
!(await PublicKeyCredential.isConditionalMediationAvailable())) {
return; // Browser não suporta Conditional UI
}
try {
// Obter opções sem username (autenticação descobrível)
const options = await apiPost('/api/auth/generate-options-discoverable', {});
const authResp = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true, // ativa mediation: 'conditional'
});
const result = await apiPost('/api/auth/verify', authResp);
showStatus(result.message, 'success');
} catch (err) {
// AbortError é esperado se o utilizador clicar no botão de login manual
if (err.name !== 'AbortError') console.error(err);
}
}
document.addEventListener('DOMContentLoaded', initConditionalUI);
Step-Up Authentication para Operações Sensíveis
Para operações de alto risco (transferências bancárias, alteração de email, acesso a dados sensíveis), implementa autenticação de segunda camada: mesmo que o utilizador tenha uma sessão ativa, exige nova verificação com passkey. Guarda um timestamp lastStepUpAt na sessão e verifica que foi há menos de 5 minutos antes de permitir a operação crítica. Esta abordagem é mais segura que uma sessão permanente e recomendada para qualquer operação que modifique dados de autenticação.
Migração Progressiva de Passwords para Passkeys
Para sistemas existentes com palavras-passe, a migração mais segura é progressiva e em três fases. Na primeira fase, mantém autenticação por password mas oferece o registo de passkey após login bem-sucedido. Na segunda fase, após 30 a 60 dias de adoção, permite login com passkey OR password. Na terceira fase, quando a adoção superar 80% dos utilizadores ativos, torna a password opcional e adiciona um banner de encorajamento para quem ainda não migrou. Nunca eliminar passwords sem verificar que todos os utilizadores têm pelo menos uma passkey registada.
Conformidade e Regulamentação em Portugal
O WebAuthn alinha-se com vários requisitos regulatórios europeus em vigor em 2026. O RGPD beneficia da implementação de passkeys porque reduz o volume de dados pessoais sensíveis armazenados: não há palavras-passe hashadas que possam ser expostas, e a chave pública armazenada no servidor não permite reconstituir qualquer credencial. Esta redução do risco de breach é relevante para a avaliação de impacto de proteção de dados (DPIA).
A Diretiva NIS2, transposta em Portugal pelo Decreto-Lei 125/2025, exige autenticação forte para operadores de serviços essenciais e prestadores de serviços digitais. As passkeys qualificam como autenticação multi-fator segundo as orientações da ENISA, porque combinam algo que o utilizador tem (o dispositivo com a chave privada) e algo que o utilizador é ou sabe (biometria ou PIN para desbloquear o autenticador).
Para efeitos de auditoria, regista os seguintes campos em cada evento de autenticação WebAuthn: user ID interno, credential ID (em base64url, não a chave pública), timestamp ISO 8601, endereço IP de origem, resultado (sucesso ou tipo de falha), e user agent. Este conjunto é suficiente para análise forense sem comprometer a privacidade.
| Requisito de Segurança | Implementação no Tutorial | Norma |
|---|---|---|
| Challenge único e de curta duração | Sessão com TTL de 5 minutos | WebAuthn Level 3, sec. 13.4.3 |
| Verificação de origem | expectedOrigin na verificação | FIDO2, sec. 6.3 |
| Verificação de RP_ID | expectedRPID na verificação | WebAuthn Level 3, sec. 7.2 |
| Deteção de clonagem | Verificação e atualização do contador | FIDO2, sec. 6.3.3 |
| Verificação do utilizador | requireUserVerification: true | NIST SP 800-63B AAL2 |
| HTTPS obrigatório em produção | Cookie secure: true + RP_ID sem localhost | W3C WebAuthn Level 3 |
FAQ: WebAuthn e Passkeys em Node.js
Posso usar WebAuthn sem HTTPS?
Em desenvolvimento local com localhost, sim. O browser trata localhost como contexto seguro. Em qualquer outro domínio, HTTPS é obrigatório pelo standard W3C. Não há exceções para endereços IP ou subdomínios sem TLS. Se precisares de testar num servidor remoto durante o desenvolvimento, usa um túnel como ngrok com HTTPS ou configura um certificado Let's Encrypt.
O que acontece se o utilizador perder o dispositivo?
Se a passkey estava sincronizada (iCloud Keychain, Google Password Manager, Samsung Pass), fica disponível em qualquer dispositivo do utilizador com a mesma conta na nuvem. Se era uma passkey de dispositivo único, como numa YubiKey física, e o dispositivo foi perdido, o utilizador precisa de um método de recuperação alternativo previamente configurado: código de recuperação de uso único gerado no registo, ou autenticação via email verificado. A aplicação deve exibir claramente ao utilizador se a sua passkey está sincronizada.
Posso usar WebAuthn como segundo fator em vez de primeiro fator?
Sim. Configura userVerification: 'discouraged' nas opções de autenticação para um segundo fator. Neste modo, o autenticador não exige verificação do utilizador (biometria ou PIN), funcionando como um segundo fator após uma password. Com userVerification: 'required' ou 'preferred', a passkey é um fator completo que combina posse do dispositivo e verificação do utilizador.
Qual é a diferença entre passkey de plataforma e passkey de roaming?
Uma passkey de plataforma usa o sensor biométrico ou PIN do dispositivo (Touch ID no Mac/iPhone, Windows Hello no PC). Uma passkey de roaming usa um autenticador externo, como uma YubiKey via USB ou NFC. O código é idêntico para ambos; a distinção é feita automaticamente pelo browser. O campo transports na resposta indica o tipo: internal para plataforma, usb, nfc ou ble para roaming.
Como funciona o campo counter e porque é importante?
O autenticador mantém um contador que incrementa a cada uso. O servidor verifica que o novo contador é sempre maior que o anterior. Se receber um counter igual ou inferior, pode indicar que a credencial foi clonada. Autenticadores de plataforma sincronizados (como passkeys no iCloud) tipicamente retornam counter: 0 porque a sincronização entre dispositivos torna o tracking por contador impraticável. Nestes casos, não deves bloquear a autenticação por counter=0, mas registar o evento para análise.
O SimpleWebAuthn 13.x suporta Deno e Bun?
Sim. O @simplewebauthn/[email protected] usa apenas APIs padrão Web Crypto (SubtleCrypto) e é compatível com Node.js 20+, Deno 1.x, e Bun 1.x. Não usa módulos nativos do Node.js, o que facilita a portabilidade entre runtimes. Para uso com Deno, importa diretamente de npm: import { generateRegistrationOptions } from 'npm:@simplewebauthn/server@13'.
Como testar WebAuthn em pipelines de CI/CD?
O WebAuthn requer interação humana por design, o que complica testes automáticos. Para testes de integração, usa o passkeys.dev Virtual Authenticator via Playwright, que suporta autenticadores virtuais para WebAuthn através de comandos CDP (Chrome DevTools Protocol). O Playwright tem suporte nativo com virtualAuthenticator, que permite criar, registar e autenticar com passkeys virtuais sem interação humana em ambientes de CI.
Onde encontro as especificações e documentação oficial?
As fontes primárias são: a especificação WebAuthn Level 3 no W3C, a documentação de passkeys da FIDO Alliance, a referência da MDN para a Web Authentication API, e a documentação oficial do SimpleWebAuthn. Para exemplos práticos adicionais, o repositório oficial do SimpleWebAuthn inclui um projeto de demonstração completo com frontend e backend.
Cobertura Relacionada
Leitura Recomendada
- Passkeys vs Passwords: 8.5s vs 31s de Autenticação [2026] - comparação de desempenho e segurança entre passkeys e palavras-passe tradicionais
- OAuth 2.0 em Node.js com PKCE: 12 Passos [2026] - autenticação delegada com fornecedores externos como Google e GitHub
- JWT Authentication in Node.js: 10 Steps [2026] - tokens de acesso para APIs REST, complementar ao WebAuthn
- Two-Factor Authentication in Node.js: 11 Steps [2026] - 2FA com TOTP para sistemas híbridos password + segundo fator
- Node.js Session Management: 11 Steps, 30 Min [2026] - gestão segura de sessões com Express, base para qualquer sistema de autenticação
- Helmet.js em Node.js: Cabeçalhos de Segurança em 12 Passos [2026] - cabeçalhos HTTP essenciais para proteger a aplicação em produção




