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érioPassword + bcryptWebAuthn / Passkey
Armazenamento no servidorHash da palavra-passeChave pública + contador
Resistência a phishingNenhumaTotal (vinculado à origem)
Exposição por breachHashes expõem credenciaisChave pública não é utilizável
Experiência do utilizadorDigitar palavra-passeBiometria / PIN (menos de 3 segundos)
Suporte browsers 2026UniversalChrome, Safari, Firefox, Edge
Requisito HTTPSRecomendadoObrigatório
Complexidade servidorBaixaMédia (verificação criptográfica)
Suporte multi-dispositivoImediatoRequer sincronização (iCloud, Google)
Recuperação de contaReset por emailChave de recuperação ou método alternativo
Custo de implementação1 a 2 dias3 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 a async/await sem 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:

  1. Introduz um nome de utilizador (mínimo 3 caracteres) no campo de input
  2. Clica em Registar Passkey
  3. O browser abre o diálogo do autenticador (Touch ID, Windows Hello, PIN, YubiKey)
  4. Confirma a tua identidade com o método disponível no dispositivo
  5. Vês a mensagem “Passkey registada com sucesso!” com informação sobre o tipo de dispositivo
  6. Clica em Entrar com Passkey com o mesmo nome de utilizador
  7. O browser invoca a passkey para assinar o novo challenge
  8. 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:

ErroCausaSolução
InvalidStateError no browserCredencial já registada para este autenticador neste siteVerificar lista em excludeCredentials ou limpar passkeys de teste no browser
Expected RP_ID to be X, got YRP_ID não corresponde ao domínio real da aplicaçãoRP_ID deve ser o domínio sem protocolo e sem porta (ex: teudominio.pt)
Challenge is not one-timeChallenge reutilizado ou sessão partilhada entre requestsApagar challenge da sessão após uso; usar Redis para sessões em produção
NotAllowedError: The operation either timed outUtilizador 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 increasedPotencial clonagem de credencial ou autenticador de plataforma com counter=0Registar o evento; revogar credencial se suspeito; aceitar counter=0 para passkeys sincronizadas
TypeError: Cannot read propertiesPacote não é ESM; require() usado em vez de importAdicionar "type":"module" ao package.json ou usar @simplewebauthn/[email protected] (CommonJS)
Challenge undefined na sessãoSessão não persiste entre requests; cookie de sessão não enviadoVerificar credentials: 'include' no fetch; verificar sameSite e secure do cookie
Erro de CORS no browserOrigin não permitida para requests com credenciaisConfigurar CORS com credentials: true e origin específica no servidor
AbortError na verificaçãoPedido cancelado por signal ou outro diálogo WebAuthn ativoImplementar retry com mensagem clara; cancelar operação anterior antes de iniciar nova
Browser não mostra diálogo de autenticaçãoORIGIN incorreta; não estás em localhost ou HTTPSVerificar 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çaImplementação no TutorialNorma
Challenge único e de curta duraçãoSessão com TTL de 5 minutosWebAuthn Level 3, sec. 13.4.3
Verificação de origemexpectedOrigin na verificaçãoFIDO2, sec. 6.3
Verificação de RP_IDexpectedRPID na verificaçãoWebAuthn Level 3, sec. 7.2
Deteção de clonagemVerificação e atualização do contadorFIDO2, sec. 6.3.3
Verificação do utilizadorrequireUserVerification: trueNIST SP 800-63B AAL2
HTTPS obrigatório em produçãoCookie secure: true + RP_ID sem localhostW3C 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