Passkeys (WebAuthn)
Login sem senha, com a biometria que o usuário já usa pra desbloquear o aparelho.
O SDK cobre a metade do cliente: as duas cerimônias (navigator.credentials.create e .get), a conversão base64url ↔ ArrayBuffer que todo mundo reimplementa errado, os sondadores de capacidade e a classificação de erro. A outra metade é o seu backend — e esta página diz exatamente o que ele precisa mandar e receber, porque um cliente WebAuthn que documenta só o próprio lado é inútil.
Por que passkey e não senha
A chave privada nunca sai do aparelho, então não existe base de senhas pra vazar, nem phishing que funcione: o navegador só assina para o domínio que registrou a credencial. Pro usuário é um toque no sensor.
A superfície
| Símbolo | Para quê |
|---|---|
createPasskeyClient |
Cliente sem framework: register + authenticate + sondadores |
usePasskeyRegistration |
Hook do fluxo "criar passkey" |
usePasskeySignIn |
Hook do fluxo "entrar com passkey", com autofill opcional |
usePasskeyCapabilities |
Só os três sondadores, quando você quer decidir a UI antes |
isPasskeySupported |
O navegador tem WebAuthn |
isPlatformAuthenticatorAvailable |
Este aparelho tem Face ID / Hello / digital |
isConditionalMediationAvailable |
O autofill de passkey está disponível |
classifyPasskeyError / PasskeyError |
Erro com kind no qual a UI faz switch |
base64UrlToBytes / bytesToBase64Url |
O encanamento, exposto porque você vai precisar |
Comece pelo hook
Dois botões, dois hooks. Este é o cadastro, na tela de conta:
import { usePasskeyRegistration } from "tempest-react-sdk";
import { api } from "../lib/api";
export function CriarPasskey() {
const passkey = usePasskeyRegistration({
getOptions: () => api.post("/api/webauthn/register/begin"),
verify: (credential) =>
api.post("/api/webauthn/register/finish", { body: credential }),
});
if (passkey.platformAvailable === null) return null;
if (passkey.error?.kind === "already-registered") {
return <p>Este aparelho já tem uma passkey da sua conta. 👍</p>;
}
return (
<>
<button
type="button"
onClick={() => void passkey.register()}
disabled={passkey.status === "prompting" || passkey.status === "verifying"}
>
{passkey.status === "prompting" ? "Confirme no aparelho…" : "Criar passkey"}
</button>
{passkey.error && <p role="alert">{passkey.error.message}</p>}
{passkey.status === "success" && <p>Passkey criada. ✅</p>}
</>
);
}
Peça por peça:
getOptionsé a sua rotabegin. O servidor sorteia o desafio e devolve as opções.verifyé a sua rotafinish. Recebe o JSON que o SDK serializou.platformAvailable === nullsignifica "ainda não sei" — o sondador é assíncrono. Não renderize nada de passkey nesse estado.register()resolve, nunca rejeita. Toda falha já é estado (status,error.kind) que a UI precisa desenhar; obrigar umtry/catchnum handler de botão só duplicaria o que o hook guarda.
platformAvailable === false não é "sem passkey"
Significa "sem autenticador embutido aqui". O usuário ainda pode usar o celular por QR/hybrid. Ofereça como "usar meu celular", não como um toque.
O login, e o autofill que faz a diferença
import { usePasskeySignIn } from "tempest-react-sdk";
import { api } from "../lib/api";
import { useAuthStore } from "../store/auth";
export function LoginForm() {
const passkey = usePasskeySignIn<{ access_token: string }>({
conditional: true,
getOptions: () => api.post("/api/webauthn/signin/begin"),
verify: (assertion) =>
api.post<{ access_token: string }>("/api/webauthn/signin/finish", {
body: assertion,
}),
onSuccess: ({ access_token }) => useAuthStore.getState().setToken(access_token),
});
return (
<form>
<label htmlFor="email">E-mail</label>
<input id="email" name="email" autoComplete="username webauthn" />
<button type="button" onClick={() => void passkey.signIn()}>
Entrar com passkey
</button>
{passkey.error && <p role="alert">{passkey.error.message}</p>}
</form>
);
}
conditional: true liga o fluxo de autofill: nenhum modal aparece; o navegador lista as passkeys dentro do campo de e-mail. O usuário toca em uma e está dentro, sem digitar nada.
autocomplete="username webauthn" não é opcional
Sem esse atributo no input, a lista nunca aparece — e não há erro nenhum, o pedido só fica pendurado. O hook não pode fazer essa parte por você: quem sabe qual campo é o de identificação é a sua tela.
Duas outras regras do modo condicional:
- Um por página. Um segundo pedido condicional simultâneo derruba o primeiro.
- Abortar não é erro. Se o usuário digitar a senha em vez de escolher a passkey, ou a tela desmontar, o pedido é abortado e o hook não marca erro — nada falhou, a pessoa escolheu outra porta.
Os erros, e o que fazer com cada um
error.kind é o que a UI olha. error.message é texto em inglês, seguro de mostrar, mas o normal é você escrever a sua cópia por kind:
kind |
Causa | O que a UI faz |
|---|---|---|
cancelled |
NotAllowedError |
Nada de vermelho. Volte ao estado inicial |
already-registered |
InvalidStateError |
Sucesso disfarçado: "já configurado neste aparelho" |
rp-mismatch |
SecurityError |
Bug seu: rp.id não bate com a origem |
not-supported |
NotSupportedError |
Nenhum autenticador aceita os algoritmos pedidos |
invalid-options |
TypeError |
O begin mandou base64url quebrado |
insecure |
Página em HTTP | Sirva por HTTPS |
unsupported |
Navegador sem WebAuthn | Esconda a opção |
aborted |
Seu signal abortou |
Silencie |
unknown |
O resto | Log + mensagem genérica |
cancelled é "cancelou ou expirou" — e é impossível saber qual
NotAllowedError cobre os dois casos por design: dizer ao site "essa conta não tem credencial aqui" vazaria a existência da conta. Trate como um só evento e nunca escreva "você cancelou" com certeza.
rp-mismatch é o erro de integração nº 1
rp.id tem que ser o domínio da página ou um pai registrável dele. app.acme.com pode usar acme.com; acme.com não pode usar app.acme.com. Em localhost deixe rp.id de fora.
Sem React: createPasskeyClient
Os hooks são uma casca. O cliente é o que faz o trabalho, e serve num formulário puro, num worker ou num teste:
import { createPasskeyClient } from "tempest-react-sdk";
const passkeys = createPasskeyClient({ rpId: "acme.com", timeoutMs: 60_000 });
const options = await fetch("/api/webauthn/signin/begin", { method: "POST" }).then((r) =>
r.json(),
);
const assertion = await passkeys.authenticate(options);
await fetch("/api/webauthn/signin/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(assertion),
});
rpId e timeoutMs são só defaults: o que vier do servidor sempre ganha.
Injetando um credentials falso (testes)
createPasskeyClient({ credentials }) aceita qualquer objeto com create e get — a interface CredentialsContainerLike. É assim que o próprio SDK testa as cerimônias, já que o jsdom não tem navigator.credentials.
O que o seu backend precisa implementar
Quatro rotas. O SDK não fala com nenhuma delas por conta própria: você passa getOptions e verify.
1. POST /webauthn/register/begin → opções
Devolva um PasskeyCreationOptionsJSON. Todo campo binário é base64url:
{
"challenge": "9WVvY2..._Q",
"rp": { "name": "Acme", "id": "acme.com" },
"user": { "id": "dXNlci0x", "name": "ada@acme.com", "displayName": "Ada Lovelace" },
"pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
"excludeCredentials": [{ "id": "AQID", "type": "public-key" }],
"authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" }
}
challenge: ≥16 bytes aleatórios, guardados na sessão, uso único.user.id: identificador opaco e estável. Nunca o e-mail — ele muda e fica gravado no autenticador.excludeCredentials: as credenciais que o usuário já tem. É o que faz o autenticador responderInvalidStateError(→already-registered) em vez de criar uma duplicada.pubKeyCredParamspode ser omitido: o SDK oferece-8(Ed25519),-7(ES256) e-257(RS256) — a lista exata está exportada comoDEFAULT_PUB_KEY_CRED_PARAMS, para o backend declarar o mesmo conjunto sem repetir os números.residentKey: "required"é o que torna a passkey descobrível — pré-requisito do login sem digitar nada.
2. POST /webauthn/register/finish ← credencial
Recebe um PasskeyRegistrationJSON. No servidor:
- Decodifique
response.clientDataJSONe confiratype === "webauthn.create", ochallengeguardado e oorigin. - Parseie
response.attestationObject, extraia a chave pública e o contador de assinatura. - Grave
id, chave pública, contador eresponse.transports(ajuda o login depois).
3. POST /webauthn/signin/begin → opções
Um PasskeyRequestOptionsJSON com um desafio novo. Omita allowCredentials para login sem identificação prévia e para o autofill — é o que deixa o navegador oferecer qualquer passkey do domínio.
4. POST /webauthn/signin/finish ← assertion
Recebe um PasskeyAuthenticationJSON:
- Ache a credencial por
id(ou porresponse.userHandle, que vem preenchido em credencial descobrível). - Confira o
clientDataJSONcomo no passo 2, agora comtype === "webauthn.get". - Verifique
response.signaturesobreauthenticatorData || sha256(clientDataJSON)com a chave pública guardada. - Rejeite contador que não cresceu — indica credencial clonada.
- Só então emita o token de sessão.
Do lado do FastAPI
Uma biblioteca de servidor (py_webauthn, por exemplo) faz os passos 1–4. O que ela não faz é o que está nesta página: guardar o desafio na sessão, escolher o user.id e montar o excludeCredentials.
Resumo
createPasskeyClient— duas cerimônias, encanamento base64url, um tipo de erro. Sem framework.usePasskeyRegistration/usePasskeySignIn— estado (status,error,data) + a ação. Resolvemnullem falha.isPlatformAuthenticatorAvailable()decide se o botão aparece;platformAvailable === nullé "ainda não sei".conditional: true+autocomplete="username webauthn"é o login de um toque. O atributo é obrigatório.kind: "already-registered"é sucesso,"cancelled"é ambíguo por design,"rp-mismatch"é bug seu.- O servidor faz 4 rotas; o cliente não inventa nenhuma delas.
Veja também
- Auth — onde guardar o token que o
finishdevolveu - HTTP — o
apidos exemplos - Access Control (RBAC) — o que a sessão pode fazer depois