Réponse courte. Une clé d’accès (passkey) est un identifiant WebAuthn détectable : une paire de clés créée par un authentificateur (un téléphone, le composant sécurisé d’un ordinateur portable, un gestionnaire de mots de passe ou une clé de sécurité) pour un seul site, la partie de confiance (relying party, RP). L’enregistrement envoie au serveur une clé publique ; la connexion envoie une signature portant sur un challenge fourni par le serveur. Le serveur doit vérifier le challenge, l’origine, l’ID de la partie de confiance (RP ID) et l’indicateur de vérification de l’utilisateur dans chaque réponse. Demandez residentKey: "required" et userVerification: "required", proposez -8, -7 et -257 comme algorithmes, laissez l’attestation à "none", sauf si vous êtes une entreprise qui restreint les modèles d’authentificateurs, et attendez-vous à ce que la plupart des clés d’accès soient synchronisées entre appareils.
Le modèle en un tableau
| Terme | Ce que cela signifie pour votre code |
|---|---|
| Partie de confiance (RP) et RP ID | Votre site. Le RP ID est un domaine, par exemple example.com ; les identifiants fonctionnent sur ce domaine et ses sous-domaines, jamais ailleurs |
| Identifiant détectable | Stocké sur l’authentificateur avec l’ID utilisateur, si bien que la connexion ne demande aucun nom d’utilisateur. WebAuthn Level 3 cite « passkey » comme autre nom de ce type d’identifiant |
ID utilisateur, ou user handle (user.id) | L’ID opaque de votre compte, 64 octets au maximum, renvoyé lors de la connexion. Jamais une adresse e-mail |
| Présence de l’utilisateur (UP) et vérification de l’utilisateur (UV) | UP : quelqu’un a touché ou confirmé. UV : l’authentificateur a vérifié un code PIN ou une donnée biométrique. Indicateurs inscrits dans les données de l’authentificateur |
| Éligible à la sauvegarde (BE) et sauvegardé (BS) | Un identifiant qui peut être synchronisé vers d’autres appareils, ou qui l’est déjà : une clé d’accès synchronisée, ou « identifiant multi-appareil » (multi-device credential) dans la spécification |
| Attestation et AAGUID | Une chaîne de certificats facultative qui indique quel modèle d’authentificateur a créé la clé ; l’AAGUID identifie ce modèle |
Les indicateurs des données de l’authentificateur sont le bit 0 UP, le bit 2 UV, le bit 3 BE, le bit 4 BS, le bit 6 AT (données d’identifiant attestées incluses) et le bit 7 ED (extensions) (WebAuthn Level 3 §6.1). La spécification WebAuthn Level 3 est devenue une recommandation du W3C le 25 août 2026.
Serveur : les deux cérémonies
Voici une partie de confiance complète, écrite avec @simplewebauthn/server 14.0.3. Le stockage se fait en mémoire pour rester concis ; en production, les trois dictionnaires sont des tables de base de données et le challenge réside dans la session côté serveur de l’utilisateur.
// 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 };
}
Ce que contrôlent les appels de vérification, et pourquoi chaque contrôle compte :
- Challenge. Aléatoire, à usage unique, lié à cette cérémonie. Une réponse qui porte un ancien challenge est un rejeu.
- Origine. Le navigateur inscrit l’origine de la page dans
clientDataJSON, et l’authentificateur signe un condensat de ces données. C’est la comparaison avec votre liste d’origines qui rend les clés d’accès résistantes à l’hameçonnage : un site imitateur produit une réponse qui porte sa propre origine. - Condensat du RP ID. Les données de l’authentificateur commencent par le SHA-256 du RP ID ; un identifiant créé pour un autre site ne peut pas servir sur le vôtre.
- Indicateurs. UP doit être positionné ; UV doit l’être aussi lorsque vous vous reposez sur la seule clé d’accès, ce qui est tout l’intérêt d’une clé d’accès.
- Signature des données de l’authentificateur et du condensat des données client, avec la clé publique stockée lors de l’enregistrement.
- Compteur. De nombreuses clés d’accès synchronisées renvoient toujours 0. WebAuthn ne considère un compteur qui diminue comme le signe d’un possible clone que si une valeur non nulle est en jeu (§6.1.1) ; ne rejetez jamais zéro.
Navigateur : deux appels
// 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 et toJSON font partie de WebAuthn Level 3 et remplacent le code de conversion base64url que chaque projet devait jusqu’ici embarquer. Pour un champ de nom d’utilisateur qui propose les clés d’accès dans la saisie automatique du navigateur, lancez navigator.credentials.get() avec mediation: "conditional" au chargement de la page, après avoir vérifié PublicKeyCredential.isConditionalMediationAvailable(), et marquez le champ de saisie avec autocomplete="username webauthn".
Exécution de bout en bout
Le laboratoire a piloté Chromium avec Playwright 1.64.1 (mcr.microsoft.com/playwright:v1.64.1-noble) et un authentificateur virtuel ajouté via le Chrome DevTools Protocol. La page servie sur http://localhost:3000 exécutait browser.js ; le pilote lui transmettait les options issues de server.mjs et en récupérait les réponses. Chaque ligne provient d’un véritable enregistrement ou d’une véritable connexion :
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.
Ce que montre chaque ligne :
- Algorithmes. Les options proposaient
-8,-7et-257; l’authentificateur a choisi-8(EdDSA), le premier qu’il prend en charge. L’ordre exprime la préférence. excludeCredentialsfonctionne, avec l’InvalidStateError qui permet à votre interface d’indiquer « cet appareil possède déjà une clé d’accès pour ce compte ».- Connexion sans nom d’utilisateur.
allowCredentialsétait vide ; l’authentificateur a proposé son identifiant détectable, et le serveur a retrouvé le compte grâce à l’ID d’identifiant et à la clé publique stockée pour celui-ci. - Les rejeux échouent sur le challenge. C’est la suppression du challenge dès son utilisation qui fait échouer la seconde tentative.
- Le navigateur ne bloque pas toutes les mauvaises origines. Une page sur
http://localhost:4000a pu créer un identifiant pour le RP IDlocalhost, car le RP ID couvre tous les ports et tous les sous-domaines de ce domaine. Seul le contrôle d’origine du serveur l’a rejeté : Unexpected registration response origin. Faites deexpectedOriginune liste exacte. - Les clés d’accès synchronisées se signalent. Avec l’éligibilité à la sauvegarde activée, SimpleWebAuthn a renvoyé
multiDeviceetbackedUp true. - Le navigateur impose l’UV obligatoire. Face à un authentificateur incapable de vérifier l’utilisateur, le navigateur a refusé avec l’erreur générique NotAllowedError ; le
requireUserVerificationcôté serveur constitue la seconde ligne de défense.
Déployer les clés d’accès à côté des mots de passe
Rares sont les sites qui peuvent basculer tous leurs comptes d’un coup. WebAuthn Level 3 ajoute les briques d’un déploiement progressif, toutes détectables via PublicKeyCredential.getClientCapabilities() :
- Proposez une clé d’accès juste après une connexion par mot de passe. Avec la capacité
conditionalCreate,navigator.credentials.create()appelé avecmediation: "conditional"permet au gestionnaire de mots de passe qui vient de remplir le mot de passe de créer une clé d’accès sans boîte de dialogue supplémentaire. - Connexion depuis le champ du nom d’utilisateur. La capacité
conditionalGetcorrespond au flux de saisie automatique décrit plus haut ; les utilisateurs qui ont une clé d’accès la choisissent, les autres saisissent un mot de passe comme avant. - Gardez la liste de l’authentificateur synchronisée avec la vôtre. Quand un utilisateur supprime une clé d’accès dans les paramètres de compte de votre site, l’authentificateur continue de l’afficher.
signalUnknownCredential()lui indique qu’un ID d’identifiant n’est plus accepté,signalAllAcceptedCredentials()envoie la liste complète pour un utilisateur connecté, etsignalCurrentUserDetails()met à jour le nom affiché après qu’un utilisateur a renommé son compte (§5.1.10). - Plusieurs domaines, un seul RP ID. Une marque dont la connexion se fait sur
example.comet surexample.co.ukpeut garder un seul RP ID et lister les autres origines danshttps://example.com/.well-known/webauthnsous la forme{"origins": [...]}. Le navigateur récupère ce fichier lorsque le RP ID n’est pas le domaine de la page elle-même (requêtes d’origines associées, related origin requests, §5.11) ; si la récupération échoue, on obtient l’erreurSecurityErrorprésentée plus bas. - Conservez BE et BS pour chaque identifiant. Un utilisateur dont la seule clé d’accès est liée à un appareil unique et n’est pas sauvegardée perdra l’accès en même temps que cet appareil ; invitez-le à en ajouter une seconde avant de lui retirer son mot de passe.
Attestation : quand elle est nécessaire
L’attestation répond à la question « quel modèle d’authentificateur a créé cette clé ». Elle ne dit rien de l’utilisateur, ni du niveau de correctifs de l’appareil, ni du fait qu’il soit rooté ou non. Avec attestation: "none", la valeur par défaut, le navigateur remplace toute déclaration d’attestation par une déclaration vide avant que votre serveur ne la voie. Le laboratoire a pourtant reçu l’AAGUID de l’authentificateur : WebAuthn Level 2 demandait aux navigateurs de le remplacer dans ce cas par 16 octets nuls, et Level 3 a supprimé cette étape. Sans attestation, l’AAGUID est une simple affirmation, pas une preuve.
N’exigez pas d’attestation sur un site grand public. Les clés d’accès synchronisées sont conçues pour suivre l’utilisateur sur n’importe quel appareil, et leurs fournisseurs ne les ont en général pas attestées : au lancement des clés d’accès sur Android, Google a déclaré qu’elles « n’auront dans un premier temps aucune attestation » (liste fido-dev, 2022). L’authentificateur virtuel du laboratoire, sollicité avec "direct", a renvoyé une déclaration packed : cela montre ce qu’envoie un authentificateur qui atteste, mais pas ce que fait tel ou tel fournisseur de synchronisation, alors testez les fournisseurs qu’ont réellement vos utilisateurs. Demandez l’attestation lorsque vous devez restreindre l’enregistrement à un ensemble de modèles, par exemple les clés de sécurité distribuées par votre entreprise, et vérifiez la chaîne auprès du service de métadonnées FIDO. Pour montrer aux utilisateurs un nom lisible pour leur fournisseur de clés d’accès (« Google Password Manager », « 1Password »), la liste des AAGUID d’authentificateurs de clés d’accès, maintenue par la communauté, fait correspondre les AAGUID à des noms ; son propre README précise qu’elle sert uniquement à l’affichage et ne doit pas fonder de décisions de sécurité.
Algorithmes COSE
pubKeyCredParams énumère des identifiants d’algorithmes COSE (registre COSE de l’IANA). L’exemple de la spécification elle-même accepte EdDSA, ES256 et RS256 et préfère EdDSA ; cette liste couvre pratiquement tous les authentificateurs en circulation :
| Valeur | Nom | Remarques |
|---|---|---|
| -8 | EdDSA | Uniquement Ed25519 dans WebAuthn (§5.8.5) ; marqué comme obsolète dans le registre COSE par la RFC 9864 au profit de -19 Ed25519, mais les authentificateurs émettent toujours -8 |
| -7 | ES256 | ECDSA P-256 ; le plus largement pris en charge. Obsolète dans COSE au profit de -9 ESP256, même situation |
| -257 | RS256 | RSA PKCS#1 v1.5 ; Windows Hello ne crée que des identifiants RS256 sous Windows 10 et sur certaines installations de Windows 11 (Yubico sur la liste du W3C) |
| -48, -49, -50 | ML-DSA-44, -65, -87 | Enregistrés par la RFC 9964 (mai 2026). L’authentificateur virtuel de Chromium 156 a accepté -49 (voir plus bas) ; vérifiez les authentificateurs réels et votre bibliothèque serveur avant de vous y fier |
Gardez -7 et -257 dans la liste même si vous préférez -8 : lorsque pubKeyCredParams est vide, la valeur par défaut de la spécification elle-même est ES256 et RS256. Stockez l’algorithme avec l’identifiant ; SimpleWebAuthn le conserve dans la clé publique COSE stockée.
Les erreurs DOMException
navigator.credentials.create() et get() signalent leurs échecs au moyen d’un petit ensemble d’exceptions, énumérées dans WebAuthn Level 3 §5.1.3.1 et §5.1.4.3. Voici ce que Chromium 156 a levé pour des erreurs courantes, face à une clé de sécurité USB virtuelle dépourvue de stockage d’identifiants détectables :
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
| Exception | Signification selon la spécification | Que faire |
|---|---|---|
NotAllowedError | Fourre-tout : annulation par l’utilisateur, délai expiré, aucun identifiant correspondant, exigences qu’aucun authentificateur ne satisfait | Traitez-la comme « non abouti » ; proposez une nouvelle tentative et une autre méthode. N’affichez jamais le message brut |
InvalidStateError | Une entrée de excludeCredentials a été reconnue | Indiquez à l’utilisateur que cet appareil possède déjà une clé d’accès pour ce site |
SecurityError | Le RP ID n’est ni le domaine de la page ni un suffixe enregistrable de celui-ci, ou la validation des origines associées a échoué | Corrigez la configuration ; consultez la page consacrée à SecurityError |
NotSupportedError | Aucun algorithme utilisable dans pubKeyCredParams | Proposez -7 et -257 |
ConstraintError | residentKey ou userVerification exigé, et aucun authentificateur n’en est capable | En pratique, même traitement que NotAllowedError |
AbortError | Votre AbortController a annulé l’appel | Attendu lors du passage de la saisie automatique à une invite modale |
TypeError | Options mal formées, par exemple un ID utilisateur de plus de 64 octets | Bogue dans vos options |
Deux des résultats du laboratoire diffèrent de la liste de la spécification : Chromium a répondu NotAllowedError, et non NotSupportedError, quand le seul algorithme proposé était inconnu, et NotAllowedError, et non ConstraintError, quand un identifiant détectable était exigé d’une clé incapable d’en stocker un. Un code qui s’appuie sur NotSupportedError ou ConstraintError pour expliquer le problème aux utilisateurs ne les verra jamais dans Chromium pour ces cas. Les deux API n’existent que dans des contextes sécurisés. Sur une page en HTTP simple qui n’est pas localhost, Chromium 156 a indiqué isSecureContext: false, avec navigator.credentials, PublicKeyCredential et crypto.subtle tous à undefined ; le code échoue donc avec une erreur TypeError avant le début de toute cérémonie (la variante Web Crypto est crypto.subtle is undefined). Servez en HTTPS toutes les pages qui utilisent des clés d’accès ; http://localhost est la seule exception, pour le développement.
Liste de contrôle
- RP ID défini sur le domaine enregistrable que voient vos utilisateurs ;
expectedOriginsous forme de liste explicite d’origines HTTPS, y compris chaque sous-domaine sur lequel on se connecte. residentKey: "required",userVerification: "required", vérification de l’UV par le serveur dans chaque réponse.pubKeyCredParamsavec-8,-7,-257;excludeCredentialsrempli avec les identifiants existants de l’utilisateur.- Challenges aléatoires, à usage unique, de courte durée de vie, stockés côté serveur.
- Identifiants stockés avec leur ID, la clé publique, le compteur, les transports, les indicateurs BE et BS, l’AAGUID et un nom modifiable par l’utilisateur ; les utilisateurs peuvent en enregistrer plusieurs et les supprimer.
- Attestation
"none", sauf si vous restreignez les modèles d’authentificateurs ; vérification auprès du FIDO MDS dans ce cas. - Une récupération de compte qui n’est pas plus faible que la clé d’accès elle-même : une seconde clé d’accès ou une procédure de récupération vérifiée, et non un code SMS qui contourne tout.
Sources
- Web Authentication Level 3, recommandation du W3C, 25 août 2026 : §5.1.3.1, §5.1.4.3, §5.8.5, §6.1, §6.1.1
- Registre IANA des algorithmes COSE ; RFC 9864 ; RFC 9964
- Documentation de SimpleWebAuthn
- Service de métadonnées FIDO
- AAGUID des authentificateurs de clés d’accès
- Chrome DevTools Protocol : domaine WebAuthn