Pular para o conteúdo

Passkeys: WebAuthn e FIDO2 sem senha

Como uma passkey funciona por baixo: chaves públicas, WebAuthn, FIDO2, transporte híbrido e por que ela não pode ser roubada como uma senha.

intermediárioPublicada em · 47 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Esta nota é uma aula sobre passkeys, o nome de mercado para credenciais WebAuthn/FIDO2. O objetivo é que, ao terminar, você consiga explicar por que uma passkey não pode ser roubada do jeito que uma senha pode, desenhar o fluxo de registro e de login de cabeça, saber o que o navegador e o servidor verificam em cada passo e reconhecer os erros que costumam quebrar a implementação. A parte final usa o openGym como estudo de caso real.

As figuras são animadas e foram geradas com o ArchFlow (veja Agents para o mesmo tipo de figura em outro tema). Os fatos de protocolo vêm da especificação W3C do WebAuthn, do capítulo de transporte híbrido do CTAP 2.2 publicado pela FIDO Alliance, das páginas de ajuda da Apple e do Google e da documentação do SimpleWebAuthn, consultados em 29/10/2026. A lista de fontes no fim diz o que foi lido na íntegra e o que ficou de fora.

Mapa da aula

ParteSeçõesPergunta que responde
1. Fundamentos1 a 3Por que senha falha e o que uma passkey faz diferente?
2. Arquitetura4 a 6Quem são os atores e como um registro e um login acontecem?
3. Conceitos que confundem7 a 10O que é RP ID, origem, verificação de usuário, credencial descobrível e attestation?
4. Sincronização e cross-device11 a 14Passkey sincronizada é a mesma coisa que vinculada ao aparelho? Como funciona o QR code?
5. Implementação15 a 18Como o código do navegador e do servidor conversa, e o que é guardado?
6. Estudo de caso19Como o openGym faz isso na prática, e por que um erro de origem acontece?
7. Armadilhas e prática20 a 22O que costuma dar errado, o que praticar e o que lembrar?

Pré-requisito: saber o que é um cookie ajuda na parte de sessão. Se faltar esse contexto, leia Cookie antes da seção 18.

Parte 1: Fundamentos

1. Por que senhas falham

Uma senha é um segredo que você repete para o site toda vez que entra. Essa repetição é o problema. Ela cria três caminhos de ataque que se somam. O primeiro é o phishing: você digita a senha numa página que parece a do seu banco, e a página fica com o texto. Ninguém precisa quebrar nada, basta que você digite. O segundo é o reuso: a mesma senha aparece em dezenas de sites, e quando um deles vaza a base de dados, os atacantes testam a combinação em todos os outros. O terceiro é o vazamento no próprio servidor: um site guarda senhas em formato reversível ou fraco, e uma invasão expõe milhões de contas de uma vez.

Os três têm a mesma raiz. O servidor precisa conhecer o segredo para verificar você, e o segredo viaja pela rede ou fica guardado em algum lugar. Qualquer um que receba uma cópia passa a poder se passar por você. Não há como fazer o servidor “saber” a senha sem que ela exista do lado dele.

Uma passkey inverte essa lógica. O segredo que prova quem você é nunca sai do seu aparelho. O que o servidor guarda é a parte pública, que serve só para conferir uma assinatura. Vazar a parte pública não permite entrar em conta alguma, porque entrar exige produzir uma assinatura nova, e essa só o aparelho faz.

2. A ideia central: criptografia de chave pública

A base é a criptografia de chave pública. Você tem duas chaves matematicamente ligadas: uma privada, que nunca sai do dispositivo, e uma pública, que pode ser entregue a qualquer um. Uma assinatura feita com a chave privada pode ser conferida com a pública, mas não pode ser fabricada sem a privada. Uma analogia boba que ajuda: a chave pública é uma caixa de correio com fenda, qualquer um deposita carta, e só você tem a chave da caixa para tirar. Só que aqui a carta é uma prova de que você está presente, e a fenda aceita apenas a prova gerada por você.

No registro, o autenticador cria esse par de chaves e entrega a pública ao servidor. No login, o servidor manda um desafio aleatório, o autenticador assina esse desafio com a privada, e o servidor confere a assinatura com a pública que já guardou. Como o desafio muda a cada tentativa, uma assinatura antiga não serve para nada. Quem intercepta a conversa pega só assinaturas usadas, e nenhuma delas abre a conta de novo.

Isso responde à pergunta do título: o segredo nunca sai do dispositivo. Ele não é transmitido, não é digitado e não fica no banco de dados do site. O que atravessa a rede são a chave pública, a assinatura e metadados que não permitem reconstruir a privada.

3. O que muda para quem usa

Para o usuário, a passkey troca “lembrar e digitar” por “confirmar aqui”. O aparelho pede o rosto, a digital ou o PIN do computador, e a entrada acontece. Não há campo de senha para o phishing preencher. O site certo recebe a assinatura, e um site falso nem chega a receber uma assinatura útil, como a seção 7 explica com detalhe.

A figura abaixo compara os dois caminhos diante de um site falso. Na coluna da senha, cada passo entrega mais uma cópia do segredo ao atacante. Na coluna da passkey, o navegador se recusa a produzir uma credencial para o site falso, e a tentativa termina bloqueada.

Senha contra passkey diante de um site falso. Você digita a senha → O site falso guarda o texto (digita)…
figura 1 · Senha contra passkey diante de um site falso

O olhar percorre as duas colunas de cima para baixo. À esquerda, as setas rosas mostram o segredo circulando e sendo reutilizado. À direita, a seta laranja sai do autenticador, a verde leva a assinatura ao site real, e a seta rosa com o X marca a tentativa barrada no site falso. A figura mostra o princípio. A seção 7 explica por que o bloqueio acontece.

Parte 2: Arquitetura

4. Atores e camadas

Uma passkey envolve quatro atores, e confundi-los é a origem de boa parte dos erros de quem começa. O usuário é a pessoa que toca o sensor. O autenticador é o componente que guarda a chave privada e a usa para assinar. Ele pode estar no próprio aparelho, como o Face ID do iPhone, o Touch ID do Mac ou o Windows Hello, e nesse caso se chama autenticador de plataforma. Pode também ser um objeto separado, como uma chave de segurança USB ou NFC, e aí se chama autenticador roaming, porque viaja com você.

O cliente é o software que conversa com o autenticador em nome do site. Na maioria das vezes é o navegador, ou o sistema operacional por trás dele. É o cliente que expõe a API JavaScript navigator.credentials, e é ele que decide se o site pode usar passkeys (exige contexto seguro, como veremos). O último ator é a Relying Party, abreviada RP, que é o servidor do site que quer autenticar o usuário. Quem escreve o código do servidor é quem é a RP.

Há duas especificações que as pessoas misturam. O WebAuthn é a API que o navegador expõe ao site, a parte que fica entre a página e o cliente. A W3C publicou o WebAuthn nível 3 como Recommendation em 25 de agosto de 2026. O CTAP2 é o protocolo que o cliente fala com o autenticador, por USB, NFC, Bluetooth ou pelo caminho híbrido. A junção dos dois, somada aos perfis de autenticação, é o que a FIDO Alliance chama de FIDO2. Em termos práticos, a página nunca fala CTAP2. Ela chama navigator.credentials.create() ou get(), e o navegador traduz essas chamadas para o CTAP2 com o autenticador.

Atores de uma passkey. Você → Autenticador (toque, rosto ou PIN); Autenticador → Cliente (assinatura e chave pública)…
figura 2 · Atores de uma passkey

A figura mostra a mesma divisão em camadas. A pessoa toca o sensor do autenticador, que fala CTAP2 com o navegador. O navegador fala WebAuthn com o seu servidor, que é a Relying Party, e o servidor guarda as credenciais públicas. Repare que a chave privada aparece só no grupo do dispositivo, e nada do grupo da internet a contém. As setas duplas mostram que a conversa vai e volta: o servidor manda opções e recebe a resposta.

5. Cerimônia de registro

A passkey nasce num registro, e a especificação chama esse passo de cerimônia de criação. O nome “cerimônia” aparece porque o fluxo tem uma ordem fixa de trocas, e cada troca só vale se a anterior foi feita. Um detalhe importante é que o servidor começa o processo. Ele não aceita uma credencial que chegou sem pedido.

O primeiro passo é o servidor gerar um challenge, um valor aleatório que o próprio servidor guarda. A especificação exige que ele tenha pelo menos 16 bytes de entropia para tornar a adivinhação inviável. Junto com o challenge, o servidor envia as opções: o nome da RP, o identificador do usuário, os algoritmos aceitos e se quer uma credencial descobrível. Essas opções saem em JSON, e o navegador precisa de alguns campos convertidos para buffers antes de usá-las.

O segundo passo é a página chamar navigator.credentials.create({ publicKey: opções }). O navegador mostra o diálogo, o usuário confirma com o rosto, a digital ou o PIN, e o autenticador gera o par de chaves. A privada fica guardada no autenticador, associada à RP e ao usuário. A pública e um identificador da credencial, o credentialId, voltam ao navegador dentro de uma resposta de attestation. O nome é ruidoso, mas a ideia é simples: é a declaração do autenticador sobre o que ele criou.

O terceiro passo é a página enviar essa resposta ao servidor, que confere o que a especificação manda conferir. Ele verifica se o challenge é o que ele mesmo emitiu e ainda está válido, se o tipo da operação é webauthn.create, se a origem do site é uma das que ele aceita, se o hash do RP ID bate com o seu e se o usuário esteve presente. Só então ele extrai a chave pública e o credentialId e os grava. Feito isso, o login do próprio registro costuma ser concluído com uma sessão.

Cerimônia de registro. 1. Gera o challenge → 2. Chama create() (opções com challenge); 2. Chama create() → 3. Cria o par de chaves (create(publicKey))…
figura 3 · Cerimônia de registro

A figura organiza a cerimônia em três raias, uma para cada ator que fala nela, e numera os seis passos na ordem em que acontecem. Acompanhe os números. O servidor gera o challenge (1), o navegador chama create() (2), o autenticador gera o par de chaves e pede a confirmação do usuário (3), o navegador devolve a attestation (4), e o servidor valida (5) antes de guardar a credencial e abrir a sessão (6). A seta de cada passo leva o dado que o próximo precisa. Repare que a chave privada não cruza nenhuma seta.

6. Cerimônia de autenticação

O login segue o mesmo raciocínio, com a assinatura no lugar da criação. O servidor gera um challenge novo, e nessa etapa não precisa saber quem é o usuário. Pode mandar uma lista vazia de credenciais permitidas, o que chamamos de login sem nome de usuário. O autenticador descobre sozinho qual passkey daquela RP pode ser usada. Isso é possível porque a credencial foi criada como descobrível, assunto da seção 9.

O navegador chama navigator.credentials.get({ publicKey: opções }), o usuário confirma, e o autenticador assina duas coisas: os dados de autenticação, que incluem o hash do RP ID e as flags, e o hash do clientDataJSON, que traz o tipo da operação, o challenge e a origem. A resposta volta com o credentialId, o identificador do usuário (userHandle), a assinatura e o contador de assinaturas.

O servidor recebe a asserção e segue a verificação da especificação. Ele procura a credencial pelo credentialId, confere o challenge e a origem, confere o RP ID, confere se o bit de presença do usuário está ligado e, se exigiu verificação, o bit de verificação. Depois verifica a assinatura com a chave pública guardada e compara o contador. Se tudo passar, atualiza o contador e abre a sessão.

Cerimônia de autenticação. 1. Gera um challenge novo → 2. Chama get() (challenge); 2. Chama get() → 3. Escolhe a passkey e assina (get(publicKey))…
figura 4 · Cerimônia de autenticação

A figura reaproveita a organização em raias da anterior, agora na ordem do login. O servidor gera o desafio (1), o navegador chama get() (2), o autenticador escolhe a passkey e assina (3), o navegador devolve a asserção (4), e o servidor verifica a assinatura e o contador (5) antes de abrir a sessão (6). Como o login não diz quem é o usuário, a seta do passo 1 leva só o challenge, e o nome da conta aparece pela credencial. A diferença de peso entre os passos está no 5: é nele que o servidor decide se confia.

Parte 3: Conceitos que confundem

7. RP ID, origem e o bloqueio do phishing

Este é o conceito que mais protege o usuário e o que mais confunde quem implementa. Cada passkey é vinculada a um RP ID, que é um domínio, como gym.example.com. O autenticador grava esse vínculo no momento do registro, e só usa a credencial quando o navegador pede para o mesmo RP ID. A especificação diz que o RP ID tem de ser igual ao domínio efetivo da origem da página, ou um sufixo registrável dele. Dessa forma, app.gym.example.com pode usar um RP ID gym.example.com, mas gym-example.net nunca pode usar um RP ID gym.example.com.

A origem é a combinação de esquema, host e porta, como https://gym.example.com. A assinatura carrega a origem dentro do clientDataJSON, e o servidor confere se ela é uma das que ele aceita. Juntos, RP ID e origem fazem o bloqueio do phishing: uma página falsa, em outro domínio, não consegue obter uma assinatura válida para o site real. Ela até pode mostrar o diálogo, mas o navegador não entrega a credencial dessa RP para a página falsa.

A especificação também exige contexto seguro, que na prática é HTTPS. Há uma exceção deliberada para localhost em HTTP, para que o desenvolvimento local funcione. Por isso a API só existe em HTTPS em domínios reais, e por isso um endereço de IP da rede local não serve para passkey: nem é HTTPS, nem é localhost.

Origem e RP ID: quando o navegador libera a credencial. RP ID: gym.example.com → Credencial usada; RP ID: gym.example.com → Credencial usada…
figura 5 · Origem e RP ID: quando o navegador libera a credencial

A figura mostra quatro casos lado a lado, com a mesma regra. Na primeira coluna, a origem e o RP ID são iguais, e a credencial é usada. Na segunda, a origem é um subdomínio, e o RP ID é o sufixo registrável, o que a regra permite. Na terceira, a página falsa pede o RP ID do site real, e o navegador bloqueia, marcado em rosa. Na quarta, o localhost usa a exceção de HTTP, e é permitido. O ponto a guardar é que a regra compara domínios, não o texto do que o usuário vê na barra de endereço.

Uma consequência prática importante: trocar o RP ID de um site depois que os usuários já têm passkeys invalida essas passkeys, porque elas foram vinculadas ao domínio antigo. Os guias de self-hosting do openGym avisam isso no texto de configuração.

8. Verificação do usuário e presença do usuário

Esses dois termos parecem sinônimos e não são. A presença do usuário, chamada de UP na especificação, significa que alguém interagiu com o autenticador naquele momento, por exemplo tocando um botão de uma chave de segurança. A verificação do usuário, UV, é mais forte: o autenticador confirmou quem é a pessoa, por biometria ou PIN. Um toque numa chave USB dá presença, mas não dá verificação, a não ser que a chave tenha PIN configurado.

O servidor decide o quanto exige. Na biblioteca SimpleWebAuthn, a checagem de UV vem ligada por padrão, e o servidor pode desligá-la para aceitar chaves só com presença. A escolha tem consequências: exigir UV protege contra alguém que pegou o aparelho destravado e apenas tocou no sensor, mas pode bloquear uma chave antiga sem PIN. O openGym passa requireUserVerification: false nas verificações e usa userVerification: 'preferred' nas opções, então pede verificação quando o autenticador pode dar, sem impedir quem não pode. Isso é uma decisão de compatibilidade, feita para aceitar chaves sem PIN, e o custo é que a segurança depende do aparelho do usuário ter bloqueio de tela.

9. Credenciais descobríveis, ou resident keys

Uma credencial descobrível, também chamada de resident key ou passkey de verdade, fica guardada no autenticador com o nome da RP e o identificador do usuário. Por isso o autenticador consegue listar as contas disponíveis sem que o site diga nada antes. Uma credencial não descobrível exige que o servidor envie o credentialId na lista de allowCredentials, e o site precisa primeiro saber quem é o usuário, normalmente pelo nome digitado.

A diferença muda a experiência. Com credenciais descobríveis, o botão “entrar com passkey” funciona sem campo de usuário, e o diálogo mostra as contas daquele site. O openGym cria credenciais com residentKey: 'required' e, no login, envia allowCredentials: [], o que pede uma credencial descobrível. Quem prefere um fluxo com nome de usuário pode enviar a lista, mas perde a entrada sem nome.

10. Attestation

A attestation é a declaração que o autenticador faz sobre si mesmo no registro: qual modelo é, qual fabricante e se o fabricante a certifica. Ela serve para RPs que querem impor um tipo de aparelho, como exigir uma chave de hardware certificada para funcionários. Para a maioria dos sites, não serve. O formato none diz ao navegador que não se quer a declaração, e a especificação alerta que a RP não deve manter listas de permissão de autenticadores, porque a attestation é fácil de falsificar em certos casos.

O openGym usa attestationType: 'none'. A consequência é que o servidor não sabe qual aparelho criou a passkey. Ele sabe apenas que a assinatura é válida para a chave pública guardada, que é o que importa para o login.

11. Passkeys vinculadas ao aparelho ou sincronizadas

Esta distinção aparece em qualquer conversa sobre passkeys e é a que mais afeta a recuperação de conta. Uma credencial vinculada ao aparelho, ou device-bound, vive num único autenticador. Uma chave de segurança USB é o exemplo clássico, e o Windows Hello de um notebook específico também. Se o aparelho some, a credencial some junto.

Uma credencial sincronizada, ou multi-device, pode ser copiada para outros aparelhos pelo provedor que guarda o cofre, como o chaveiro do iCloud, o gerenciador de senhas do Google ou um gerenciador de terceiros. Assim você cria a passkey no celular e a usa no notebook, sem cadastrar de novo.

A especificação define duas flags na parte de dados de autenticação. A BE, Backup Eligibility, bit 3, diz se a credencial é elegível para backup, ou seja, se é multi-device. Ela é definida na criação e não muda depois. A BS, Backup State, bit 4, diz se a credencial está de fato com backup feito naquele momento, e pode mudar com o tempo. A regra da especificação é que a BE é ligada se e somente se a credencial é multi-device, e a BS só é ligada se ela é multi-device e está copiada agora.

Passkey vinculada ao aparelho contra sincronizada. Chave só neste aparelho → Outro aparelho (não copia); Aparelho A → Nuvem do provedor (sincroniza)…
figura 6 · Passkey vinculada ao aparelho contra sincronizada

A figura compara as duas. À esquerda, a chave fica só no aparelho, marcada com BE=0 e BS=0, e o outro aparelho não tem a credencial. À direita, o mesmo provedor copia a credencial entre os dois aparelhos, marcada com BE=1 e BS=1. A seta rosa com o bloqueio mostra o caso que assusta quem perde o aparelho com chave vinculada: não há cópia para recuperar. A seta de sincronização, nos dois sentidos, mostra que a cópia é o que dá a comodidade da passkey sincronizada.

O que a Apple documenta sobre o iCloud Keychain

A página “About the security of passkeys” da Apple, consultada em 29/10/2026, diz que as passkeys são sincronizadas entre os aparelhos do usuário pelo iCloud Keychain. Ela afirma que o chaveiro é protegido por criptografia de ponta a ponta com chaves que a Apple não conhece, e que o serviço de custódia (escrow) não permite à Apple ler os dados que guarda.

Ao trocar de aparelho, o novo entra no grupo de sincronização de uma de duas formas: pareando com um aparelho que já tem o iCloud Keychain e sendo por ele autorizado, ou pela recuperação do iCloud Keychain. A recuperação pede a senha da conta Apple, um código SMS enviado ao telefone cadastrado e o código de desbloqueio do aparelho. A página diz que iOS, iPadOS e macOS permitem apenas 10 tentativas de autenticação; depois de várias falhas o registro fica travado e o usuário precisa ligar para o suporte da Apple, e após a décima falha o registro de custódia é destruído.

A página também diz que toda conta Apple que usa o iCloud Keychain precisa de autenticação de dois fatores. Sobre outros ecossistemas, ela diz que as passkeys funcionam em todos os aparelhos Apple e, sem explicar como, em aparelhos que não são da Apple quando estão na mesma proximidade física. Ela não afirma que o iCloud Keychain sincroniza passkeys com Android ou Windows.

O compartilhamento pelo AirDrop aparece num artigo de apoio separado, “Share passwords and passkeys securely with AirDrop on iPhone”, que a página de AirDrop da Apple cita. Consegui ver a referência, mas não abri o artigo. O que a página geral de AirDrop confirma é o alcance de cerca de 10 metros, Wi-Fi e Bluetooth ligados, e a exigência de contato ou de um código do AirDrop para quem não está na lista de contatos. Não consegui confirmar se a passkey enviada fica também no aparelho de quem envia.

O que o Google documenta sobre o Google Password Manager

A página “Passkey support on Android and Chrome”, do Google for Developers, com data da última atualização em 19/05/2025, diz que o Google Password Manager vem habilitado por padrão como provedor de passkeys no Android. O Chrome guarda e autentica passkeys nesse provedor por padrão no Android, no macOS, no Windows, no Linux e no ChromeOS. Segundo a página, uma passkey criada no Google Password Manager “é sincronizada e criptografada de ponta a ponta”.

O PIN do Password Manager entra nessa criptografia. A página diz que, se a primeira passkey do Google Password Manager for criada no computador, o Chrome pede a criação de um PIN e passa a usá-lo. Para recuperar as passkeys num novo aparelho, a pessoa entra na conta Google e informa o bloqueio de tela do Android ou o PIN do Password Manager, para descriptografar a passkey sincronizada. A página não explica o papel criptográfico do PIN além dessa etapa. Ela também diz que, no Android 14 ou superior, o usuário pode escolher outro provedor de passkeys nas configurações do sistema, embora isso possa não aparecer em alguns aparelhos de fabricantes.

Limites entre ecossistemas

Pelo que as duas páginas dizem, cada provedor guarda o seu cofre. Passkeys do iCloud Keychain ficam no ecossistema Apple, e passkeys do Google Password Manager ficam na conta Google e no Android ou Chrome. As páginas não descrevem uma sincronização direta entre os dois. Quando você entra numa conta a partir de um aparelho de outro ecossistema, o que acontece é o fluxo cross-device da seção 13, em que um aparelho assina para o outro, e não uma cópia da chave.

Dois pontos seguem sem confirmação: se o Chrome em macOS usa o iCloud Keychain ou o Google Password Manager por padrão, e o que exatamente acontece com a passkey compartilhada por AirDrop. Antes de descrever o comportamento de um produto específico, confirme na configuração do aparelho.

Parte 4: Sincronização e cross-device

12. Passkeys e o problema da recuperação

Uma passkey sincronizada resolve o problema do aparelho perdido, mas cria outro: quem controla o provedor controla a conta. Um atacante que invade o cofre de senhas do usuário herda as passkeys dele. Por isso a recuperação de conta é a parte mais delicada da implementação, e a boa prática é ter mais de uma forma de entrar. O openGym, por exemplo, deixa mais de uma passkey por conta (até 20 por padrão no código, em passkeys-store.js) e impede remover a última forma de entrar. Essa regra de “nunca perder o último jeito de entrar” aparece em quase todo produto sério.

A recuperação também precisa de um caminho que não dependa de passkey. Pode ser um código de uso único enviado a um e-mail verificado, uma senha de reserva ou uma chave de recuperação guardada pelo próprio usuário. O custo é de segurança: o caminho de recuperação passa a ser o alvo preferido de quem quer invadir a conta, então ele precisa ser tão forte quanto a passkey, e de preferência com alguma demora ou confirmação extra.

Os dois provedores da seção 11 já têm recuperação própria, com limites. No caso da Apple, o limite de 10 tentativas e a destruição do registro após a falha mostram que o provedor também pode perder a cópia. A sua aplicação continua responsável por ter um caminho de entrada próprio.

13. O fluxo cross-device e o QR code

Você provavelmente viu isso ao testar o login. Quando o navegador do computador pede uma passkey e a credencial está no celular, o diálogo oferece usar o celular, e aparece um QR code. Esse é o transporte híbrido do CTAP 2.2, o capítulo que descreve como um cliente (o computador) conversa com um autenticador que está em outro aparelho (o celular). Ele não exige que os dois estejam na mesma rede nem que tenham sido pareados antes.

O capítulo 11.5 do CTAP 2.2 separa duas coisas: a prova de que o autenticador está fisicamente perto do cliente, e o transporte das mensagens CTAP2 entre os dois. A prova é feita por Bluetooth Low Energy. O transporte usa um serviço de túnel na internet, uma rede de alta disponibilidade com domínios conhecidos pelos autenticadores.

O fluxo começa no computador. Ele gera uma chave P-256 de identidade e um segredo aleatório de 16 bytes, e mostra um QR code. O conteúdo do código é um URI que começa com FIDO:/, seguido de dados em dígitos codificados a partir de um mapa CBOR canônico. Entre os campos estão a chave pública comprimida do computador (chave 0), o segredo do QR (chave 1), o número de domínios de túnel que o computador conhece (chave 2) e uma dica do fluxo de usuário, como ga para getAssertion ou mc para makeCredential (chave 5). A especificação diz que a chave pública autentica o cliente para qualquer autenticador que se conecte, e que conhecer o segredo autentica o autenticador para o cliente.

Depois de mostrar o QR, o computador fica escutando. O celular lê o código com a câmera e, para avisar que quer conectar, anuncia por Bluetooth um anúncio BLE com o UUID 0000fff9-0000-1000-8000-00805f9b34fb e 20 bytes de dados de serviço. O computador escuta esse UUID e tenta decifrar cada anúncio com uma chave derivada do segredo do QR. O anúncio só é aceito se a etiqueta HMAC de 4 bytes confere. Decifrado, ele traz 16 bytes: um byte de flags, 80 bits de nonce de conexão, um identificador de roteamento de 24 bits e um identificador do domínio de túnel de 16 bits. O nonce é a prova de que quem fala tem o anúncio, portanto está perto. A especificação explica por que o anúncio existe: sem ele, um site poderia mostrar um QR para uma vítima e tentar convencê-la a escanear. Para passar pela prova, o atacante precisaria controlar um rádio Bluetooth perto da vítima.

Com o anúncio em mãos, o computador deriva o ID do túnel, um valor de 128 bits que vem do segredo do QR, e abre uma conexão WebSocket segura com o domínio de túnel. O caminho da URL é /cable/connect/ seguido do identificador de roteamento e do ID do túnel, ambos em hexadecimal minúsculo, e o subprotocolo é fido.cable. O servidor de túnel só encaminha mensagens. A especificação diz que ele é confiável pelo autenticador, mas que não se deve confiar nele mais do que o necessário.

Por fim vem a chave de sessão. Os dois lados fazem um handshake Noise KNpsk0, com P-256, SHA-256 e AES-256-GCM. O cliente fala primeiro, e assim prova que recebeu o anúncio. Esse handshake usa uma chave pré-compartilhada (PSK) derivada do segredo do QR junto com o anúncio decifrado inteiro, e a chave pública do cliente já estava no QR. A especificação descreve o resultado como uma conexão autenticada e segura para frente (forward-secure). Só depois disso as mensagens CTAP2 trafegam em quadros binários do WebSocket. No fim, o celular assina o desafio, e o computador envia a assinatura ao servidor do site, que é a RP.

O texto do diálogo do navegador

A página de ajuda do Chrome “Manage passkeys in Chrome”, consultada em 29/10/2026, descreve o fluxo: ao entrar com passkey, escolha a opção “Use Passkey from Another Device” ou “Use a phone or tablet”. Se a opção de passkey não aparecer, escolha “Try another way”. Depois, escaneie o QR code com o celular e siga as instruções na tela dele. A página também diz que as passkeys continuam no outro aparelho.

Os rótulos que você viu no diálogo podem ser diferentes dos citados pela ajuda, porque a interface muda entre versões. A ajuda não traz o título “Passkeys & Security Keys”, e não cita um rótulo para a opção de chave de segurança. Também não consegui abrir um navegador para conferir o diálogo ao vivo nesta consulta. A relação, porém, é clara: a opção de telefone ou tablet é o caminho híbrido desta seção, com QR code e Bluetooth; a opção de chave de segurança fala CTAP2 direto com a chave, por USB, NFC ou Bluetooth, sem QR code.

O estado da especificação

O texto lido acima é o do CTAP 2.2, publicado como Proposed Standard em 14/07/2025. Existe também o rascunho Proximity Exchange Protocol, Working Draft de 17/07/2026, que descreve os mesmos elementos: o QR com chave pública e segredo, o anúncio BLE, o túnel por WebSocket e o handshake Noise com AES-256-GCM. Por ser rascunho, a própria FIDO diz que ele não é base para implementação. Uma fonte secundária afirma que o CTAP 2.3.1 passou a apontar para esse rascunho no lugar do capítulo híbrido, mas não encontrei essa mudança nas páginas oficiais que consultei.

Login cross-device com QR code e Bluetooth. Computador → QR code (gera); QR code → Celular (câmera lê o QR); Celular → Computador (BLE)…
figura 7 · Login cross-device com QR code e Bluetooth

A figura segue a ordem do fluxo. O computador gera o QR code (1), o celular lê o QR pela câmera (2), o celular anuncia o sinal BLE cifrado que o computador escuta (3), e então os dois conversam pelo túnel (4 e 5). Quando a conversa está estabelecida, o celular assina o desafio, e o computador envia a assinatura para o servidor do site, que é a RP (6). Repare que o servidor do túnel está no meio, mas a seta do BLE não passa por ele: a prova de proximidade é direta entre os dois aparelhos.

14. Quando o híbrido é atacado

A figura seguinte mostra o caso que o mecanismo existe para barrar. Um site do atacante, em outro país, mostra um QR code próprio e pede que a vítima o escaneie com o celular. O celular lê o código e começa a anunciar por Bluetooth, mas o anúncio não chega ao computador do atacante, porque o celular está longe dele. A especificação exige que o cliente prove ter recebido esse anúncio, e sem o sinal o cliente não consegue fazer essa prova. Então a conexão não segue.

Falha do híbrido: QR sem proximidade. Site do atacante → Celular da vítima (QR escaneado); Celular da vítima → Site do atacante (anúncio BLE)…
figura 8 · Falha do híbrido: QR sem proximidade

A figura mostra a falha em três movimentos. O QR escaneado (seta rosa) chega ao celular, mas o anúncio BLE, marcado com o X, não chega ao site do atacante. A tentativa pelo servidor de túnel existe, mas o túnel sozinho não prova proximidade, e a caixa vermelha no fim mostra o handshake sem completar. O ponto da figura é que o protocolo não confia no que chega pela internet: a confiança vem do sinal de rádio, que só existe perto da vítima.

Por isso a boa prática do usuário é a mesma de qualquer QR: escanear só quando o diálogo veio do próprio navegador que ele está usando, e nunca um código mostrado por uma página que pediu isso sem motivo.

Parte 5: Implementação

15. Lado do navegador

O navegador só entende buffers binários, enquanto o servidor manda os campos em base64url, que é texto seguro para JSON. A primeira tarefa do frontend é converter os campos de ida, e a segunda é converter os de volta. A conversão é a parte mais chata e a mais comum de quebrar. O trecho abaixo é um esboço do fluxo de registro, com as funções de conversão omitidas:

// 1. Pede ao servidor as opções e o identificador da cerimônia (cid).
const { cid, options } = await fetch('/api/register/options', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Jean' })
}).then(r => r.json())

// 2. O navegador precisa de buffers, não de texto base64url.
options.challenge = b64uToBuf(options.challenge)
options.user.id = b64uToBuf(options.user.id)

// 3. Mostra o diálogo. Só funciona em contexto seguro (HTTPS ou localhost),
//    e só se o usuário confirmar com o rosto, a digital ou o PIN.
const cred = await navigator.credentials.create({ publicKey: options })

// 4. Devolve ao servidor o que ele precisa para verificar, em texto.
await fetch('/api/register/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ cid, credential: credToJSON(cred) })
})

O login segue o mesmo formato com navigator.credentials.get(). O ponto a guardar é que o desafio vem do servidor junto com o cid, e o cliente devolve os dois. Sem o cid, o servidor não consegue saber qual desafio foi usado.

Um detalhe do cliente costuma passar despercebido: o navegador pode cancelar a chamada se a janela perder o foco, e o código precisa tratar esse erro como cancelamento, e não como falha do sistema. O openGym, por exemplo, aceita um AbortSignal para fechar o diálogo quando o usuário muda de tela.

16. Lado do servidor

No servidor, a tarefa é gerar as opções, guardar o desafio e verificar a resposta. Há bibliotecas para isso em quase todas as linguagens. Em JavaScript, a mais usada é a @simplewebauthn/server. Em Go existe a go-webauthn, e em Java a java-webauthn-server. A regra é não implementar a verificação na mão, porque a lista de checagens é longa e um detalhe errado vira brecha. Ainda assim, a biblioteca só confere o que lhe é dito: o desafio esperado, a origem esperada e o RP ID esperado precisam vir da sua aplicação.

O trecho abaixo mostra o registro com a biblioteca usada pelo openGym, versão 13.3.3 instalada no projeto. Os nomes das opções e a forma da chamada são os mesmos do código real, e os comentários explicam o que a aplicação precisa guardar:

import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'

// Opções de criação. O challenge é gerado pela biblioteca e precisa ser guardado.
const options = await generateRegistrationOptions({
  rpName: 'openGym',
  rpID: 'localhost',                   // o domínio; nunca inclua esquema ou porta
  userID: Buffer.from(uid),            // identificador interno, não o nome
  userName: name,
  attestationType: 'none',             // não pedimos a declaração do aparelho
  authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
  excludeCredentials: []               // passkeys já existentes para não duplicar
})
// Guarde options.challenge junto com um cid, com prazo de 5 minutos e uso único.

// Verificação da resposta. Nenhum desses três valores vem do cliente.
const v = await verifyRegistrationResponse({
  response: body.credential,
  expectedChallenge: saved.challenge,  // o que o servidor emitiu
  expectedOrigin: 'http://localhost:5173',  // a origem que este site aceita
  expectedRPID: 'localhost',
  requireUserVerification: false       // o padrão da biblioteca é true
})
if (!v.verified) throw new Error('not verified')
const { credential } = v.registrationInfo
// Grave credential.id, credential.publicKey, credential.counter e transports.

Três pontos desse trecho merecem atenção. O primeiro é que expectedOrigin e expectedRPID precisam refletir o endereço real do site, e não um valor fixo copiado de outro projeto. O segundo é que o padrão da biblioteca exige verificação do usuário, e o openGym desliga isso explicitamente, o que é uma decisão de projeto, como a seção 8 explicou. O terceiro é o próprio desafio: ele é guardado do lado do servidor, e o cliente nunca o decide.

Na verificação do login, a biblioteca recebe a credencial guardada e confere a assinatura. O trecho abaixo resume a chamada:

import { verifyAuthenticationResponse } from '@simplewebauthn/server'

const v = await verifyAuthenticationResponse({
  response: body.credential,           // a asserção que o cliente enviou
  expectedChallenge: saved.challenge,  // o desafio do cid, já consumido
  expectedOrigin: 'http://localhost:5173',
  expectedRPID: 'localhost',
  requireUserVerification: false,
  credential: {
    id: cred.id,                       // o credentialId guardado
    publicKey: Buffer.from(cred.publicKey, 'base64url'),  // Uint8Array, não texto
    counter: cred.counter,
    transports: cred.transports
  }
})
if (!v.verified) throw new Error('not verified')
cred.counter = v.authenticationInfo.newCounter   // grave o novo valor

A biblioteca lança erro quando o contador regride, ou seja, quando o novo valor não sobe em relação ao guardado, e um dos dois não é zero. O código do openGym captura esse erro e responde com uma recusa. A seção 17 explica o contador com mais detalhe.

A figura a seguir organiza essa mesma verificação como uma sequência de decisões. Cada losango é uma checagem, e a seta “sim” leva à próxima. Cada “não” cai numa recusa com motivo próprio, em rosa, e a única saída que cria sessão é a verde, no fim da cadeia.

Verificação no servidor, em ordem. Chega POST /login/verify → Challenge existe e não expirou?; Challenge existe e não expirou? → Tipo e origem conferem? (sim)…
figura 9 · Verificação no servidor, em ordem

A leitura vai de cima para baixo. A primeira pergunta é se o desafio existe e ainda está dentro dos cinco minutos. Depois vêm o tipo da operação e a origem, o RP ID, a assinatura com a chave guardada e o contador. Só depois de cinco “sim” seguidos o servidor abre a sessão. A ordem importa: a assinatura é a checagem mais cara, e as baratas (desafio, origem, RP ID) vêm antes, para que uma tentativa claramente errada nem chegue a ela.

17. Modelo de dados

O que o servidor guarda é pequeno, e essa simplicidade é uma vantagem. Cada passkey é uma linha de credencial ligada a um usuário, e cada desafio é um registro temporário. A figura mostra as duas coisas, com o desafio fora do banco de dados principal, porque ele vive poucos minutos.

Modelo de dados de uma passkey. usuarios → credenciais (tem); Desafios → usuarios (verifica e apaga)
figura 10 · Modelo de dados de uma passkey

A figura mostra a tabela de usuários ligada à de credenciais pela chave estrangeira, com uma credencial por linha e a chave pública em texto. A tabela de desafios não tem vínculo permanente: ela guarda o identificador da cerimônia, o challenge, o tipo da operação e a validade, e é apagada no uso. A seta de “verifica e apaga” carrega a regra de uso único, que faz parte da segurança do fluxo.

O contador merece uma explicação. A especificação diz que o autenticador pode manter um contador que sobe a cada assinatura, e que o servidor deve guardar o último valor visto. Se um contador não zero chegar menor ou igual ao guardado, pode existir uma cópia clonada da chave, ou um autenticador com defeito, ou uma resposta fora de ordem. A especificação é cuidadosa: essa situação não prova clonagem, e apenas sinaliza que vale checar. Passkeys sincronizadas costumam não usar contador, e nesse caso o valor fica em zero e a checagem não se aplica. Não trate zero como sinal de clonagem.

Os nomes das colunas na figura são uma forma conceitual. O openGym não usa banco SQL: ele guarda o estado em db.json dentro de DATA_DIR, e as credenciais ficam como registros JSON com os campos id, userId, publicKey, counter, transports, created e lastUsed. O modelo é o mesmo, só muda a forma de armazenar.

18. A ordem das chamadas e a sessão

Juntando tudo, a ordem completa do login é esta: o cliente pede opções, o servidor gera um desafio e devolve o cid, o navegador chama get(), o autenticador assina, o cliente envia a asserção com o cid, o servidor consome o desafio, verifica a assinatura, atualiza o contador e só então abre a sessão. A sessão é o ponto em que a passkey deixa de existir para o restante do site. Dali em diante, o que prova quem você é é um cookie.

A passkey não substitui a sessão, e essa é uma confusão comum. Ela prova a identidade uma vez, e o cookie carrega essa prova pelas requisições seguintes. Se a sessão for fraca, a passkey de nada adianta, porque um cookie roubado dá acesso sem nova assinatura. Por isso o cookie precisa de atributos firmes: HttpOnly para o JavaScript da página não ler, SameSite=Lax para limitar o envio entre sites, Secure quando o site é HTTPS e um prazo de validade explícito. Uma boa prática adicional é assinar o conteúdo do cookie com uma chave do servidor, para que ele não possa ser forjado.

Um cuidado a mais, que aparece em sites com passkeys e cookies: as rotas de login são alvo de login CSRF. Um site malicioso poderia fazer o navegador da vítima enviar um login para a conta do atacante e trocar a sessão dela. O openGym trata isso verificando a origem de toda requisição que altera estado, inclusive as rotas de passkey, o que é explicado no próprio código do servidor.

Parte 6: Estudo de caso, openGym

19. Como o openGym faz isso

O openGym é um app de academia e peso corporal com servidor próprio. O login por passkey é a forma padrão de entrar, e o código fica em api/server.js do repositório treino-app, com a parte de cookie e sessão no mesmo arquivo. A leitura abaixo é só de leitura: nenhuma linha do projeto foi alterada.

A configuração vem de três variáveis: RP_ID, ORIGIN e RP_NAME. Os padrões aparecem em dois lugares que batem entre si. O .env.example traz RP_ID=localhost, ORIGIN=http://localhost:8080 e WEB_PORT=8080, e o api/server.js usa os mesmos valores quando as variáveis não existem. O RP_NAME vale openGym. O guia docs/SELF_HOSTING.md explica que, numa instalação real, o RP_ID é o domínio final, e que trocá-lo depois invalida as passkeys existentes. O guia não cita a porta 5173 do desenvolvimento: essa informação só aparece no frontend/vite.config.js.

Caso real: origin em localhost:5173

Um erro que confunde muita gente acontece quando o app abre em http://localhost:5173, a porta do servidor de desenvolvimento do Vite, e a API continua com o ORIGIN padrão, http://localhost:8080. A tela mostra a mensagem “Este servidor só aceita alterações feitas em http://localhost:8080”, seguida de uma orientação para ajustar ORIGIN e RP_ID. A frase vem da função refusal em frontend/src/lib/api.js, e aparece quando o servidor responde 403 com o código origin-mismatch. Ela não vem da verificação do WebAuthn, e isso muda onde procurar a causa.

Há duas checagens de origem no caminho, e a mensagem pode vir de qualquer uma delas. A primeira é o guarda de CSRF, a função csrfOk em api/server.js. Ela roda antes de qualquer rota e recusa requisições que alteram estado quando o navegador diz que elas vieram de outro site. Primeiro ela olha o cabeçalho Sec-Fetch-Site, que o navegador define e a página não consegue forjar, e aceita os valores same-origin e none. Se esse cabeçalho não existir, ela compara o cabeçalho Origin com ORIGIN. A segunda checagem é a da própria passkey: a origem que o navegador escreve no clientDataJSON é comparada com expectedOrigin pela biblioteca. Quando isso falha, a biblioteca devolve um erro do tipo “Unexpected authentication response origin”, e o api/verify-error.js troca essa mensagem por outra que cita RP_ID e ORIGIN. Nas duas checagens, a regra é a mesma: a origem precisa bater com o valor configurado no servidor.

Por que a origem não bateu? O frontend/vite.config.js reescreve o cabeçalho Origin das requisições /api para API_ORIGIN, que por padrão vale http://localhost:8080. A API então recebe a origem 8080, que bate com o ORIGIN padrão dela, e a comparação passa. Pelo código, o navegador que abre o app em 5173 também envia Sec-Fetch-Site: same-origin, e esse valor passa pelo guarda. Por isso, a recusa que aparece indica que a requisição chegou à API sem esse cabeçalho, ou por um caminho que não passa pelo proxy do Vite. Não consegui reproduzir o navegador ao vivo nesta consulta. A forma segura de confirmar é o log do servidor: a recusa escreve uma linha refused cross-origin, com a origem recebida e a esperada, e essa linha mostra qual valor chegou.

Há duas formas de resolver:

  1. Abrir o app em http://localhost:8080. Nesse endereço, o contêiner web serve o app e encaminha /api para a API, então a origem do navegador e o ORIGIN são o mesmo valor, sem reescrita.
  2. Manter o app em 5173 e configurar as duas pontas. A API sobe com ORIGIN=http://localhost:5173, e o Vite sobe com API_ORIGIN=http://localhost:5173 npm run dev. Só uma das duas não basta: se o Vite continuar enviando Origin: http://localhost:8080, a API recusa. O comentário do vite.config.js diz exatamente que o API_ORIGIN deve acompanhar o ORIGIN do .env.

Nas duas soluções, RP_ID continua localhost, porque o RP ID não carrega porta. Só a origem muda de um caso para o outro, e ela é a parte que a passkey confere no clientDataJSON.

Como o código se organiza

No cliente, o arquivo frontend/src/lib/api.js faz as conversões de base64url, chama navigator.credentials.create() e get(), e encapsula o fluxo em passkeyRegister, passkeyAssertion e passkeyLogin. A função passkeyAssertion é a que serve também para confirmar ações sensíveis, como adicionar outra passkey, e por isso ela aceita um sinal de cancelamento.

No servidor, o registro e o login estão nas rotas POST /api/register/options, POST /api/register/verify, POST /api/login/options e POST /api/login/verify. O mapa de desafios é um Map em memória, com validade de cinco minutos, criado com putChallenge e consumido com takeChallenge. A função takeChallenge apaga o desafio antes de conferir a validade, o que garante o uso único mesmo quando a verificação falha. Os desafios de cada cerimônia têm um tipo, e uma rota só aceita o tipo que ela mesma emitiu. Isso impede que um desafio de cadastro seja usado como login.

Depois de verificada, a asserção atualiza o contador da credencial e grava o lastUsed, e o servidor responde com um Set-Cookie. A função makeSession monta o conteúdo do cookie com o uid, a validade, a versão da sessão e o tipo, e sign assina tudo com HMAC-SHA256 usando um segredo guardado em DATA_DIR. O cookie sai com HttpOnly e SameSite=Lax, e com Secure e o prefixo __Host- quando a origem é HTTPS. A função sessionCookie também remove o cookie antigo sem prefixo, para que ninguém fique com dois.

A figura abaixo junta esse caminho. Ela segue o fluxo real de login, com os nomes das rotas e das funções que aparecem no código.

Login por passkey no openGym. Variáveis de ambiente → api/server.js (define rpID e origem); Navegador (api.js) → api/server.js (POST /api/login/options)…
figura 11 · Login por passkey no openGym

A figura mostra o login inteiro a partir do navegador. A pessoa chama as opções (seta azul), o servidor guarda o desafio no mapa, o navegador pede a assinatura à passkey do aparelho (seta laranja), o login é enviado com o cid, o servidor apaga o desafio, confere a credencial no db.json e atualiza o contador, e por último grava o cookie de sessão. A caixa de variáveis de ambiente, à direita, mostra o único lugar onde o RP ID e a origem são definidos. Repare que a seta de configuração não transporta nenhum dado do usuário: ela só informa ao servidor o que ele deve esperar.

Esse é o mesmo esqueleto da seção 6, com nomes concretos. A parte que mais se parece com o que você aprendeu é a verificação do servidor, que segue a lista da especificação, e a parte que mais se parece com um projeto de verdade é a de cookie, que precisa de HTTPS para ganhar os prefixos de segurança.

Parte 7: Armadilhas, boas práticas e prática

20. Erros comuns e o porquê de cada um

O primeiro erro é o RP ID incorreto. Usar a origem completa, com esquema e porta, no lugar do domínio, faz o registro funcionar em algumas máquinas e falhar em outras, e quase sempre aparece como “não verificado”. O RP ID é um domínio. A origem é um endereço completo, e as duas coisas entram em campos diferentes.

O segundo é validar a origem de forma frouxa, aceitando qualquer valor que começa com o domínio certo. Um site como gym.example.com.attacker.net passaria num teste de prefixo. A comparação precisa ser de igualdade com a lista de origens permitidas, e a origem precisa ser conferida em toda verificação, não só no registro.

O terceiro é reutilizar o desafio. Se o servidor não apaga o desafio no uso, uma asserção capturada pode ser reenviada. A regra é consumir o desafio antes de verificar, para que ele valha uma única vez, mesmo que a verificação falhe depois. Uma consequência é que o usuário precisa começar de novo quando a tentativa falha, e isso é aceitável.

O quarto é esquecer de validar o challenge no servidor e confiar no que o cliente manda de volta. O desafio vem do servidor, e a verificação precisa comparar com o que foi guardado, não com o valor enviado.

O quinto é tratar o contador como prova de clonagem. A especificação fala em sinal, e não em prova. Passkeys sincronizadas costumam ter contador zero. Um erro de contador deve ser registrado e, conforme a política, tratado com cautela, mas não deve virar diagnóstico definitivo.

O sexto é o fallback mal desenhado. Oferecer senha como alternativa tira a proteção da passkey se a senha for fraca ou se o fluxo de recuperação for fácil de explorar. Um fallback só faz sentido se for pelo menos tão forte quanto a passkey, e a maioria das aplicações precisa decidir isso com cuidado.

O sétimo é a falta de múltiplas passkeys por conta. Com uma única passkey, perder o aparelho significa perder a conta. Permitir cadastrar várias, em aparelhos diferentes, e impedir remover a última forma de entrar, é a regra que mais evita chamados de suporte.

O oitavo é a UX de recuperação ruim. Um botão “esqueci a passkey” que leva a um formulário de senha esquecida, sem verificação forte, transforma a recuperação no ponto mais fraco do sistema. A boa prática é tratar a recuperação como um fluxo de primeira classe, com aviso ao e-mail, prazo e confirmação.

O nono é confundir a origem da página com a origem da API em desenvolvimento. Quando o front e a API rodam em portas diferentes, a origem que a API precisa aceitar é a do navegador, e o proxy não pode reescrevê-la para outro valor sem que o servidor saiba disso. O caso da seção 19 mostra exatamente essa armadilha.

21. Boas práticas resumidas

Use uma biblioteca madura e não implemente a verificação na mão. Gere o desafio no servidor com pelo menos 16 bytes e guarde-o com validade curta e uso único. Compare origem e RP ID com listas explícitas. Decida conscientemente se exige verificação do usuário e documente a decisão. Grave o contador e trate zero com cuidado. Mantenha mais de uma forma de entrar e impeça remover a última. Proteja o cookie de sessão com os atributos certos e trate as rotas de login contra CSRF. E confirme na documentação atual, antes de descrever, o comportamento de provedores de sincronização e de transportes que estão em rascunho.

22. Prática

Estes exercícios podem ser feitos sem um servidor de produção, com um projeto pequeno ou com o próprio openGym rodando localmente.

  1. Explique com as suas palavras por que a chave privada da passkey não pode ser roubada por um site falso, citando o papel do RP ID e da origem. Se a explicação precisar de “o navegador confere” sem dizer o quê, reescreva.
  2. Rode o openGym em localhost:5173 com o ORIGIN padrão, abra o login e anote a mensagem. Depois suba a API com ORIGIN=http://localhost:5173 e o Vite com API_ORIGIN=http://localhost:5173, e compare. Confira no log do servidor a linha refused cross-origin de cada tentativa.
  3. No registro, troque attestationType de none para direct em um projeto de teste e descreva o que muda no servidor. Não rode isso em produção.
  4. Escreva, em um papel, a tabela de credenciais de um sistema com três usuários e duas passkeys por usuário, e decida quais campos são obrigatórios para o login funcionar.
  5. Leia a seção 11.5 do CTAP 2.2 (o capítulo híbrido) e compare com o rascunho do PXP. Liste duas diferenças que você encontrar, ou registre que não conseguiu ler.

Glossário

Relying Party (RP): o servidor do site que quer autenticar o usuário. Também chamada de parte confiante.

RP ID: o domínio ao qual uma passkey está vinculada, como gym.example.com. Não inclui esquema nem porta.

Origem: a combinação de esquema, host e porta de uma página, como https://gym.example.com.

Autenticador: o componente que guarda a chave privada e assina os desafios. Pode ser de plataforma ou roaming.

Challenge: valor aleatório gerado pelo servidor, usado uma única vez para impedir a reutilização de assinaturas.

Asserção: a resposta de um login, com credentialId, dados de autenticação, assinatura e userHandle.

Attestation: a declaração do autenticador sobre si mesmo, dada no registro.

UP e UV: presença do usuário (alguém tocou no autenticador) e verificação do usuário (a pessoa foi identificada por biometria ou PIN).

Credencial descobrível (resident key): credencial guardada no autenticador, que permite login sem o site informar o nome.

BE e BS: flags que dizem se a credencial é elegível para backup (multi-device) e se está copiada no momento.

Contador (signCount): número que o autenticador pode incrementar a cada assinatura, usado como sinal de clonagem.

CTAP2: protocolo entre o cliente e o autenticador, por USB, NFC, Bluetooth ou pelo caminho híbrido.

Transporte híbrido: o mecanismo do QR code, que usa Bluetooth para provar proximidade e um túnel na internet para a conversa.

PSK: chave pré-compartilhada, aqui derivada do segredo do QR e do anúncio BLE, usada no handshake Noise.

Escrow (custódia): registro guardado pelo provedor para permitir recuperação, que na Apple só pode ser lido com a conta e o aparelho do usuário.

Fontes

Consultadas em 29/10/2026:

Lacunas que ficaram sem confirmação:

  • O artigo “Share passwords and passkeys securely with AirDrop on iPhone” da Apple não foi aberto, então não sei se a passkey enviada fica também no aparelho de quem envia.
  • Não há, nas páginas oficiais lidas, uma descrição de sincronização entre iCloud Keychain e Google Password Manager, nem de qual provedor o Chrome em macOS usa por padrão.
  • O título “Passkeys & Security Keys” e o rótulo “Use your security key” não aparecem nas páginas do Chrome consultadas. Não rodei um navegador ao vivo.
  • Não confirmei nas páginas oficiais se o CTAP 2.3.1 aponta para o rascunho PXP. A informação veio de fonte secundária.
  • A recusa de origem vista no app em localhost:5173 depende de qual cabeçalho chegou à API. Não reproduzi o navegador, então a causa exata no seu ambiente deve ser confirmada pela linha refused cross-origin do log.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?