Le 25 août 2026, le W3C a publié WebAuthn Level 3 comme recommandation officielle, actant la bascule définitive vers une authentification sans mot de passe. Selon la FIDO Alliance, le monde compte désormais 7 milliards de passkeys actifs, un chiffre qui a doublé en moins de deux ans. Pour les développeurs, la question n’est plus de savoir s’il faut migrer vers les passkeys, mais comment les implémenter correctement, sans ouvrir de faille d’account takeover au passage. Ce tutoriel vous guide pas à pas, avec du code Node.js réel, pour construire une authentification passkey conforme à WebAuthn Level 3 en 13 étapes.

Contrairement aux tutoriels qui se contentent d’un exemple jouet, nous allons construire un projet complet : serveur Express, base SQLite, enregistrement de credential, connexion, gestion multi-appareils, repli MFA et configuration de production. Comptez environ 90 minutes pour suivre l’ensemble des étapes, tester le flux dans un navigateur et comprendre les pièges qui font échouer la plupart des implémentations passkey en production.

Avant de plonger dans le code, un mot sur le périmètre de ce tutoriel : il s’adresse aux développeurs backend et frontend qui doivent ajouter une connexion passkey à une application existante, qu’il s’agisse d’un produit SaaS, d’un portail client ou d’une application interne d’entreprise. Vous n’avez pas besoin de connaissances préalables en cryptographie à clé publique pour suivre les 13 étapes : les bibliothèques @simplewebauthn/server et @simplewebauthn/browser encapsulent toute la complexité mathématique, et votre travail consiste surtout à orchestrer correctement les échanges entre navigateur, serveur et base de données.

Qu’est-ce qu’un Passkey ? Comprendre FIDO2, WebAuthn et CTAP

Un passkey est une paire de clés cryptographiques asymétriques générée par un appareil (téléphone, ordinateur, clé de sécurité) et liée à un site web précis. La clé privée ne quitte jamais l’appareil ou le gestionnaire de clés de l’utilisateur, tandis que la clé publique est envoyée au serveur lors de l’enregistrement. À chaque connexion, le serveur envoie un défi aléatoire que l’appareil signe avec la clé privée, prouvant la possession du secret sans jamais le transmettre. C’est ce mécanisme qui rend le phishing structurellement inefficace : même si un utilisateur saisit ses identifiants sur un faux site, il n’y a pas de mot de passe à voler, et la signature ne fonctionne que pour le domaine exact enregistré.

Techniquement, un passkey repose sur deux standards complémentaires regroupés sous le nom FIDO2 : le WebAuthn du W3C, qui définit l’API JavaScript exposée par le navigateur, et le CTAP (Client to Authenticator Protocol) de la FIDO Alliance, qui gère la communication entre le navigateur et l’authentificateur matériel ou logiciel. La FIDO Alliance a publié un brouillon de travail CTAP 2.3.1 courant 2026, ainsi qu’un brouillon PXP 1.0, deux chantiers qui affinent la compatibilité entre authentificateurs et navigateurs sans remettre en cause le cœur du protocole utilisé dans ce tutoriel.

WebAuthn Level 3 : Ce Qui Change par Rapport aux Versions Précédentes

WebAuthn Level 3 reste rétrocompatible avec les niveaux 1 et 2, mais introduit plusieurs ajouts utiles en production. Le plus important pour les développeurs est la fonctionnalité des Related Origin Requests : un passkey enregistré pour un identifiant de Relying Party (RP ID) peut désormais fonctionner sur un ensemble contrôlé de domaines associés, via une ressource JSON exposée sur /.well-known/webauthn. Cela résout un problème concret pour les entreprises qui opèrent plusieurs domaines régionaux ou des sous-marques (par exemple monentreprise.fr et monentreprise.eu) sans devoir dupliquer les passkeys de chaque utilisateur.

Level 3 précise également le comportement des credentials découvrables (discoverable credentials), ceux qui permettent une connexion sans que l’utilisateur ait à saisir d’identifiant au préalable, en laissant le navigateur proposer directement la liste des comptes disponibles sur l’appareil. C’est ce flux que la plupart des grandes plateformes utilisent désormais pour l’écran de connexion passkey en un clic. Nous verrons comment l’activer à l’étape 7.

Prérequis : Outils, Versions et Environnement de Développement

Avant de commencer, installez les éléments suivants. Les versions indiquées sont celles disponibles au moment de la rédaction et doivent être vérifiées sur le registre npm avant de démarrer un projet réel.

  • Node.js 24 LTS (version 24.22.0, nom de code Krypton) ou supérieur
  • npm 10.x, fourni avec Node.js 24
  • Express 5.3.0 pour le serveur HTTP
  • @simplewebauthn/server 14.0.3, l’implémentation serveur WebAuthn la plus utilisée en Node.js
  • @simplewebauthn/browser 14.0.0, le wrapper client qui encapsule l’API WebAuthn native
  • better-sqlite3 13.0.3 pour stocker utilisateurs et credentials sans dépendance externe
  • express-session 1.19.0 pour la gestion de session
  • Un navigateur à jour (Chrome, Edge, Safari ou Firefox récents) supportant WebAuthn Level 3
  • Un certificat TLS valide pour la production, ou localhost en développement (WebAuthn exige un contexte sécurisé)
  • Des bases en JavaScript asynchrone (async/await) et en requêtes HTTP

Un point souvent sous-estimé : WebAuthn ne fonctionne que sur HTTPS, à l’exception de localhost qui bénéficie d’une dérogation navigateur pour le développement. Si vous testez depuis une IP locale ou un sous-domaine de prévisualisation, prévoyez un certificat auto-signé ou un tunnel HTTPS (ngrok, Cloudflare Tunnel) dès le début, sous peine de blocages difficiles à diagnostiquer plus tard.

Pourquoi les Passkeys Réduisent le Risque RGPD et les Coûts de Support

Du point de vue de la conformité, les passkeys s’inscrivent naturellement dans le principe de minimisation des données posé par le RGPD : votre base ne contient plus de hash de mot de passe, seulement des clés publiques, qui n’ont aucune valeur en cas de fuite puisqu’elles ne permettent pas de se connecter sans la clé privée correspondante. Une base de credentials WebAuthn compromise n’est donc pas, en soi, un incident de sécurité critique pour vos utilisateurs, contrairement à une base de mots de passe hashés qui reste attaquable par force brute hors ligne.

Côté support client, l’argument économique est tout aussi direct. Les réinitialisations de mot de passe et les validations d’identité après un verrouillage de compte figurent parmi les tickets les plus coûteux à traiter pour une équipe support, car ils nécessitent souvent une vérification manuelle d’identité. Une fois qu’un utilisateur a adopté un passkey synchronisé, ce type de ticket disparaît presque entièrement : il lui suffit de se reconnecter avec le même compte cloud (Apple, Google ou Microsoft) sur un nouvel appareil pour retrouver l’accès, sans intervention humaine côté entreprise.

Étape 1 : Initialiser le Projet Node.js et Installer les Dépendances

Créez un nouveau dossier de projet et initialisez-le. Cette étape pose les fondations de tout le reste du tutoriel.

mkdir passkey-demo && cd passkey-demo
npm init -y
npm install [email protected] @simplewebauthn/[email protected] @simplewebauthn/[email protected] [email protected] [email protected]
mkdir -p public src

Le dossier public contiendra le code JavaScript exécuté côté navigateur (y compris la copie du bundle @simplewebauthn/browser), tandis que src hébergera la logique serveur. Cette séparation claire évite d’exposer accidentellement du code serveur au client, une erreur fréquente dans les implémentations WebAuthn improvisées.

Étape 2 : Configurer la Base de Données Utilisateurs et Credentials

Un utilisateur peut posséder plusieurs passkeys (téléphone, ordinateur portable, clé de sécurité), il faut donc une relation un-à-plusieurs entre utilisateurs et credentials. Créez src/db.js :

import Database from 'better-sqlite3';

const db = new Database('passkeys.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,
    username TEXT UNIQUE NOT NULL,
    created_at INTEGER NOT NULL
  );

  CREATE TABLE IF NOT EXISTS credentials (
    credential_id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    public_key BLOB NOT NULL,
    counter INTEGER NOT NULL,
    device_type TEXT NOT NULL,
    backed_up INTEGER NOT NULL,
    transports TEXT,
    created_at INTEGER NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
  );
`);

export default db;

Le champ counter est critique pour la sécurité : il augmente à chaque authentification et permet de détecter un clonage de credential si sa valeur n’avance pas comme attendu. Le champ backed_up indique si le passkey est synchronisé dans le cloud de l’utilisateur (par exemple via un trousseau Apple ou Google) ou lié à un seul appareil physique, une distinction qui affecte vos décisions de politique de sécurité.

Étape 3 : Mettre en Place le Serveur Express et les Sessions

Créez src/server.js avec la configuration de base. C’est ici que vous fixez le RP ID (Relying Party ID), l’identifiant racine auquel tous les passkeys de votre site seront liés.

import express from 'express';
import session from 'express-session';

const app = express();

export const rpName = 'Passkey Demo';
export const rpID = process.env.RP_ID || 'localhost';
export const origin = process.env.ORIGIN || `https://${rpID}`;

app.use(express.json());
app.use(session({
  secret: process.env.SESSION_SECRET || 'change-moi-en-production',
  resave: false,
  saveUninitialized: false,
  cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' }
}));
app.use(express.static('public'));

app.listen(3000, () => console.log('Serveur passkey sur le port 3000'));
export default app;

Le RP ID doit correspondre exactement au domaine servant l’application, sans protocole ni port. Une erreur classique consiste à mettre https://monsite.fr au lieu de monsite.fr, ce qui fait échouer silencieusement toute vérification de credential côté serveur.

Étape 4 : Générer les Options d’Enregistrement Passkey (Serveur)

Lorsqu’un utilisateur souhaite créer un passkey, le serveur doit d’abord générer un défi (challenge) et une liste d’options que le navigateur transmettra à l’authentificateur. Ajoutez cette route :

import { generateRegistrationOptions } from '@simplewebauthn/server';
import db from './db.js';
import { randomUUID } from 'crypto';

app.post('/register/options', async (req, res) => {
  const { username } = req.body;
  let user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);

  if (!user) {
    user = { id: randomUUID(), username, created_at: Date.now() };
    db.prepare('INSERT INTO users (id, username, created_at) VALUES (?, ?, ?)')
      .run(user.id, user.username, user.created_at);
  }

  const existing = db.prepare('SELECT credential_id FROM credentials WHERE user_id = ?').all(user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: Buffer.from(user.id),
    userName: user.username,
    attestationType: 'none',
    excludeCredentials: existing.map(c => ({ id: c.credential_id })),
    authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
  });

  req.session.currentChallenge = options.challenge;
  req.session.userId = user.id;
  res.json(options);
});

Le paramètre residentKey: 'required' force la création d’un credential découvrable, indispensable pour profiter du flux de connexion sans identifiant que permet WebAuthn Level 3. L’option excludeCredentials empêche un utilisateur d’enregistrer deux fois le même appareil, ce qui éviterait des doublons inutiles dans la base.

Étape 5 : Déclencher la Création du Passkey Côté Navigateur

Côté client, le bundle @simplewebauthn/browser encapsule l’appel natif navigator.credentials.create() et gère l’encodage base64url automatiquement. Dans public/register.js :

import { startRegistration } from '@simplewebauthn/browser';

async function registerPasskey(username) {
  const optionsResp = await fetch('/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  });
  const options = await optionsResp.json();

  let attestationResponse;
  try {
    attestationResponse = await startRegistration({ optionsJSON: options });
  } catch (err) {
    if (err.name === 'InvalidStateError') {
      alert('Ce passkey existe déjà pour cet appareil.');
    } else {
      alert('Échec de la création du passkey : ' + err.message);
    }
    return;
  }

  const verifyResp = await fetch('/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(attestationResponse),
  });
  const result = await verifyResp.json();
  if (result.verified) alert('Passkey créé avec succès !');
}

Notez la gestion de l’erreur InvalidStateError, qui survient quand l’utilisateur tente de recréer un passkey déjà présent sur l’appareil courant. C’est l’un des rares messages d’erreur exploitables que l’API WebAuthn renvoie ; la plupart des autres échecs sont volontairement vagues pour des raisons de sécurité, et il faudra les gérer génériquement.

Étape 6 : Vérifier et Stocker le Nouveau Credential

Retour côté serveur : il faut vérifier la signature d’attestation renvoyée par le navigateur avant de faire confiance au nouveau credential.

import { verifyRegistrationResponse } from '@simplewebauthn/server';

app.post('/register/verify', async (req, res) => {
  const { currentChallenge, userId } = req.session;
  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: req.body,
      expectedChallenge: currentChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
  } catch (err) {
    return res.status(400).json({ verified: false, error: err.message });
  }

  const { verified, registrationInfo } = verification;
  if (verified && registrationInfo) {
    const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
    db.prepare(`INSERT INTO credentials
      (credential_id, user_id, public_key, counter, device_type, backed_up, transports, created_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
      .run(credential.id, userId, credential.publicKey, credential.counter,
           credentialDeviceType, credentialBackedUp ? 1 : 0,
           JSON.stringify(credential.transports || []), Date.now());
  }
  delete req.session.currentChallenge;
  res.json({ verified });
});

Trois vérifications sont non négociables ici : le challenge doit correspondre exactement à celui généré à l’étape 4, l’origine doit correspondre au domaine exact servant la page, et le RP ID doit correspondre à celui attendu. Ignorer l’une de ces trois valeurs ouvre la porte à des attaques de type replay ou cross-origin.

Étape 7 : Générer les Options d’Authentification (Login)

Pour la connexion, laissez le champ allowCredentials vide afin de profiter du flux découvrable : le navigateur affichera directement la liste des passkeys disponibles sur l’appareil, sans que l’utilisateur ait à taper son identifiant.

import { generateAuthenticationOptions } from '@simplewebauthn/server';

app.post('/login/options', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
  });
  req.session.currentChallenge = options.challenge;
  res.json(options);
});

C’est ce flux “sans identifiant” (usernameless) qui explique le succès des passkeys auprès du grand public : l’utilisateur clique simplement sur un bouton, choisit son passkey dans la boîte de dialogue native du système, et valide par biométrie ou code PIN de l’appareil.

Étape 8 : Implémenter la Connexion Côté Client

import { startAuthentication } from '@simplewebauthn/browser';

async function loginWithPasskey() {
  const optionsResp = await fetch('/login/options', { method: 'POST' });
  const options = await optionsResp.json();

  let assertionResponse;
  try {
    assertionResponse = await startAuthentication({ optionsJSON: options });
  } catch (err) {
    alert('Connexion annulée ou échouée : ' + err.message);
    return;
  }

  const verifyResp = await fetch('/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(assertionResponse),
  });
  const result = await verifyResp.json();
  if (result.verified) window.location.href = '/dashboard.html';
}

Testez ce flux dans deux navigateurs différents, sur desktop et sur mobile, avant de passer à l’étape suivante. Le comportement de la boîte de dialogue native varie sensiblement entre Chrome, Safari et Firefox, et une partie des bugs de production proviennent justement d’une implémentation testée sur un seul navigateur.

Étape 9 : Vérifier l’Assertion et Ouvrir la Session

import { verifyAuthenticationResponse } from '@simplewebauthn/server';

app.post('/login/verify', async (req, res) => {
  const credentialId = req.body.id;
  const stored = db.prepare('SELECT * FROM credentials WHERE credential_id = ?').get(credentialId);
  if (!stored) return res.status(400).json({ verified: false, error: 'Credential inconnu' });

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: req.body,
      expectedChallenge: req.session.currentChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      credential: {
        id: stored.credential_id,
        publicKey: stored.public_key,
        counter: stored.counter,
      },
    });
  } catch (err) {
    return res.status(400).json({ verified: false, error: err.message });
  }

  if (verification.verified) {
    db.prepare('UPDATE credentials SET counter = ? WHERE credential_id = ?')
      .run(verification.authenticationInfo.newCounter, credentialId);
    req.session.userId = stored.user_id;
  }
  delete req.session.currentChallenge;
  res.json({ verified: verification.verified });
});

La mise à jour du compteur après chaque connexion est l’étape la plus souvent oubliée des implémentations improvisées. Sans elle, vous perdez la capacité de détecter un credential cloné ou exfiltré, ce qui annule une partie du bénéfice sécurité du passkey.

Si votre application tourne sur plusieurs domaines liés (une appli principale et une variante régionale, par exemple), WebAuthn Level 3 permet de déclarer les origines associées sans dupliquer les credentials. Créez un fichier statique accessible à la racine du domaine principal :

// Fichier servi en GET sur https://monsite.fr/.well-known/webauthn
{
  "origins": [
    "https://monsite.fr",
    "https://app.monsite.fr",
    "https://monsite.eu"
  ]
}

Le navigateur récupère cette ressource au moment de l’authentification pour valider qu’une origine demandeuse fait bien partie de l’ensemble autorisé par le RP ID. Cette fonctionnalité reste encore inégalement supportée selon les navigateurs début octobre 2026 : testez-la explicitement sur chacun de vos navigateurs cibles avant de vous y reposer entièrement, et conservez une solution de repli par domaine unique en cas d’échec silencieux.

Étape 11 : Gérer le Multi-Appareils, la Récupération et la Révocation

Un utilisateur réel possède en moyenne plusieurs appareils, et il doit pouvoir perdre son téléphone sans perdre l’accès à son compte. Exposez une page de gestion des passkeys qui liste les credentials enregistrés, avec leur type d’appareil, leur date de création et un bouton de révocation :

app.get('/account/passkeys', requireAuth, (req, res) => {
  const list = db.prepare(
    'SELECT credential_id, device_type, backed_up, created_at FROM credentials WHERE user_id = ?'
  ).all(req.session.userId);
  res.json(list);
});

app.delete('/account/passkeys/:id', requireAuth, (req, res) => {
  const remaining = db.prepare('SELECT COUNT(*) AS n FROM credentials WHERE user_id = ?')
    .get(req.session.userId).n;
  if (remaining <= 1) {
    return res.status(400).json({ error: 'Impossible de supprimer le dernier passkey sans méthode de repli.' });
  }
  db.prepare('DELETE FROM credentials WHERE credential_id = ? AND user_id = ?')
    .run(req.params.id, req.session.userId);
  res.json({ deleted: true });
});

La vérification du nombre de credentials restants avant suppression évite qu'un utilisateur se retrouve verrouillé hors de son propre compte. C'est un détail d'expérience utilisateur qui devient un ticket de support coûteux si vous l'oubliez : les passkeys synchronisés (backed_up = 1) se récupèrent via le trousseau cloud de l'utilisateur, mais les passkeys liés à un seul appareil (backed_up = 0) disparaissent définitivement si l'appareil est perdu sans sauvegarde.

Étape 12 : Prévoir un Repli MFA pour la Phase de Transition

Même si les passkeys progressent vite, l'adoption totale ne se fait pas en un jour. Selon l'étude FIDO 2026 incluant la France et l'Allemagne, 90 % des personnes interrogées connaissent désormais les passkeys, mais seulement 49 % les utilisent régulièrement dès qu'elles sont disponibles. Il faut donc maintenir un chemin de repli (mot de passe plus code TOTP, ou lien magique envoyé par e-mail) pour les utilisateurs qui changent d'appareil, perdent l'accès à leur gestionnaire de clés, ou n'ont tout simplement pas encore adopté le passkey comme méthode principale.

Côté entreprise, l'enjeu est différent : 68 % des organisations ont déjà déployé ou sont en cours de déploiement de passkeys pour la connexion de leurs employés selon la même étude, mais 24 % attendent encore que les standards et les outils arrivent à maturité avant de basculer. Dans un contexte d'entreprise, ce repli MFA sert aussi de filet de sécurité pendant la phase pilote, avant un déploiement généralisé.

Étape 13 : Durcir la Configuration pour la Production

Avant de mettre en ligne, passez cette liste de contrôle :

  • Forcer HTTPS strict avec HSTS, aucune exception pour les environnements de production
  • Fixer le RP ID au domaine racine exact, jamais à un sous-domaine de test
  • Stocker SESSION_SECRET dans un gestionnaire de secrets, jamais en dur dans le code
  • Activer cookie.secure: true et sameSite: 'strict' sur les sessions
  • Journaliser chaque tentative de vérification échouée pour détecter les anomalies de compteur
  • Mettre en place un rate limiting sur /login/options et /register/options
  • Tester le parcours complet sur au moins trois navigateurs et deux systèmes mobiles différents

Un exemple de configuration nginx minimale pour servir le site en HTTPS strict devant votre application Node.js :

server {
  listen 443 ssl http2;
  server_name monsite.fr;
  ssl_certificate /etc/letsencrypt/live/monsite.fr/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/monsite.fr/privkey.pem;
  add_header Strict-Transport-Security "max-age=63072000" always;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
  }
}

Projet Complet : Structure des Fichiers et Lancement

À l'issue des 13 étapes, votre projet doit ressembler à ceci :

passkey-demo/
├── package.json
├── passkeys.db
├── public/
│   ├── index.html
│   ├── dashboard.html
│   ├── register.js
│   └── login.js
└── src/
    ├── db.js
    └── server.js

Lancez le serveur avec node src/server.js, ouvrez https://localhost:3000, créez un premier utilisateur via le formulaire d'enregistrement, puis déconnectez-vous et reconnectez-vous en passkey. Si tout fonctionne, la sortie console affiche un objet proche de :

Serveur passkey sur le port 3000
POST /register/options 200 42ms
POST /register/verify 200 18ms  { verified: true }
POST /login/options 200 11ms
POST /login/verify 200 15ms  { verified: true }

7 Pièges Courants à Éviter avec WebAuthn et les Passkeys

1. Confondre RP ID et origine. Le RP ID est un nom d'hôte nu (monsite.fr), l'origine est une URL complète (https://monsite.fr). Les confondre casse la vérification de signature sans message d'erreur clair.

2. Oublier de persister le compteur mis à jour. Si le compteur stocké ne bouge jamais, vous perdez la détection de clonage de credential, l'un des principaux garde-fous de sécurité du protocole.

3. Tester uniquement sur localhost. Le comportement change sur un vrai domaine HTTPS, notamment pour les Related Origin Requests et les authentificateurs plateforme mobiles.

4. Ne pas gérer le dernier passkey restant. Permettre à un utilisateur de supprimer son unique passkey sans méthode de repli génère des comptes verrouillés et des tickets de support.

5. Ignorer le flux usernameless. Exiger systématiquement un identifiant avant de proposer le passkey prive les utilisateurs du flux de connexion en un clic qui fait l'intérêt principal du protocole.

6. Négliger la compatibilité multi-navigateurs. Chrome, Safari et Firefox n'implémentent pas toutes les fonctionnalités de niveau 3 au même rythme ; un test sur un seul navigateur donne un faux sentiment de couverture.

7. Stocker la clé publique dans un format non portable. Utilisez le format retourné par la bibliothèque serveur sans le transformer manuellement ; des conversions maison introduisent des bugs de vérification difficiles à reproduire.

Dépannage : 9 Erreurs Fréquentes et Leurs Solutions

  • "NotAllowedError" à la création : l'utilisateur a annulé la boîte de dialogue, ou le délai d'expiration du challenge a été dépassé ; augmentez le timeout côté options si besoin.
  • "SecurityError: The operation is insecure" : la page n'est pas servie en HTTPS (hors localhost) ; vérifiez votre certificat ou votre tunnel de développement.
  • Vérification toujours "verified: false" : comparez précisément expectedOrigin et expectedRPID avec ce que le navigateur envoie réellement ; un port ou un sous-domaine mal aligné suffit à tout casser.
  • "InvalidStateError" en boucle : l'appareil a déjà un credential pour ce compte ; proposez à l'utilisateur de se connecter plutôt que de ré-enregistrer.
  • Aucun passkey proposé au login sur mobile : vérifiez que residentKey était bien à required lors de l'enregistrement initial, sinon le credential n'est pas découvrable.
  • Session perdue entre les deux appels d'enregistrement : vérifiez la configuration des cookies de session, en particulier sameSite et secure, qui bloquent parfois les cookies cross-origin en test.
  • Erreur "challenge mismatch" : le challenge stocké en session a expiré ou a été écrasé par un appel concurrent ; limitez à un challenge actif par session.
  • Passkey créé mais invisible après changement d'appareil : le credential n'était pas marqué backed_up, donc lié physiquement à l'ancien appareil ; c'est un comportement attendu, pas un bug.
  • Related Origin Requests ignorées : certains navigateurs ne lisent le fichier /.well-known/webauthn qu'après un redémarrage du profil ou une mise à jour récente ; vérifiez la version du navigateur avant de suspecter votre code.

Astuces Avancées pour Passer à l'Échelle

Une fois le flux de base validé, plusieurs ajustements améliorent la robustesse en production. D'abord, séparez le stockage des credentials dans une table indexée par user_id et credential_id, avec un index composite si votre base d'utilisateurs dépasse quelques centaines de milliers de comptes : les requêtes de vérification au login doivent rester en dessous de la dizaine de millisecondes même sous charge.

Ensuite, pensez à l'observabilité : journalisez le type d'authentificateur utilisé (plateforme ou itinérant), le device_type et le résultat de chaque vérification dans un système de métriques. Cela permet de suivre en temps réel le taux de bascule des utilisateurs vers les passkeys, un indicateur que beaucoup d'équipes suivent désormais au même titre que le taux de conversion de connexion classique.

Pour les applications à fort trafic, envisagez de déplacer la vérification cryptographique sur un service dédié plutôt que sur le serveur applicatif principal, afin d'isoler cette charge de calcul. Enfin, si vous opérez dans un environnement réglementé, documentez votre politique de passkeys (device-bound ou synced, durée de session, exigences de vérification utilisateur) : les auditeurs de sécurité commencent à demander explicitement ce type de documentation pour les audits liés au Cyber Resilience Act en Europe.

Autre piste utile pour les équipes qui gèrent un volume important de connexions simultanées : mettez en cache les métadonnées d'authentificateur (AAGUID) côté serveur plutôt que de les recalculer à chaque vérification, et surveillez spécifiquement le ratio entre tentatives d'enregistrement et tentatives de connexion effectives. Un écart anormal entre les deux peut signaler une campagne de credential stuffing adaptée, où des attaquants tentent d'enregistrer des passkeys sur des comptes volés avant que leurs propriétaires légitimes ne s'en rendent compte. Combiner cette surveillance à une notification par e-mail systématique à chaque nouvel enregistrement de passkey reste l'une des mesures les plus simples et les plus efficaces contre ce scénario.

Passkeys vs Mots de Passe vs 2FA SMS/TOTP : Tableau Comparatif

CritèreMot de passe seulMot de passe + 2FA SMS/TOTPPasskey (WebAuthn)
Résistance au phishingFaibleMoyenne (SMS contournable)Forte, liée au domaine
Donnée secrète transmise au serveurOui (hash)Oui (hash + code temporaire)Non, seule la clé publique
Expérience de connexionSaisie manuelleSaisie + code à copierBiométrie ou PIN local
Risque de réutilisation entre sitesÉlevéÉlevé pour le mot de passeNul, credential par domaine
Coût de support (réinitialisation)ÉlevéÉlevéFaible une fois adopté
Taux d'adoption régulière (étude FIDO 2026)Référence historiqueVariable selon secteur49 % des utilisateurs interrogés

Bibliothèques WebAuthn par Langage : Tableau Comparatif

LangageBibliothèqueUsage principalNiveau WebAuthn supporté
Node.js@simplewebauthn/server et @simplewebauthn/browserEnregistrement et vérification complèteLevel 2/3 (selon fonctionnalités)
Pythonpy_webauthn (webauthn)Génération d'options et vérification serveurLevel 2
Gogo-webauthn/webauthnServeur FIDO2 pour API GoLevel 2
Javawebauthn4jIntégration Spring SecurityLevel 2
PHPweb-auth/webauthn-libFramework Symfony et LaravelLevel 2

Quel que soit le langage choisi côté serveur, l'API navigateur reste la même : c'est le W3C qui définit l'API WebAuthn exposée par navigator.credentials, pas chaque bibliothèque serveur. Changer de stack backend n'impose donc pas de réécrire la logique côté client, seulement d'adapter les appels de génération et de vérification des options.

Migrer une Base d'Utilisateurs Existante vers les Passkeys

La plupart des projets qui suivent ce tutoriel ne partent pas de zéro : ils ont déjà une base d'utilisateurs authentifiés par mot de passe, parfois avec une couche 2FA TOTP ou SMS. La migration se fait presque toujours en trois phases, sans jamais forcer une bascule brutale qui bloquerait une partie de la base utilisateurs.

Dans la première phase, proposez la création d'un passkey comme option additionnelle dans les paramètres de compte, sans toucher au flux de connexion existant. C'est la phase la plus longue : elle permet de mesurer le taux d'adoption réel sur votre base avant d'investir davantage dans l'interface. Dans la deuxième phase, une fois qu'un utilisateur possède au moins un passkey, proposez-le comme méthode de connexion par défaut sur l'écran de login, tout en gardant le mot de passe accessible via un lien secondaire. Dans la troisième phase, réservée aux produits qui visent une adoption proche de 100 %, vous pouvez envisager de retirer purement et simplement le mot de passe pour les nouveaux comptes, en ne le gardant que comme filet de sécurité pour les comptes existants qui n'ont pas encore migré.

Un détail technique à anticiper dès la phase un : votre schéma de base de données doit permettre à un compte de n'avoir ni mot de passe ni passkey enregistré pendant une fenêtre transitoire (par exemple, un compte créé via SSO qui ajoute ensuite un passkey). Évitez les contraintes NOT NULL strictes sur la colonne mot de passe si vous voulez garder cette flexibilité pour les migrations futures.

L'Adoption des Passkeys en France et en Europe

Le déploiement à grande échelle le plus documenté reste celui du Royaume-Uni avec GOV.UK One Login : selon la lettre Sector Watch, le service d'identité numérique britannique a exposé la connexion passkey à 23 millions d'utilisateurs, et près de 10 % d'entre eux, soit environ 300 000 personnes, ont créé un passkey dès le premier mois du déploiement. C'est un signal fort pour les administrations françaises et européennes qui évaluent une bascule comparable sur leurs propres portails d'identité.

Côté standardisation, l'ingénieur Mike Jones, coauteur historique de la spécification WebAuthn, a confirmé sur son blog technique que la publication de WebAuthn Level 3 comme recommandation W3C le 25 août 2026 ferme un cycle de travail entamé plusieurs années auparavant, tout en gardant une compatibilité totale avec les implémentations basées sur les niveaux 1 et 2 déjà déployées dans la nature. Pour un développeur qui suit ce tutoriel aujourd'hui, cela signifie qu'il n'y a plus de risque à investir dans une implémentation de niveau 3 : la base est stable et ne sera pas remise en cause par une future révision majeure à court terme.

Foire Aux Questions

Un passkey peut-il être volé comme un mot de passe ?
Non, la clé privée ne quitte jamais l'appareil ou le trousseau synchronisé de l'utilisateur. Un attaquant qui compromet votre base de données ne récupère que des clés publiques, inutilisables pour se connecter sans la clé privée correspondante.

Les passkeys fonctionnent-ils sans connexion internet ?
La création et la vérification de signature se font localement sur l'appareil, mais l'échange avec le serveur (envoi du challenge, vérification de la réponse) nécessite bien une connexion réseau active.

Que se passe-t-il si l'utilisateur perd son téléphone ?
Si le passkey était synchronisé (backed_up: true), il est récupérable via le trousseau cloud associé sur un nouvel appareil. Sinon, l'utilisateur doit passer par votre méthode de repli (mot de passe, lien magique, ou support client) pour recréer un passkey.

Faut-il abandonner complètement les mots de passe ?
Pas immédiatement. La majorité des sites maintiennent un chemin de repli pendant la phase de transition, d'autant que l'étude FIDO 2026 montre que seuls 49 % des utilisateurs interrogés utilisent déjà les passkeys de façon régulière.

WebAuthn Level 3 est-il compatible avec mon implémentation Level 2 existante ?
Oui, Level 3 est entièrement rétrocompatible. Vous pouvez adopter progressivement les nouvelles fonctionnalités, comme les Related Origin Requests, sans casser les credentials déjà enregistrés par vos utilisateurs.

Peut-on utiliser les passkeys sur une application mobile native plutôt que sur le web ?
Oui, les plateformes mobiles exposent des API natives équivalentes (Credential Manager sur Android, ASAuthorizationController sur iOS) qui s'appuient sur le même socle FIDO2/CTAP, avec une logique serveur quasiment identique à celle présentée dans ce tutoriel.

Les clés de sécurité physiques (type YubiKey) sont-elles toujours utiles avec les passkeys ?
Oui, elles restent pertinentes pour les comptes à exigence de sécurité élevée, car elles constituent des credentials liés à l'appareil, non synchronisables dans un cloud tiers, ce qui réduit encore la surface d'attaque pour les profils les plus sensibles.

Combien de temps faut-il prévoir pour migrer une application existante vers les passkeys ?
Pour une application de taille moyenne avec une base d'utilisateurs déjà authentifiés par mot de passe, comptez entre deux et quatre semaines de développement pour la phase d'ajout optionnel, puis plusieurs mois d'observation du taux d'adoption avant d'envisager de rendre le passkey obligatoire pour les nouveaux comptes.

Un même passkey peut-il être utilisé sur deux sites différents ?
Non, par conception. Chaque passkey est cryptographiquement lié à un RP ID unique au moment de sa création, ce qui empêche toute réutilisation involontaire ou malveillante entre domaines, contrairement à un mot de passe qu'un utilisateur peut recopier sur plusieurs services.