Resposta curta. Uma chave de acesso (passkey) é uma credencial WebAuthn detectável: um par de chaves criado por um autenticador (um celular, o hardware seguro de um notebook, um gerenciador de senhas ou uma chave de segurança) para um único site, a parte confiável (relying party, RP). No registro, o servidor recebe uma chave pública; no login, recebe uma assinatura sobre um desafio gerado por ele. O servidor precisa verificar o desafio, a origem, o RP ID e a flag de verificação do usuário em toda resposta. Peça residentKey: "required" e userVerification: "required", ofereça -8, -7 e -257 como algoritmos, deixe o atestado em "none", a menos que você seja uma empresa que restringe os modelos de autenticador, e espere que a maioria das chaves de acesso seja sincronizada entre dispositivos.
O modelo em uma tabela
| Termo | O que significa para seu código |
|---|---|
| Parte confiável (RP) e RP ID | Seu site. O RP ID é um domínio, como example.com; as credenciais funcionam nesse domínio e nos subdomínios dele, nunca em outro lugar |
| Credencial detectável | Armazenada no autenticador junto com o identificador do usuário (user handle), por isso o login dispensa o nome de usuário. O WebAuthn Level 3 cita “passkey” como outro nome para ela |
Identificador do usuário (user.id) | O ID opaco da conta no seu sistema, com no máximo 64 bytes, devolvido no login. Nunca um endereço de e-mail |
| Presença do usuário (UP) e verificação do usuário (UV) | UP: alguém tocou no autenticador ou confirmou. UV: o autenticador conferiu um PIN ou a biometria. Flags nos dados do autenticador |
| Elegível para backup (BE) e com backup (BS) | Uma credencial que pode ser, ou já é, sincronizada com outros dispositivos: uma chave de acesso sincronizada, ou “multi-device credential” na especificação |
| Atestado e AAGUID | Uma cadeia de certificados opcional que informa qual modelo de autenticador criou a chave; o AAGUID identifica o modelo |
As flags dos dados do autenticador são: bit 0 UP, bit 2 UV, bit 3 BE, bit 4 BS, bit 6 AT (dados atestados da credencial incluídos) e bit 7 ED (extensões) (WebAuthn Level 3 §6.1). O WebAuthn Level 3 se tornou Recomendação do W3C em 25 de agosto de 2026.
Servidor: as duas cerimônias
Esta é uma parte confiável completa com o @simplewebauthn/server 14.0.3. O armazenamento fica em memória para manter o exemplo curto; em produção, os três mapas são tabelas de banco de dados, e o desafio fica na sessão do usuário, no lado do servidor.
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use
export async function registrationOptions(userName, { attestation = "none" } = {}) {
const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
users.set(userName, user);
const options = await generateRegistrationOptions({
rpName: "Example",
rpID,
userName,
userID: user.id,
attestationType: attestation,
authenticatorSelection: { residentKey: "required", userVerification: "required" },
supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
});
pending.set(`reg:${userName}`, options.challenge);
return options;
}
export async function finishRegistration(userName, response) {
const expectedChallenge = pending.get(`reg:${userName}`);
pending.delete(`reg:${userName}`);
const { verified, registrationInfo: info } = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
});
if (!verified) throw new Error("registration not verified");
const { credential } = info;
credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
users.get(userName).credentials.push(credential.id);
return info;
}
export async function authenticationOptions() {
// Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
pending.set(`auth:${options.challenge}`, options.challenge);
return options;
}
export async function finishAuthentication(response, challenge) {
if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
const stored = credentials.get(response.id);
if (!stored) throw new Error("unknown credential");
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
requireUserVerification: true,
});
if (!verified) throw new Error("authentication not verified");
stored.counter = authenticationInfo.newCounter;
return { userName: stored.userName, ...authenticationInfo };
}
O que as chamadas de verificação conferem e por que cada item importa:
- Desafio. Aleatório, de uso único, vinculado a esta cerimônia. Uma resposta com um desafio antigo é uma repetição (replay).
- Origem. O navegador grava a origem da página no
clientDataJSON, e o autenticador assina um hash desse conteúdo. É a comparação com sua lista de origens que torna as chaves de acesso resistentes a phishing: um site que imita o seu produz uma resposta com a própria origem. - Hash do RP ID. Os dados do autenticador começam com o SHA-256 do RP ID; uma credencial de outro site não pode ser usada no seu.
- Flags. A UP precisa estar ativada; a UV precisa estar ativada quando você depende apenas da chave de acesso, o que é justamente o propósito de uma chave de acesso.
- Assinatura sobre os dados do autenticador e o hash dos dados do cliente, com a chave pública armazenada no registro.
- Contador. Muitas chaves de acesso sincronizadas sempre informam 0. O WebAuthn trata um contador que diminui como possível clone apenas quando há um valor diferente de zero envolvido (§6.1.1); nunca rejeite o zero.
Navegador: duas chamadas
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
const credential = await navigator.credentials.create({ publicKey });
return credential.toJSON(); // send to the server as-is
}
async function signIn(optionsJSON) {
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
const credential = await navigator.credentials.get({ publicKey });
return credential.toJSON();
}
parseCreationOptionsFromJSON, parseRequestOptionsFromJSON e toJSON fazem parte do WebAuthn Level 3 e substituem o código de conversão de base64url que todo projeto costumava carregar. Para um campo de nome de usuário que ofereça chaves de acesso no preenchimento automático do navegador, chame navigator.credentials.get() com mediation: "conditional" quando a página carregar, depois de verificar PublicKeyCredential.isConditionalMediationAvailable(), e marque o campo de entrada com autocomplete="username webauthn".
Execução de ponta a ponta
O laboratório controlou o Chromium com o Playwright 1.64.1 (mcr.microsoft.com/playwright:v1.64.1-noble) e um autenticador virtual adicionado pelo Chrome DevTools Protocol. A página em http://localhost:3000 executou o browser.js; o script de controle levava para a página as opções geradas pelo server.mjs e trazia de volta as respostas. Cada linha vem de um registro ou de um login real:
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
O que cada linha demonstra:
- Algoritmos. As opções ofereciam
-8,-7e-257; o autenticador escolheu-8(EdDSA), o primeiro que ele aceita. A ordem expressa a preferência. - O
excludeCredentialsfunciona, com o InvalidStateError, que informa à sua interface que “este dispositivo já tem uma chave de acesso para esta conta”. - Login sem nome de usuário. O
allowCredentialsestava vazio; o autenticador ofereceu a credencial detectável dele, e o servidor encontrou a conta pelo ID da credencial e pela chave pública armazenada para ela. - Repetições falham no desafio. É a exclusão do desafio no momento em que ele é usado que faz a segunda tentativa falhar.
- O navegador não bloqueia toda origem errada. Uma página em
http://localhost:4000conseguiu criar uma credencial para o RP IDlocalhost, porque o RP ID abrange todas as portas e todos os subdomínios desse domínio. Só a verificação de origem do servidor a rejeitou: Unexpected registration response origin. MantenhaexpectedOrigincomo uma lista exata. - Chaves de acesso sincronizadas se identificam. Com a elegibilidade para backup ativada, o SimpleWebAuthn informou
multiDeviceebackedUp true. - O navegador impõe a UV obrigatória. Com um autenticador que não conseguia verificar o usuário, o navegador recusou a operação com o genérico NotAllowedError; o
requireUserVerificationno lado do servidor é a segunda linha de defesa.
Implantação de chaves de acesso junto com senhas
Poucos sites conseguem migrar todas as contas de uma vez. O WebAuthn Level 3 adiciona as peças para uma implantação gradual, e cada uma delas pode ser detectada com PublicKeyCredential.getClientCapabilities():
- Ofereça uma chave de acesso logo depois de um login com senha. Com o recurso
conditionalCreate,navigator.credentials.create()commediation: "conditional"permite que o gerenciador de senhas que acabou de preencher a senha crie uma chave de acesso sem uma caixa de diálogo extra. - Login pelo campo de nome de usuário. O recurso
conditionalGeté o fluxo de preenchimento automático descrito acima; quem tem uma chave de acesso a escolhe, e todos os outros digitam a senha como antes. - Mantenha a lista do autenticador sincronizada com a sua. Quando um usuário exclui uma chave de acesso nas configurações da conta do seu site, o autenticador continua a exibi-la.
signalUnknownCredential()avisa ao autenticador que um ID de credencial não é mais aceito,signalAllAcceptedCredentials()envia a lista completa de um usuário com sessão ativa, esignalCurrentUserDetails()atualiza o nome exibido depois que o usuário renomeia a conta (§5.1.10). - Vários domínios, um RP ID. Uma marca com login em
example.come emexample.co.ukpode manter um único RP ID e listar as outras origens emhttps://example.com/.well-known/webauthncomo{"origins": [...]}. O navegador busca esse arquivo quando o RP ID não é o próprio domínio da página (requisições de origens relacionadas, related origin requests, §5.11); se a busca falhar, o resultado é oSecurityErrormostrado abaixo. - Armazene BE e BS por credencial. Um usuário cuja única chave de acesso é de dispositivo único e não tem backup perde o acesso junto com esse dispositivo; peça que ele adicione uma segunda chave de acesso antes de remover a senha da conta.
Atestado: quando você precisa dele
O atestado responde à pergunta “qual modelo de autenticador criou esta chave”. Ele não diz nada sobre o usuário, sobre o nível de atualizações do dispositivo nem sobre se o dispositivo tem acesso root. Com attestation: "none", o padrão, o navegador substitui qualquer declaração de atestado por uma vazia antes que seu servidor a veja. Mesmo assim, o laboratório recebeu o AAGUID do autenticador: o WebAuthn Level 2 mandava os navegadores substituí-lo por 16 bytes zerados nesse caso, e o Level 3 eliminou essa etapa. Sem atestado, o AAGUID é uma alegação, não uma prova.
Não exija atestado em um site voltado ao consumidor final. As chaves de acesso sincronizadas foram feitas para acompanhar o usuário em qualquer dispositivo, e os provedores delas em geral não as atestam: ao lançar as chaves de acesso no Android, o Google disse que elas “inicialmente não terão nenhum atestado” (lista fido-dev, 2022). Quando recebeu o pedido "direct", o autenticador virtual do laboratório retornou uma declaração packed, o que mostra o que um autenticador com atestado envia, mas não o que um provedor de sincronização específico faz; por isso, teste os provedores que seus usuários de fato usam. Peça atestado quando você precisar restringir o registro a um conjunto de modelos, como as chaves de segurança fornecidas pela sua empresa, e verifique a cadeia no serviço de metadados da FIDO (FIDO Metadata Service). Para mostrar aos usuários um nome amigável do provedor de chaves de acesso deles (“Google Password Manager”, “1Password”), a lista de AAGUIDs de autenticadores de chaves de acesso, mantida pela comunidade, associa AAGUIDs a nomes; o próprio README dela diz que a lista serve só para exibição e não é fonte para decisões de segurança.
Algoritmos COSE
pubKeyCredParams lista identificadores de algoritmos COSE (registro COSE da IANA). O próprio exemplo da especificação aceita EdDSA, ES256 e RS256 e prefere EdDSA; essa lista cobre praticamente todos os autenticadores em uso:
| Valor | Nome | Observações |
|---|---|---|
| -8 | EdDSA | Só Ed25519 no WebAuthn (§5.8.5); marcado como obsoleto no registro COSE pela RFC 9864 em favor de -19 Ed25519, mas os autenticadores ainda emitem -8 |
| -7 | ES256 | ECDSA P-256; o mais amplamente aceito. Obsoleto no COSE em favor de -9 ESP256, mesma situação |
| -257 | RS256 | RSA PKCS#1 v1.5; o Windows 10 e algumas instalações do Windows Hello no Windows 11 só criam credenciais RS256 (Yubico na lista do W3C) |
| -48, -49, -50 | ML-DSA-44, -65, -87 | Registrados pela RFC 9964 (maio de 2026). O autenticador virtual do Chromium 156 aceitou o -49 (veja abaixo); confira os autenticadores reais e a biblioteca do seu servidor antes de depender deles |
Mantenha -7 e -257 na lista mesmo que você prefira -8: quando pubKeyCredParams está vazio, o padrão da própria especificação é ES256 e RS256. Armazene o algoritmo junto com a credencial; o SimpleWebAuthn o guarda dentro da chave pública COSE armazenada.
Os erros DOMException
navigator.credentials.create() e get() informam falhas por meio de um pequeno conjunto de exceções, listadas no WebAuthn Level 3, §5.1.3.1 e §5.1.4.3. Veja o que o Chromium 156 lançou para erros comuns, com uma chave de segurança USB virtual sem armazenamento de credenciais detectáveis:
only an unknown algorithm (-65000) NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49) created, COSE alg -49
residentKey required, key cannot store it NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes TypeError: User handle exceeds 64 bytes.
aborted with AbortController AbortError: signal is aborted without reason
| Exceção | Significado segundo a especificação | O que fazer |
|---|---|---|
NotAllowedError | Erro genérico: o usuário cancelou, o tempo esgotou, não há credencial correspondente, nenhum autenticador atendeu aos requisitos | Trate como “não concluído”; ofereça uma nova tentativa e outro método. Nunca mostre a mensagem bruta |
InvalidStateError | Uma entrada de excludeCredentials foi reconhecida | Informe ao usuário que este dispositivo já tem uma chave de acesso aqui |
SecurityError | O RP ID não é o domínio da página nem um sufixo registrável dele, ou a validação de origens relacionadas falhou | Corrija a configuração; veja a página sobre o SecurityError |
NotSupportedError | Nenhum algoritmo utilizável em pubKeyCredParams | Ofereça -7 e -257 |
ConstraintError | residentKey ou userVerification obrigatório e nenhum autenticador capaz de atendê-lo | Na prática, o mesmo tratamento do NotAllowedError |
AbortError | Seu AbortController cancelou a chamada | Esperado ao trocar do preenchimento automático para uma caixa de diálogo modal |
TypeError | Opções malformadas, por exemplo um identificador do usuário com mais de 64 bytes | Bug nas suas opções |
Dois resultados do laboratório diferem da lista da especificação: o Chromium respondeu NotAllowedError, e não NotSupportedError, quando o único algoritmo era desconhecido, e NotAllowedError, e não ConstraintError, quando foi exigida uma credencial detectável de uma chave que não consegue armazená-la. Um código que trata NotSupportedError ou ConstraintError à parte para explicar o problema aos usuários nunca vai receber essas exceções no Chromium nesses casos. As duas APIs só existem em contextos seguros. Em uma página com HTTP não criptografado que não seja localhost, o Chromium 156 informou isSecureContext: false, com navigator.credentials, PublicKeyCredential e crypto.subtle todos undefined; assim, o código falha com um TypeError antes de qualquer cerimônia começar (a variante do Web Crypto é crypto.subtle is undefined). Sirva por HTTPS toda página que usa chaves de acesso; http://localhost é a única exceção, para desenvolvimento.
Lista de verificação
- RP ID definido como o domínio registrável que seus usuários veem;
expectedOrigincomo uma lista explícita de origens HTTPS, incluindo todos os subdomínios em que há login. residentKey: "required",userVerification: "required", e o servidor verifica a UV em toda resposta.pubKeyCredParamscom-8,-7,-257;excludeCredentialspreenchido com as credenciais que o usuário já tem.- Desafios aleatórios, de uso único, de vida curta e armazenados no lado do servidor.
- Credenciais armazenadas com ID, chave pública, contador, transportes, flags BE e BS, AAGUID e um nome que o usuário pode editar; os usuários podem registrar várias e excluí-las.
- Atestado
"none", a menos que você restrinja os modelos de autenticador; nesse caso, verifique no FIDO MDS. - Recuperação de conta que não seja mais fraca que a própria chave de acesso: uma segunda chave de acesso ou um caminho de recuperação verificado, e não um código por SMS que contorna tudo.
Fontes
- Web Authentication Level 3, Recomendação do W3C, 25 de agosto de 2026: §5.1.3.1, §5.1.4.3, §5.8.5, §6.1, §6.1.1
- Registro de algoritmos COSE da IANA; RFC 9864; RFC 9964
- Documentação do SimpleWebAuthn
- FIDO Metadata Service
- AAGUIDs de autenticadores de chaves de acesso
- Chrome DevTools Protocol: domínio WebAuthn