Pourquoi une passkey résiste au phishing?
Un utilisateur qui saisit son mot de passe et son code OTP sur un faux portail de connexion donne à l’attaquant tout ce qu’il faut pour rejouer l’authentification. Est-ce qu’il se passe la même chose s’il valide sa passkey sur ce même faux portail? Non. La signature produite par l’authentificateur est liée au nom de domaine du service légitime et à un challenge à usage unique, et c’est le navigateur qui renseigne l’origine, pas la page.
Pour comprendre d’où vient cette propriété, il faut regarder ce qui circule pendant les deux cérémonies WebAuthn, l’enregistrement puis l’authentification, ce qu’Entra ID conserve de son côté et ce qui reste enfermé dans l’authentificateur.
Trois acteurs participent à la cérémonie: la Relying Party (RP), ici Entra ID, identifiée par un RP ID qui est un nom de domaine (login.microsoft.com pour Entra), le client (le navigateur ou l’OS, qui expose l’API navigator.credentials) et l’authentificateur (clé de sécurité USB/NFC, Windows Hello, Microsoft Authenticator, gestionnaire de passkeys synchronisées, etc…).
Le point de départ de la résistance au phishing est simple: une paire de clés est générée pour chaque couple RP ID / compte, et le navigateur refuse qu’une page demande un RP ID qui ne correspond pas à son propre domaine. Une page hébergée sur login-micros0ft.com ne peut donc pas demander une signature pour login.microsoft.com. Si l’attaquant monte un proxy de type adversary-in-the-middle qui relaie la page légitime, l’origine que le navigateur inscrit dans le clientDataJSON est celle du proxy, elle est couverte par la signature, et Entra ID la rejette.
Comparons avec une réponse NTLM: comme nous l’avions vu dans l’article sur les protocoles NBT-NS, LLMNR et l’exploitation des failles, rien dans cette réponse ne dit à qui elle était destinée, d’où la possibilité de la relayer. WebAuthn ferme cette porte au niveau du protocole.
Enregistrement: ce que le serveur stocke
À l’enregistrement, Entra ID envoie au navigateur des options de création: un challenge aléatoire, le RP ID, l’identifiant de l’utilisateur, les algorithmes acceptés, la préférence d’attestation et la liste des credentials déjà enregistrés pour éviter les doublons. Le navigateur construit alors un clientDataJSON qui contient le type (webauthn.create), le challenge et l’origine qu’il a lui-même constatée, puis transmet à l’authentificateur le hash SHA-256 de cet objet.
L’authentificateur vérifie la présence de l’utilisateur (un appui sur la clé), et généralement sa vérification (PIN ou biométrie), génère une nouvelle paire de clés et renvoie un attestationObject encodé en CBOR. Il contient trois éléments:
fmt: le format de l’attestation (packed,tpm,android-key,apple,fido-u2founone),attStmt: la déclaration d’attestation, c’est à dire une signature produite avec une clé d’attestation du fabricant et sa chaîne de certificats,authData: les données de l’authentificateur, dont la structure est détaillée ci dessous.
Le premier champ est le hash SHA-256 du RP ID: le serveur vérifie qu’il correspond au sien. L’octet de flags porte les bits UP (bit 0, présence), UV (bit 2, vérification), BE et BS (bits 3 et 4, credential éligible à la sauvegarde et effectivement sauvegardé, autrement dit synchronisable), AT (bit 6, données de credential présentes) et ED (bit 7, extensions). Le signCount est un compteur de signatures. À l’enregistrement viennent ensuite l’AAGUID, un identifiant de 128 bits qui désigne le modèle d’authentificateur, le credential ID et la clé publique.
À l’issue de la cérémonie, Entra ID conserve pour ce compte le credential ID, la clé publique, l’AAGUID, le dernier compteur et les informations tirées de l’attestation (modèle, niveau d’attestation). Nous pouvons consulter une partie de ces données avec Microsoft Graph:
GET https://graph.microsoft.com/v1.0/users/alice@test.onmicrosoft.com/authentication/fido2Methods
{
"id": "Y2Ek...",
"displayName": "Clé USB bureau",
"aaguid": "de1e552d-db1d-4423-a619-566b625cdc84",
"model": "Microsoft Authenticator for Android",
"attestationLevel": "attested",
"createdDateTime": "2026-10-12T08:41:03Z"
}Aucun secret dans cette liste. La clé privée ne quitte jamais l’authentificateur, et le PIN ou l’empreinte non plus: le serveur ne voit que le bit UV. Une fuite de la base côté RP livre des clés publiques, rien de rejouable, alors qu’une fuite d’un annuaire livre des hashs exploitables, même lorsque ce sont des hashs de hashs comme nous l’avions vu dans l’article sur Password Hash Sync et Seamless SSO.
Authentification: signature et compteur
La seconde cérémonie suit la même logique, sans création de clé. Entra ID envoie un nouveau challenge, le navigateur construit un clientDataJSON de type webauthn.get, et l’authentificateur signe la concaténation de l’authenticatorData (cette fois sans le bloc attestedCredentialData) et du hash SHA-256 du clientDataJSON.
Côté serveur, Entra ID retrouve la clé publique grâce au credential ID, vérifie la signature, puis contrôle le type, le challenge (il doit être celui qu’il vient d’émettre), l’origine, le rpIdHash, les bits UP et UV et le compteur. Si le compteur reçu n’est pas supérieur au dernier stocké alors qu’il n’est pas nul, cela peut trahir un clone de l’authentificateur.
Attention: les passkeys synchronisées (iCloud Keychain, Google Password Manager…) renvoient en général un compteur à 0, puisque la même clé vit sur plusieurs appareils. La détection de clonage par compteur ne vaut donc que pour les authentificateurs liés à un appareil.
Pour observer ces objets sur un compte de test, le plus simple est d’intercepter la réponse de l’API dans la console du navigateur, sur la page de connexion, avant de lancer la connexion par passkey:
const b = x => btoa(String.fromCharCode(...new Uint8Array(x)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const get = navigator.credentials.get.bind(navigator.credentials);
navigator.credentials.get = async o => {
const c = await get(o);
console.log(c.id, b(c.response.clientDataJSON), b(c.response.authenticatorData));
return c;
};Pour l’enregistrement, la même technique s’applique à navigator.credentials.create, et la méthode getAuthenticatorData() de la réponse évite d’avoir à décoder le CBOR de l’attestationObject. Attention, ce hook disparaît au moindre rechargement de la page: il faut le coller une fois la page de choix de la méthode affichée. L’onglet « WebAuthn » des outils de développement de Chrome permet aussi de simuler un authentificateur virtuel. Une capture Fiddler fonctionne également, comme nous l’avions fait dans l’article sur les entêtes WWW-Authenticate et Authorization, mais il faut alors retrouver les champs dans le corps du POST.
Le script Python ci dessous décode les deux objets récupérés (encodés en base64url):
import base64, hashlib, json, struct, sys, uuid
def b64u_decode(s):
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
FLAGS = {0: "UP", 2: "UV", 3: "BE", 4: "BS", 6: "AT", 7: "ED"}
def parse_client_data(b64):
cd = json.loads(b64u_decode(b64))
for k, v in cd.items():
print(f"clientDataJSON.{k:<12}: {v}")
return cd
def parse_auth_data(raw, rp_id="login.microsoft.com"):
rp_hash, flags, count = raw[:32], raw[32], struct.unpack(">I", raw[33:37])[0]
attendu = hashlib.sha256(rp_id.encode()).digest()
print(f"rpIdHash : {rp_hash.hex()[:16]}... ({'OK' if rp_hash == attendu else 'DIFFERENT'} pour {rp_id})")
print(f"flags : 0x{flags:02x} -> {' '.join(n for b, n in FLAGS.items() if flags >> b & 1)}")
print(f"signCount : {count}")
if flags >> 6 & 1:
aaguid = uuid.UUID(bytes=raw[37:53])
l = struct.unpack(">H", raw[53:55])[0]
cred_id = raw[55:55 + l]
print(f"aaguid : {aaguid}")
print(f"credentialId : {base64.urlsafe_b64encode(cred_id).rstrip(b'=').decode()[:24]}... ({l} octets)")
print(f"credentialPublicKey : {len(raw) - 55 - l} octets (COSE_Key)")
if __name__ == "__main__":
parse_client_data(sys.argv[1])
parse_auth_data(b64u_decode(sys.argv[2]))Voici la sortie pour une assertion d’exemple:
python3 webauthn_decode.py eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Iiwi... NWye1KCTIblpXx6vkYID8bVfaJ2mH7yWGEwVfdpoDIEFAAAAKg
clientDataJSON.type : webauthn.get
clientDataJSON.challenge : Qm9uam91ciB0ZXN0LmxvY2Fs
clientDataJSON.origin : https://login.microsoft.com
clientDataJSON.crossOrigin : False
rpIdHash : 356c9ed4a09321b9... (OK pour login.microsoft.com)
flags : 0x05 -> UP UV
signCount : 42-> origin est écrite par le navigateur et couverte par la signature via le hash du clientDataJSON: le proxy d’un attaquant ne peut pas la modifier.
-> 0x05: présence et vérification de l’utilisateur, pas de bit BE/BS, nous sommes sur un authentificateur lié à l’appareil, et le compteur est incrémenté.
Et pour l’enregistrement d’une passkey dans Microsoft Authenticator sur Android:
clientDataJSON.type : webauthn.create
clientDataJSON.challenge : Qm9uam91ciB0ZXN0LmxvY2Fs
clientDataJSON.origin : https://login.microsoft.com
clientDataJSON.crossOrigin : False
rpIdHash : 356c9ed4a09321b9... (OK pour login.microsoft.com)
flags : 0x45 -> UP UV AT
signCount : 0
aaguid : de1e552d-db1d-4423-a619-566b625cdc84
credentialId : -cfkJ7NsbAtA4siGwhrxE0eM... (32 octets)
credentialPublicKey : 77 octets (COSE_Key)Le bit AT est levé et l’AAGUID apparaît. C’est cette valeur qu’Entra ID utilise pour ses restrictions de clés.
AAGUID et attestation dans Entra ID
Entra ID regroupe désormais ces réglages dans des profils de passkeys, que l’on attribue à des groupes. La documentation Microsoft présente des exigences spécifiques comme l’attestation, le type de passkey (liée à l’appareil ou synchronisée) ou des restrictions par AAGUID, avec des profils distincts selon les populations, par exemple les administrateurs d’un côté et les opérateurs de terrain de l’autre.
Les deux réglages ne s’appliquent pas au même moment, et ce point est très important. L’application de l’attestation ne détermine si une passkey est acceptée qu’au moment de l’enregistrement. Une passkey enregistrée sans attestation continue donc de fonctionner si nous activons l’option plus tard. Les restrictions de clés, elles, conditionnent l’utilisation des modèles ou fournisseurs à la fois pour l’enregistrement et pour l’authentification: retirer un AAGUID de la liste autorisée coupe immédiatement les connexions des passkeys de ce modèle déjà enregistrées. Il vaut mieux le savoir avant de « nettoyer » une liste en production.
Le décodage plus haut explique le reste. L’AAGUID se trouve dans l’authData, que l’authentificateur remplit lui-même. Sans attestation, rien ne prouve que cette valeur est vraie: un authentificateur logiciel peut y écrire l’AAGUID d’une clé YubiKey. Avec l’attestation, la déclaration attStmt est signée par une clé du fabricant dont la chaîne de certificats peut être vérifiée dans les métadonnées de confiance. Microsoft le dit sans détour: sans attestation imposée, Entra ID ne peut garantir aucun attribut d’une passkey, y compris son caractère synchronisé ou lié à un appareil. Une liste d’AAGUID sans attestation reste utile pour orienter les utilisateurs vers les bons modèles, pas pour bloquer un attaquant.
Le cas de Microsoft Authenticator illustre la combinaison des deux. La page dédiée donne les AAGUID de l’application, dont de1e552d-db1d-4423-a619-566b625cdc84 pour Android, et précise une contrainte: l’enregistrement de passkeys attestées n’est possible que directement dans l’application Authenticator, les parcours inter-appareils ne le supportent pas.
Un dernier niveau de contrôle se place dans l’accès conditionnel: les options avancées des niveaux d’authentification acceptent elles aussi une liste d’AAGUID. Nous pouvons ainsi laisser tous les utilisateurs enregistrer une passkey, puis n’exiger un modèle attesté que pour les rôles d’administration ou les applications sensibles.
Attention, la passkey protège uniquement la cérémonie d’authentification: les jetons et cookies de session émis ensuite restent exposés comme avec n’importe quelle autre méthode. Un poste compromis par un voleur de cookies reste un poste compromis. Pour les comptes à privilèges, la combinaison que je retiens est un profil limité aux passkeys liées à l’appareil, avec attestation imposée et une liste courte d’AAGUID de clés matérielles validées, complétée par un niveau d’authentification qui reprend cette même liste.
Complément: maquette PHP avec lbuchs/webauthn
Pour suivre ces deux cérémonies sans tenant Entra ID, j’avais testé la bibliothèque lbuchs/webauthn de Lukas Buchs. Elle tient dans une classe principale, WebAuthn.php, accompagnée de ses décodeurs CBOR et d’attestation, et son dossier « _test » contient une démo complète: une page client.html et un server.php qui jouent les deux rôles. Nous la remontons ici sur un Apache avec PHP 8.3, dans un conteneur Docker nommé srvphp01, avec localhost comme Relying Party.
Attention: le navigateur n’expose l’API WebAuthn que dans un contexte sécurisé, c’est à dire en HTTPS ou sur localhost. Sur un vrai serveur il faut un certificat, et un RP ID égal au nom de domaine du site ou à l’un de ses suffixes.
Installation sur le serveur PHP
La bibliothèque demande PHP 8.0 au minimum avec les extensions OpenSSL et mbstring, et sodium pour les clés Ed25519. L’image officielle php:8.3-apache les fournit déjà. Nous y ajoutons unzip pour Composer et jq pour lire les logs, et nous envoyons les logs d’Apache dans des fichiers, l’image les dirigeant par défaut vers la sortie du conteneur:
docker run -d --name webauthn-lab --hostname srvphp01 -p 127.0.0.1:8090:80 php:8.3-apache
docker exec -it webauthn-lab bash
apt-get update && apt-get install -y unzip jq
rm /var/log/apache2/access.log /var/log/apache2/error.log && apache2ctl -k graceful
php -v
php -m | grep -E 'openssl|mbstring|sodium'
Composer s’installe avec le script officiel, puis la bibliothèque arrive depuis Packagist, en version 2.2.0 à l’heure où j’écris ces lignes:
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
cd /var/www
composer require lbuchs/webauthn
La démo est livrée avec le paquet, dans vendor/lbuchs/webauthn/_test. Nous la copions dans le site et remplaçons son require_once '../src/WebAuthn.php' par l’autoload de Composer:
cp -r vendor/lbuchs/webauthn/_test html/webauthn
sed -i "s#require_once '../src/WebAuthn.php';#require_once __DIR__ . '/../../vendor/autoload.php';#" html/webauthn/server.php
chown -R www-data: html/webauthn
La démo ne journalise rien. Pour retrouver dans les logs ce que le serveur a reçu et vérifié, nous ajoutons dans server.php un error_log() juste après l’appel à processCreate, et un autre après processGet:
function b64u($b) { return rtrim(strtr(base64_encode($b), "+/", "-_"), "="); }
// après $data = $WebAuthn->processCreate(...)
error_log('webauthn processCreate ' . json_encode(array(
'clientData' => json_decode($clientDataJSON), 'fmt' => $data->attestationFormat,
'aaguid' => bin2hex($data->AAGUID), 'UP' => $data->userPresent, 'UV' => $data->userVerified,
'BE' => $data->isBackupEligible, 'BS' => $data->isBackedUp, 'signCount' => $data->signatureCounter,
'rootValid' => $data->rootValid, 'credentialId' => b64u($data->credentialId)), JSON_UNESCAPED_SLASHES));
// après $WebAuthn->processGet(...)
error_log('webauthn processGet ' . json_encode(array(
'clientData' => json_decode($clientDataJSON), 'signCount' => $WebAuthn->getSignatureCounter(),
'clientDataJSON_b64u' => b64u($clientDataJSON), 'authenticatorData_b64u' => b64u($authenticatorData)), JSON_UNESCAPED_SLASHES));Enrôlement
La page http://localhost:8090/webauthn/client.html expose les paramètres de la cérémonie. Nous cochons les passkeys (credentials découvrables côté client), nous imposons la vérification de l’utilisateur et nous laissons l’attestation activée, avec tous les formats proposés:

Pour ne pas dépendre d’un matériel, l’authentificateur est celui que Chromium sait simuler, le même que dans l’onglet « WebAuthn » des outils de développement. Nous le pilotons avec Playwright par le protocole DevTools: un authentificateur interne en CTAP2, qui conserve ses clés et répond à la vérification de l’utilisateur sans intervention.
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', { options: {
protocol: 'ctap2', transport: 'internal', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });Le bouton « new registration » enchaîne les deux appels du schéma de la bibliothèque. Le premier, GET server.php?fn=getCreateArgs, renvoie les options de création:
{
"publicKey": {
"rp": {
"name": "WebAuthn Library",
"id": "localhost"
},
"authenticatorSelection": {
"userVerification": "required",
"requireResidentKey": true,
"residentKey": "required"
},
"user": {
"id": "=?BINARY?B?ZGVtb2RlbW8=?=",
"name": "demo",
"displayName": "Demo Demolin"
},
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -8
},
{
"type": "public-key",
"alg": -7
},
{
"type": "public-key",
"alg": -257
}
],
"attestation": "direct",
"extensions": {
"exts": true
},
"timeout": 240000,
"challenge": "=?BINARY?B?ySlWh2+Z8VeQ5PdXk0ZbSks0olxsDV3YO7J6OwOuwPM=?=",
"excludeCredentials": []
}
}Nous y retrouvons ce que nous avons décrit pour Entra ID: le RP ID, l’identifiant de l’utilisateur, les algorithmes acceptés (-8 pour Ed25519, -7 pour ES256, -257 pour RS256), residentKey à required pour obtenir une passkey, et une attestation direct, que la bibliothèque demande dès que des certificats racines sont chargés (ici le dossier du FIDO Metadata Service, coché par défaut dans la page). Les champs binaires voyagent sous une forme assez inhabituelle, =?BINARY?B?…?=, que client.html reconvertit en ArrayBuffer avant d’appeler navigator.credentials.create. Le second appel, POST fn=processCreate, transmet le clientDataJSON et l’attestationObject.
La page répond registration ok, but certificate does not match any of the selected root ca. et le tableau des données que le serveur a conservées (server.php?fn=getStoredDataHtml) montre pourquoi:

L’attestation est au format packed, mais son certificat a le même émetteur et le même sujet, « Batch Certificate (Chromium Authenticator Attestation) »: il est auto-signé, ne remonte à aucune racine de confiance, et rootValid vaut no. La démo accepte malgré tout l’enregistrement, car elle appelle processCreate avec son dernier paramètre, $failIfRootMismatch, à false. Nous sommes dans le cas décrit plus haut pour Entra ID: l’authentificateur annonce un AAGUID, ici 01020304-0506-0708-0102-030405060708, celui de l’authentificateur virtuel de Chromium, et rien ne permet de croire cette valeur.
-> la clé publique commence par MCowBQYDK2Vw, l’en-tête d’une clé Ed25519: l’authentificateur a retenu le premier algorithme de la liste (-8), que la bibliothèque ne propose que si sodium ou OpenSSL savent vérifier ce type de signature.
-> UP et UV sont levés, BE et BS ne le sont pas: la clé est liée à l’appareil, et son compteur démarre à 1.
Le tableau laisse l’AAGUID vide car ses octets ne sont pas imprimables. Le log de l’enrôlement le donne en hexadécimal, avec le clientDataJSON décodé et l’origine http://localhost:8090 que le navigateur a lui-même inscrite:
awk '{sub(/&.*/, "", $7); print $4, $6, $7, $9}' /var/log/apache2/access.log | grep server.php
grep processCreate /var/log/apache2/error.log | tail -1 | grep -o '{.*}' | jq .
Authentification
Le bouton « check registration » enchaîne fn=getGetArgs puis fn=processGet. Nous le lançons deux fois. Le journal d’accès d’Apache, réduit à l’heure, à la méthode, à la fonction appelée et au code retour, montre les deux cérémonies:

Le log du premier processGet contient un clientDataJSON de type webauthn.get avec un nouveau challenge, le compteur, et l’authenticatorData encodée en base64url:

Le script de décodage vu plus haut, appliqué au clientDataJSON et à l’authenticatorData de cette connexion, donne:
$ python3 webauthn_decode.py eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxl... SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAg
clientDataJSON.type : webauthn.get
clientDataJSON.challenge : zKiu6qgkfyN8OA2t7ggfHfaik9AN8i0BgzYcqFSiUA8
clientDataJSON.origin : http://localhost:8090
clientDataJSON.crossOrigin : False
rpIdHash : 49960de5880e8c68... (DIFFERENT pour login.microsoft.com)
flags : 0x05 -> UP UV
signCount : 2-> le rpIdHash est le SHA-256 de « localhost » (49960de5880e8c68…). Le script, écrit pour Entra ID, le compare à login.microsoft.com et le trouve différent: c’est la liaison au domaine décrite au début de l’article, vue depuis une autre Relying Party.
-> flags 0x05 (UP UV) et compteur à 2, puis à 3 lors de la seconde connexion: l’authentificateur virtuel incrémente son compteur à chaque signature.
Lors d’un premier passage, Chrome avait ajouté au clientDataJSON une clé other_keys_can_be_added_here, dont la valeur demande de ne pas comparer cet objet à un modèle. Le serveur doit donc lire ce JSON champ par champ, comme le fait processGet, et jamais le comparer à une chaîne attendue.
Attention, la démo appelle processGet avec null à la place du compteur précédent et ne met jamais à jour celui qu’elle a stocké: le contrôle de clonage décrit plus haut n’y est pas actif. Dans une vraie application, il faut enregistrer après chaque connexion la valeur renvoyée par getSignatureCounter() et la passer au processGet suivant, la bibliothèque levant alors l’exception « signature counter not valid » si le compteur n’augmente pas. La démo garde aussi ses enregistrements dans la session PHP. En production ils vont en base, rattachés au compte, et les premières étapes de la vérification (le credential ID fait-il partie de ceux attendus, le userHandle correspond-il au compte?) restent à notre charge: les commentaires du code de la bibliothèque les laissent explicitement à l’implémentation.
Rémi Vernier
Responsable des opérations cybersécurité dans un grand groupe international. Identité, Active Directory, Entra ID, cloud hybride, et désormais SAP, IA et modèles en local.