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