Autenticação com Passkeys (WebAuthn) em SPA e API: arquitetura e fallback JWT
Por John Pires · 10 de março de 2026

Autenticação com Passkeys (WebAuthn) em SPA e API: arquitetura e fallback JWT
1) Problema em escala
Autenticação sem senha usando Passkeys/WebAuthn oferece segurança superior, UX reduzida e resistência a phishing. Contudo, operacionalizar isso em uma aplicação moderna—SPA no front-end e API separada no back-end—traz desafios específicos: compatibilidade de navegadores, provisionamento de credenciais por usuários que alternam dispositivos, orquestração entre SPAs e APIs em domínios diferentes, e fallback para clientes ou situações onde WebAuthn não está disponível.
Em escala, o problema não é apenas técnico: envolve governança de chaves, controle de revogação, monitoramento de padrões de autenticação e decisões sobre como migrar usuários legados que usam senhas. Sem uma arquitetura clara, você corre riscos de bloquear usuários, criar vetores de ataque (ex.: endpoints de fallback pouco protegidos) ou ter um sistema frágil que não escala com cargas altas.
Vamos analisar os sintomas práticos que indicam problemas em escala:
- Taxa crescente de suporte por conta de erros irreproduzíveis de autenticação WebAuthn;
- Latência de autenticação elevada que afeta o tempo de inicialização da sessão em SPAs;
- Incidentes de sincronização entre dispositivos (usuário registra em celular, tenta logar em desktop e fala que não existe credencial);
- Endpoints de fallback JWT mal projetados que abrem brecha para contornos de segurança.
1.1 Impacto na experiência do usuário
Passkeys prometem reduzir atrito, mas falhas de implementação transformam promessa em desastre: fluxos de registro quebrados, prompts de segurança confusos, ou diálogos de navegador inconsistentes. Em SPAs, pequenos delays na troca de challenge/response podem fazer o usuário abandonar o login e ativar canais de suporte.
1.2 Impacto operacional e de segurança
Scale aumenta o custo de pós-incidente: rotação de chaves, investigação de fraudadores, blacklist/whitelist de dispositivos e necessidade de rotinas automatizadas para revogação e análise de fraude. Sem um fallback controlado, equipes podem liberar JWTs inseguros só para reduzir tickets, criando risco adicional.
Diagnóstico: se você usa WebAuthn sem um fallback seguro e sem monitoramento de métricas (erro/latência/taxa de sucesso por browser), você ainda está no experimental — e não em produção.
2) Comparativo técnico de abordagens
Existem três estratégias práticas para autenticação em SPA+API com passkeys:
- Somente WebAuthn: rejeitar outros métodos; simplifica modelo, exige cobertura de clientes;
- WebAuthn primário + fallback JWT via senha/email/OTP: garante continuidade, complexifica surface de ataque;
- Modelo híbrido: WebAuthn para autenticação primária, JWT para sessões curtas e refresh tokens com constraints rígidas (aud/iss, device-bound).
Vamos comparar em critérios relevantes: segurança, compatibilidade, complexidade operacional e escalabilidade.
2.1 Segurança
WebAuthn vence em resistência a phishing, replay e roubo de credenciais. O modelo de chaves públicas evita exposição de segredos. Já o JWT como fallback introduz risco se os tokens são long-lived ou emitidos sem binding ao dispositivo/contexto.
2.2 Compatibilidade e experiência
WebAuthn depende de browsers modernos e plataformas com suporte a autenticação por hardware. O fallback é obrigatório para usuários em ambientes não suportados (ex.: navegadores corporativos bloqueando APIs). Do ponto de vista UX, é aceitável tentar WebAuthn primeiro e degradar para fallback apenas quando necessário.
2.3 Complexidade operacional
Manter um sistema híbrido exige:
- Política clara de expiração e revogação de credenciais;
- Mecanismos de binding e detecção de fraude quando emitir JWT como fallback;
- Logs e métricas específicas para cada fluxo.
2.4 Performance e escalabilidade
WebAuthn é client-heavy: o servidor apenas emite challenges e verifica assinaturas; a validação é computacionalmente leve (verificação de assinatura). O ponto de escala é o estado: manter desafios, sessões temporárias e rate-limits. Já JWTs exigem auditoria e storage para refresh tokens e revogação se quiser segurança real, o que adiciona carga de armazenamento e consultas por validação de token revogado.
3) Implementacao production-ready
Vou detalhar um padrão pragmático que usei em projetos reais: attempt-first WebAuthn + secure JWT fallback. Estrutura:
- SPA inicia fluxo de autenticação. Chamada para API /auth/begin → servidor cria challenge (crypto.randomBytes), persiste em store curto (Redis) com nonce, expiry e contexto (userId ou unknown), retorna challenge;
- SPA chama navigator.credentials.create/get com challenge e options; recebe attestation/assertion;
- SPA envia resposta para /auth/complete; servidor verifica assinatura, registra credencial pública se necessário, e emite session token (JWT curto) + refresh token opcional;
- Se WebAuthn não disponível, SPA apresenta fallback: email+OTP ou senha; servidor valida e emite JWT com constraints adicionais e flag de fallback.
Aqui temos decisions importantes:
- Expiry curto para JWTs primários (ex.: 15 minutos) e refresh tokens rotativos;
- Binding de refresh token ao dispositivo ou browser fingerprint quando possível (HMAC sobre fingerprint);
- Limit de emissão de tokens via fallback por conta para mitigar abuso.
3.1 Exemplo: endpoint back-end (Node.js + Express) para beginRegistration
O código abaixo cria um challenge para registro WebAuthn. Ele armazena o challenge no Redis com TTL. Use isso em /auth/begin-registration.
// begin-registration.js
// O que faz: gera um challenge e detalhes de usuário para o client criar uma passkey
const crypto = require('crypto');
const express = require('express');
const redis = require('redis');
const base64url = require('base64url');
const client = redis.createClient();
const router = express.Router();
router.post('/auth/begin-registration', async (req, res) => {
const { userId, displayName } = req.body; // esperado do SPA
const challenge = crypto.randomBytes(32).toString('base64');
const rp = { name: 'MinhaApp' };
const user = { id: base64url(Buffer.from(userId)), name: userId, displayName };
const publicKey = {
challenge: base64url(Buffer.from(challenge, 'base64')),
rp,
user,
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
timeout: 60000,
attestation: 'none'
};
await client.setexAsync(`webauthn:challenge:${userId}`, 120, challenge);
res.json(publicKey);
});
module.exports = router;
O que acontece depois: o SPA usa os dados retornados para chamar navigator.credentials.create(). O challenge retorna em base64url e precisa ser convertido para ArrayBuffer no cliente.
3.2 Exemplo: SPA — registro client-side
Este trecho converte o challenge para ArrayBuffer, chama navigator.credentials.create e envia o resultado de volta ao servidor. Use em um componente JS do seu SPA.
// register-client.js
// O que faz: consome /auth/begin-registration, chama WebAuthn e envia o attestation ao servidor
async function beginRegistration(userId, displayName) {
const res = await fetch('/auth/begin-registration', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ userId, displayName }) });
const options = await res.json();
// helper: base64url -> ArrayBuffer
const decode = s => Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
options.challenge = decode(options.challenge);
options.user.id = decode(options.user.id);
const cred = await navigator.credentials.create({ publicKey: options });
// serializa cred para enviar ao servidor
const attestationResponse = {
id: cred.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(cred.rawId))),
type: cred.type,
response: {
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(cred.response.clientDataJSON))),
attestationObject: btoa(String.fromCharCode(...new Uint8Array(cred.response.attestationObject)))
}
};
const complete = await fetch('/auth/complete-registration', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ attestation: attestationResponse, userId }) });
return complete.json();
}
// o que acontece: o servidor valida e grava a credencial pública vinculada ao usuário
Observação: a conversão base64/url e ArrayBuffer é fonte comum de bug. Teste com ferramentas que permitem inspecionar os bytes.
3.3 Exemplo: verificação e emissão de JWT (fallback seguro)
Ao final da verificação de WebAuthn, ou quando o usuário usa o fallback (senha/OTP), o servidor precisa emitir tokens. O exemplo a seguir mostra criação de JWT curto + refresh token rotativo com binding simples (HMAC com deviceId).
// token-issue.js
// O que faz: emite um access token JWT curto e um refresh token rotativo bound ao device
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const router = express.Router();
const ACCESS_SECRET = process.env.ACCESS_SECRET || 'access-secret';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'refresh-secret';
function signAccess(userId) {
return jwt.sign({ sub: userId }, ACCESS_SECRET, { expiresIn: '15m' });
}
function makeRefresh(userId, deviceId) {
const nonce = crypto.randomBytes(32).toString('hex');
const payload = { sub: userId, d: deviceId, n: nonce };
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '30d' });
}
router.post('/auth/issue', async (req, res) => {
const { userId, deviceId, viaFallback } = req.body; // validated earlier
const access = signAccess(userId);
const refresh = makeRefresh(userId, deviceId);
// guarde refresh token hash no DB para revogação/rotatividade
// ex: storeRefreshToken(userId, hash(refresh))
res.json({ accessToken: access, refreshToken: refresh, viaFallback });
});
module.exports = router;
Depois da emissão: o SPA salva accessToken em memória (não localStorage) e o refreshToken em httpOnly secure cookie. Em requests subsequentes, o access é enviado em Authorization, e o cookie permite silent refresh.
3.4 Erros comuns na implementação
Erros e armadilhas frequentes:
- Salvar accessToken em localStorage expõe a token em XSS;
- Não validar o rpId ou origin na verificação da assinatura;
- Confiar em dados do client sem re-verificação (ex.: deviceId enviado pelo browser);
- Challenge com encoding incorreto (base64 vs base64url) e má conversão em ArrayBuffer;
- Refresh tokens long-lived sem binding ao dispositivo facilitam tomada de sessão.
Dica prática: sempre armazene apenas o hash do refresh token e implemente rotatividade (issue novo refresh token ao usar o antigo e marcar o antigo como revogado).
4) Testes de carga e resiliencia
WebAuthn implica duas interações sensíveis: geração/expiração de challenges e verificação criptográfica. Em escala, teste os dois pontos com foco em latência, throughput e comportamento sob falhas (Redis indisponível, lentidão de DB).
Estratégias de teste:
- Simule picos de registro e autenticação com ferramentas de carga (k6, JMeter). Em particular, teste bursts de /auth/begin-* que criam muitos estados temporários em Redis;
- Teste timeouts e retries no SPA: se challenge expira, como o cliente se recupera?;
- Injete falhas: Redis offline, latências altas no DB e respostas 500 do serviço de token para validar mecanismos de fallback e graceful degradation.
4.1 Métricas essenciais
Monitore:
- Taxa de sucesso por browser/OS para registrar e autenticar (métrica principal para WebAuthn);
- Tempo médio e p95 de verificação de assinatura (CPU-bound se alto tráfego);
- Número de tokens emitidos por fluxo (webauthn vs fallback);
- Redis miss/ttl expirations e taxa de challenges expirados vs completados.
4.2 Cenários de teste e scripts
Um cenário típico com k6:
// k6-script.js (trecho)
// O que faz: simula início de registro seguido de cadastro via WebAuthn mock
import http from 'k6/http';
import { check } from 'k6';
export default function () {
const payload = JSON.stringify({ userId: `u-${__VU}-${__ITER}`, displayName: 'Teste' });
const res = http.post('https://api.example.com/auth/begin-registration', payload, { headers: { 'Content-Type': 'application/json' } });
check(res, { 'began': (r) => r.status === 200 });
// Não tentamos a parte do navigator.credentials em load test — apenas medimos o backend
}
O que acontece: este teste valida que o backend aguenta picos de geração de challenges; para validação completa, combine com testes funcionais que simulam o client-side WebAuthn (testes E2E com Puppeteer/Playwright).
4.3 Resiliência operacional
Para reduzir impacto de falhas:
- Use Redis em cluster com réplica e TTL previsível;
- Implemente circuit breakers para endpoints de emissão de token e redução automática de carga;
- Graceful degradation: se sistema de WebAuthn estiver degradado, comunique o usuário e ofereça fallback controlado com throttling.
5) Evolucao e manutencao a longo prazo
Adotar Passkeys é mais do que código: é um ciclo de vida de credenciais. Planos de longo prazo devem cobrir rotação de chaves, estratégias de migração e governança. Abaixo, pontos práticos que usei em projetos corporativos.
5.1 Governança de credenciais
Defina:
- Política de revogação: quando revogar credenciais e como replicar a lista de revogados (ex.: manter timestamp de revogação por credencial no DB);
- Procedimentos de recuperação: como um usuário que perdeu todos os dispositivos pode recuperar conta sem expor riscos (ex.: notar a seguridade do processo de verificação por identidade);
- Auditoria: logs imutáveis de eventos de registro/autenticação, com retenção e alertas para padrões suspeitos.
5.2 Atualizações e compatibilidade futura
WebAuthn evolui. Programe atualizações:
- Suporte a novos algoritmos de chave (p.ex., esquemas pós-quânticos no futuro) — mantenha abstrações limpas para verificação de chave;
- Feature flags que permitam ativar/desativar o fallback sem deploy exemplo: um toggle remoto para 'enforce-webauthn' por cohorte de usuários;
- Testes automáticos contra matrix de browsers/combinations em pipelines CI.
5.3 Monitoramento e alertas
Crie alertas para:
- Aumento súbito na taxa de fallback (indicando regressão de WebAuthn);
- Queda na taxa de sucesso de verificação;
- Uso anômalo de refresh tokens via fallback (potencial abuso).
Automatize relatórios semanais com tendências por navegador, sistema e geographic region.
Conclusão prática
Implementar Passkeys/WebAuthn em um stack SPA + API exige projeto: tente WebAuthn primeiro, mas tenha um fallback JWT seguro. Priorize:
- Challenges curtos, armazenamento temporário (Redis com TTL) e validação completa (origin, rpId);
- Tokens curtos e refresh tokens rotativos, armazenando apenas hashes do refresh no DB;
- Binding de refresh tokens ao contexto (deviceId/HMAC) quando usar fallback;
- Monitoramento granular e testes de carga focando geração/expiração de challenges e emissão de tokens.
Evite atalhos: salvar tokens long-lived em localStorage, emitir refresh tokens sem binding, ou confiar em valores do cliente. Com disciplina operacional e automação de testes/monitoramento, você consegue implantar passkeys com baixo atrito e alto nível de segurança.
Checklist mínimo antes de ir para produção:
- Testes E2E com Playwright para fluxos WebAuthn em navegadores suportados;
- k6 para simular picos de /auth/begin-* e medir TTL expirations;
- Alertas para taxa de fallback e perda de challenges;
- Procedimento de recuperação seguro para usuários que perdem dispositivos.
Implementar Passkeys é um investimento: reduz risco de phishing e melhora UX, mas exige disciplina de arquitetura para não aumentar a superfície de ataque via fallbacks. Siga o padrão attempt-first WebAuthn + secure JWT fallback descrito aqui e você terá um equilíbrio entre segurança, disponibilidade e experiência.
Leve-prático: comece implementando challenge/verify e métricas; volte a política de refresh tokens e binding em seguida; por fim, refine a recuperação de conta e o rollout gradual.
Assuntos: webauthn, passkeys, jwt, spa, Segurança, Node.js
Leia também
Diagnóstico de latência em APIs Node.js com async_hooks, event-loop e GC
Diagnostique latência em APIs Node.js combinando async_hooks/AsyncLocalStorage, monitorEventLoopDelay e análise de GC. Passo a passo prático: medir…
Gerar thumbnails e checksums de uploads usando Streams e Worker Threads no Node.js
Como gerar thumbnails e checksums de uploads sem sobrecarregar o Node: padrões com Streams (PassThrough/pipeline) e offload para Worker Threads. Boas…
Health checks e readiness probes em containers Docker para Node.js: passo a passo
Guia prático para colocar health checks e readiness probes em Node.js com Docker, docker-compose, Kubernetes e GitHub Actions. Passo a passo, erros comuns e…
Rotação segura de refresh tokens JWT: detectar reuse e revogar sessões
Padrão prático para rotacionar refresh tokens JWT com detecção de reuse: como modelar sessões no Redis, atualizar currentJti de forma atômica, revogar sessões…
Worker Threads: sandbox seguro para executar código de usuário no Node.js
Implementar sandbox seguro para executar código de usuário em Node.js: use worker threads + vm, limite memória com execArgv, aplique timeout externo, evite…
Webhooks idempotentes em Node/Express: deduplicação, verificação e retries
Guia prático para implementar webhooks idempotentes em Node/Express: validação HMAC sobre raw body, deduplicação com Redis, persistência atômica e retries com…