Passkeys e WebAuthn: O Guia Completo para Acabar com Senhas na Sua App Web
Seus usuários ainda tão digitando senhas. Em 2026. Mesmo com cada plataforma grande — Apple, Google, Microsoft — já suportando passkeys, a maioria das apps web continua presa na mesma arquitetura de autenticação de 2005: hashear uma senha, guardar no banco, e torcer pra ninguém achar.
Não é que passkeys sejam difíceis de entender. É que o caminho de implementação tá cheio de armadilhas sem documentação, specs confusas, e uma API de WebAuthn que parece simples nos demos mas quebra em produção. O que exatamente o allowCredentials faz? Por que navigator.credentials.get() falha silenciosamente em alguns navegadores? Como você migra 500 mil usuários existentes sem quebrar as sessões deles?
Esse guia cobre tudo que você precisa pra levar passkeys pra produção — dos fundamentos criptográficos até implementações completas em TypeScript, integração com Conditional UI, design de schema de banco, sincronização multi-dispositivo, e a estratégia de migração por fases que permite fazer a transição sem forçar os usuários a mudar do dia pra noite.
Por Que Senhas Ainda São um Risco
Os números falam por si. Segundo o relatório Verizon DBIR 2025, mais de 80% das brechas em aplicações web têm origem em credenciais roubadas ou fracas. Ataques de phishing funcionam porque senhas são um segredo compartilhado — o servidor sabe, o usuário sabe, e quem interceptar em trânsito ou em repouso tem acesso total.
O Que Passkeys Realmente Resolvem
Passkeys eliminam o segredo compartilhado por completo. A diferença fundamental:
Autenticação com senha:
Usuário → envia senha → Servidor compara hash
Superfície de ataque: phishing, credential stuffing, brecha de BD
Autenticação com passkey:
Usuário → assina challenge com chave privada → Servidor verifica com chave pública
Superfície de ataque: roubo físico do dispositivo (requer biométrico)
A chave privada nunca sai do dispositivo do usuário. O servidor só armazena a chave pública. Mesmo que o banco inteiro vaze, os atacantes não conseguem nada útil — chaves públicas não servem pra autenticar.
As Três Propriedades Que Importam
-
Resistente a phishing. Passkeys são criptograficamente vinculados ao seu domínio (o "Relying Party ID"). Uma página de login falsa em
evil-example.comnão consegue fisicamente disparar um passkey criado praexample.com. O navegador impede no nível do protocolo — nenhuma engenharia social contorna isso. -
Sem segredos compartilhados. Seu servidor nunca vê, armazena ou transmite nenhum material secreto. Não tem nada pra hashear, nada pra saltar, nada pra vazar. Ataques de credential stuffing se tornam irrelevantes porque não tem credenciais pra reutilizar.
-
Multi-fator embutido. Autenticação com passkey combina "algo que você tem" (o dispositivo) com "algo que você é" (biométrico) ou "algo que você sabe" (PIN do dispositivo). Você ganha segurança de MFA sem pedir pros usuários instalarem um app de autenticação.
Entendendo o Fluxo do WebAuthn
A Web Authentication API (WebAuthn) define duas cerimônias principais: Registro (criar uma credencial) e Autenticação (usar pra fazer login).
Fluxo de Registro
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Navegador │ │ Servidor │ │ Autenticador │
└────┬─────┘ └────┬─────┘ └──────┬───────┘
│ 1. Iniciar Registro│ │
│ ──────────────────> │ │
│ │ │
│ 2. Challenge + Opts│ │
│ <────────────────── │ │
│ │ │
│ 3. Criar Credencial│ │
│ ──────────────────────────────────────────>│
│ │ │
│ 4. Prompt Biométr. │ │
│ <──────────────────────────────────────────│
│ │ │
│ 5. Chave Pública │ │
│ ──────────────────> │ │
│ │ │
│ 6. Verificar │ │
│ <────────────────── │ │
Fluxo de Autenticação
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Navegador │ │ Servidor │ │ Autenticador │
└────┬─────┘ └────┬─────┘ └──────┬───────┘
│ 1. Iniciar Login │ │
│ ──────────────────> │ │
│ │ │
│ 2. Challenge │ │
│ <────────────────── │ │
│ │ │
│ 3. Assinar Challeng│ │
│ ──────────────────────────────────────────>│
│ │ │
│ 4. Prompt Biométr. │ │
│ <──────────────────────────────────────────│
│ │ │
│ 5. Assertion Assin.│ │
│ ──────────────────> │ │
│ │ │
│ 6. Verificar Assin.│ │
│ <────────────────── │ │
O detalhe crucial: no passo 5, o autenticador assina o challenge do servidor com a chave privada. O servidor então verifica essa assinatura contra a chave pública armazenada. Nenhum segredo é transmitido — só uma prova de que o usuário possui a chave privada.
Implementação Completa com SimpleWebAuthn
Fazer sua própria implementação de WebAuthn do zero é garantia de bugs de segurança sutis. O parsing de CBOR, a verificação de attestation e o gerenciamento de challenges são complexos o bastante pra que bibliotecas testadas sejam o único caminho razoável. SimpleWebAuthn é a lib TypeScript mais adotada pra isso.
Setup do Projeto
# Server-side npm install @simplewebauthn/server # Client-side npm install @simplewebauthn/browser
Schema do Banco de Dados
Antes de escrever código de auth, projete a camada de armazenamento. Você precisa de duas tabelas:
-- Tabela de usuários CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(255) UNIQUE NOT NULL, display_name VARCHAR(255), -- Campos legacy de senha (manter durante migração) password_hash VARCHAR(255), created_at TIMESTAMPTZ DEFAULT NOW() ); -- Tabela de credenciais passkey (um usuário pode ter múltiplos passkeys) CREATE TABLE passkey_credentials ( id VARCHAR(512) PRIMARY KEY, -- credential ID (base64url) user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, public_key BYTEA NOT NULL, -- armazenada como raw bytes counter BIGINT NOT NULL DEFAULT 0, -- contador de assinaturas device_type VARCHAR(50) NOT NULL, -- 'singleDevice' ou 'multiDevice' backed_up BOOLEAN NOT NULL DEFAULT false, -- sincronizado na nuvem? transports TEXT[], -- ['internal', 'hybrid', etc.] display_name VARCHAR(255), -- "MacBook Pro Touch ID" created_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ ); CREATE INDEX idx_credentials_user_id ON passkey_credentials(user_id);
Decisões de design:
- Relação um-para-muitos. Um usuário pode registrar múltiplos passkeys (celular, notebook, security key). Nunca limite a um só.
- Campo
counter. Autenticadores incrementam um contador de assinatura a cada uso. Se chegar uma assinatura com contador menor que o armazenado, a credencial foi clonada — rejeite e marque imediatamente. device_typeebacked_up. Te dizem se o passkey tá sincronizado entre dispositivos (iCloud Keychain, Google Password Manager) ou vinculado a um único dispositivo (hardware security key).- Array
transports. Guarda como o autenticador se comunica ('internal'pra biométrico de plataforma,'hybrid'pra QR cross-device,'usb'pra security keys). Isso acelera autenticações futuras dando hints pro navegador sobre qual transporte tentar primeiro.
Servidor: Endpoint de Registro
import { generateRegistrationOptions, verifyRegistrationResponse, type VerifiedRegistrationResponse, } from '@simplewebauthn/server'; const RP_NAME = 'My App'; const RP_ID = 'example.com'; const ORIGIN = 'https://example.com'; app.post('/api/auth/register/begin', async (req, res) => { const user = await getUserFromSession(req); const existingCredentials = await db.query( 'SELECT id, transports FROM passkey_credentials WHERE user_id = $1', [user.id] ); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID: RP_ID, userName: user.username, userDisplayName: user.display_name || user.username, excludeCredentials: existingCredentials.rows.map(cred => ({ id: cred.id, transports: cred.transports, })), authenticatorSelection: { residentKey: 'required', // CRÍTICO: isso que faz virar passkey userVerification: 'preferred', }, attestationType: 'none', }); await setSessionChallenge(req, options.challenge); res.json(options); }); app.post('/api/auth/register/complete', async (req, res) => { const user = await getUserFromSession(req); const expectedChallenge = await getSessionChallenge(req); try { const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, }); if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: 'Verificação falhou' }); } const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; await db.query( `INSERT INTO passkey_credentials (id, user_id, public_key, counter, device_type, backed_up, transports) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ credential.id, user.id, Buffer.from(credential.publicKey), credential.counter, credentialDeviceType, credentialBackedUp, credential.transports ?? [], ] ); res.json({ verified: true }); } catch (error) { console.error('Verificação de registro falhou:', error); res.status(400).json({ error: 'Registro falhou' }); } });
Servidor: Endpoint de Autenticação
import { generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; app.post('/api/auth/login/begin', async (req, res) => { const options = await generateAuthenticationOptions({ rpID: RP_ID, userVerification: 'preferred', allowCredentials: [], }); await setChallengeStore(options.challenge, { expiresIn: 300 }); res.json(options); }); app.post('/api/auth/login/complete', async (req, res) => { const { id: credentialId } = req.body; const credRow = await db.query( `SELECT pc.*, u.id as uid, u.username FROM passkey_credentials pc JOIN users u ON pc.user_id = u.id WHERE pc.id = $1`, [credentialId] ); if (credRow.rows.length === 0) { return res.status(401).json({ error: 'Credencial desconhecida' }); } const cred = credRow.rows[0]; const expectedChallenge = await getChallengeStore(req.body.response.clientDataJSON); try { const verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, credential: { id: cred.id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.counter), transports: cred.transports, }, }); if (!verification.verified) { return res.status(401).json({ error: 'Verificação falhou' }); } const { newCounter } = verification.authenticationInfo; await db.query( `UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE id = $2`, [newCounter, credentialId] ); const token = await createSession(cred.uid); res.json({ verified: true, token }); } catch (error) { console.error('Autenticação falhou:', error); res.status(401).json({ error: 'Autenticação falhou' }); } });
Cliente: Integração com o Navegador
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; async function registerPasskey(): Promise<void> { const optionsRes = await fetch('/api/auth/register/begin', { method: 'POST' }); const options = await optionsRes.json(); const credential = await startRegistration({ optionsJSON: options }); const verifyRes = await fetch('/api/auth/register/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), }); const result = await verifyRes.json(); if (result.verified) { console.log('Passkey registrado com sucesso'); } } async function loginWithPasskey(): Promise<void> { const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); const assertion = await startAuthentication({ optionsJSON: options }); const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } }
Conditional UI: Passkeys no Menu de Autocomplete
A maior melhoria de UX dos passkeys não é o fluxo de registro — é a Conditional UI. Em vez de exigir que os usuários cliquem num botão "Entrar com passkey", o navegador mostra os passkeys disponíveis direto no dropdown de autocomplete do campo de usuário, do lado das senhas salvas.
Como Funciona
<form> <input type="text" id="username" name="username" autocomplete="username webauthn" placeholder="Email ou nome de usuário" /> <input type="password" name="password" autocomplete="current-password" /> <button type="submit">Entrar</button> </form>
O token webauthn no atributo autocomplete diz pro navegador incluir opções de passkey no dropdown. Ele tem que ser o último token no valor do atributo.
Implementação
import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser'; async function initConditionalUI(): Promise<void> { const supported = await browserSupportsWebAuthnAutofill(); if (!supported) { console.log('Conditional UI não suportada, fallback pra botão'); return; } const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); try { const assertion = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true, }); const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } } catch (error) { if ((error as Error).name !== 'AbortError') { console.error('Erro na Conditional UI:', error); } } } document.addEventListener('DOMContentLoaded', initConditionalUI);
Detalhes Críticos de Implementação
1. Chame no carregamento da página, não no clique. A Conditional UI precisa ser iniciada quando a página carrega. O navegador fica ouvindo continuamente até o usuário interagir com o dropdown de autocomplete. Se você só chamar no clique de um botão, perde a experiência fluida de autocomplete.
2. Use um AbortController. Se o usuário trocar pra outro método de login (social, magic link), aborte o request pendente de Conditional UI pra evitar erros de "request already pending".
3. Estratégia de fallback. Sempre deixe visível um botão "Entrar com passkey" pra navegadores que não suportam Conditional UI. O fluxo modal padrão funciona como fallback.
Estratégia de Migração por Fases
Não dá pra apertar um botão e apagar todas as senhas do dia pra noite. Esse é o approach de três fases que funciona em produção:
Fase 1: Introdução (Enrolamento Passivo)
Depois de um login com senha bem-sucedido, sugira registrar um passkey. Sem forçar — só deixe a opção visível e atraente.
async function postLoginPasskeyPrompt(user: User): Promise<void> { const hasPasskey = await userHasPasskey(user.id); if (hasPasskey) return; const dismissedCount = await getPromptDismissals(user.id); if (dismissedCount >= 3) return; showPasskeyEnrollmentBanner({ title: 'Ative o login rápido', description: 'Use Face ID ou sua digital em vez da senha', onAccept: () => registerPasskey(), onDismiss: () => incrementPromptDismissals(user.id), }); }
Fase 2: Padrão (Novos Usuários Começam Passwordless)
Registro de novos usuários começa com passkey. Senha fica disponível mas como opção secundária.
async function registerNewUser(userData: NewUser): Promise<void> { const user = await createUser(userData); // Verificar suporte WebAuthn primeiro const webauthnSupported = await isWebAuthnSupported(); if (webauthnSupported) { await registerPasskey(); // Primário — passkey // Opcionalmente ainda coletar senha como backup } else { await setPassword(user.id, userData.password); // Fallback } }
Fase 3: Transição (Opção Passwordless-Only)
Usuários com passkeys registrados podem optar por desabilitar sua senha.
app.post('/api/auth/disable-password', async (req, res) => { const user = await getUserFromSession(req); const credentials = await getUserCredentials(user.id); if (credentials.length < 2) { return res.status(400).json({ error: 'Registre pelo menos 2 passkeys antes de desabilitar a senha' }); } const hasSyncedPasskey = credentials.some(c => c.backed_up); if (!hasSyncedPasskey) { return res.status(400).json({ error: 'Registre pelo menos um passkey sincronizado (iCloud/Google) pra recuperação' }); } await db.query( 'UPDATE users SET password_hash = NULL WHERE id = $1', [user.id] ); res.json({ success: true }); });
Métricas de Migração
| Métrica | Objetivo | O Que Te Diz |
|---|---|---|
| Taxa de adoção de passkeys | >30% em 6 meses | Velocidade geral da migração |
| Completação do registro | >80% dos que começam | Fricção no UX de enrolamento |
| Taxa de sucesso de auth | >99% | Confiabilidade do fluxo passkey |
| Uso de fallback | Tendência de queda | Se os usuários tão saindo das senhas |
| Taxa de dismiss do prompt | <60% | Se os prompts tão agressivos demais |
Armadilhas de Segurança em Produção
1. Ataques de Replay de Challenge
Todo challenge tem que ser de uso único e ter tempo limitado. Se você guardar challenges num JWT stateless, um atacante pode replayar o mesmo challenge.
// RUIM: Challenge num cookie assinado (replayável) const challenge = signJWT({ challenge: randomBytes(32) }); // BOM: Challenge em store server-side com TTL const challenge = randomBytes(32).toString('base64url'); await redis.setex(`webauthn:challenge:${sessionId}`, 300, challenge); // Depois de verificar, apagar imediatamente await redis.del(`webauthn:challenge:${sessionId}`);
2. Validação de Contador
O contador de assinatura é sua defesa contra clonagem de credenciais. Se o contador andar pra trás, algo tá muito errado.
async function validateCounter( credentialId: string, newCounter: number ): Promise<void> { const stored = await getStoredCounter(credentialId); if (newCounter > 0 && newCounter <= stored) { await flagCredentialAsCompromised(credentialId); await notifySecurityTeam({ event: 'counter_regression', credentialId, storedCounter: stored, receivedCounter: newCounter, }); throw new Error('Regressão de contador detectada'); } }
Nota: Alguns autenticadores de plataforma (especialmente passkeys sincronizados) sempre reportam contador 0. Nesse caso, a validação de contador tá efetivamente desabilitada. Só marque regressões quando o contador tá sendo usado de verdade (ou seja, quando é maior que 0).
3. Validação de Origin
Sempre valide o origin de forma estrita. Um check de origin mal configurado pode anular a resistência a phishing que torna passkeys valiosos.
// RUIM: Match por substring (vulnerável a ataques de subdomínio) if (origin.includes('example.com')) { ... } // BOM: Match exato contra origins permitidos const ALLOWED_ORIGINS = [ 'https://example.com', 'https://app.example.com', ]; if (!ALLOWED_ORIGINS.includes(origin)) { throw new Error('Origin não permitido'); }
4. Recuperação de Conta
A maior dor de cabeça da autenticação passwordless: o que acontece quando o usuário perde todos os dispositivos?
interface RecoveryStrategy { minimumPasskeys: 2; requireSyncedCredential: true; fallbacks: [ 'recovery_codes', 'email_magic_link', 'identity_verification' ]; }
Gere códigos de recuperação durante o registro inicial do passkey e oriente os usuários a guardar em local seguro. É o mesmo padrão que security keys usam há anos — funciona.
Considerações Cross-Platform
Comportamento de Sincronização
| Plataforma | Mecanismo de Sync | Alcance |
|---|---|---|
| Apple | iCloud Keychain | Todos os dispositivos Apple com o mesmo Apple ID |
| Google Password Manager | Chrome/Android com a mesma conta Google | |
| Microsoft | Microsoft Authenticator | Dispositivos Windows com a mesma conta MS |
| 1Password / Bitwarden | Vault de terceiros | Cross-platform, todos os dispositivos com acesso |
Fluxo Cross-Device (Transporte Hybrid)
Quando um usuário quer fazer login num dispositivo que não tem o passkey dele, o transporte "hybrid" entra em ação:
- O navegador desktop mostra um QR code
- O usuário escaneia com o celular (que tem o passkey)
- O celular pede biométrico
- A autenticação completa no desktop
Isso funciona automaticamente — você não precisa implementar nada especial. Quando transports inclui 'hybrid', o navegador e o autenticador cuidam de todo o fluxo.
Modelo de Performance e Custos
Autenticação com passkeys é significativamente mais barata em escala que autenticação com senhas:
| Fator | Senhas | Passkeys |
|---|---|---|
| Compute do servidor | Hashing bcrypt/Argon2 (CPU intensivo) | Verificação Ed25519 (rápido) |
| Storage por usuário | ~128 bytes (hash + salt) | ~256 bytes (chave pública + metadata) |
| Tickets de suporte | "Esqueci minha senha" (~40% do volume) | Quase zero depois do enrolamento |
| Custo de brecha | Média de $4.44M por incidente | Sem credenciais úteis pra roubar |
| Custo de SMS 2FA | $0.01-0.05 por mensagem | $0 (verificação embutida) |
Pra um serviço com 100 mil usuários, eliminar só os fluxos de reset de senha pode economizar 15-20 horas de suporte por mês.
A Realidade de 2026
Passkeys não são tecnologia do futuro — são do presente. Todos os navegadores principais suportam. Todas as plataformas principais sincronizam. A spec do WebAuthn tá estável. Libs como SimpleWebAuthn cuidam dos detalhes criptográficos perigosos.
Os bloqueios reais são organizacionais, não técnicos. Times hesitam porque "nossos usuários tão acostumados com senhas" ou "não dá pra quebrar o fluxo de login atual". A migração por fases resolve isso. Não precisa deprecar senhas no dia um. Só precisa começar a oferecer algo melhor do lado.
Os times implementando passkeys hoje tão vendo taxas de adoção voluntária de 50-70% em seis meses quando o UX de enrolamento é fluido. Os usuários de verdade preferem tocar a digital a digitar uma senha. A fricção de conversão cai, a carga de suporte diminui, e a postura de segurança melhora drasticamente.
Começa pela Conditional UI. Requer mudanças mínimas de UI — só um atributo autocomplete e um script no carregamento da página. Usuários que têm passkeys pegam o caminho rápido automaticamente. Quem não tem vê o mesmo formulário de login de sempre. Zero disrupção, máximo benefício.
A senha é uma tecnologia de 60 anos. Tá na hora de aposentar.
Explore ferramentas relacionadas
Experimente estas ferramentas gratuitas do Pockit