Passkey vs mots de passe : fonctionnement et intégration

1. Vue d’ensemble

Les mots de passe ont trois problèmes majeurs : ils sont faciles à deviner, réutilisés à plusieurs endroits, et facilement volés en entrant sur de fausses pages. Passkey résout les trois grâce à la cryptographie asymétrique. Le serveur ne garde plus aucun secret de l’utilisateur.

Cet article explique ce qu’est passkey et comment fonctionnent les flux d’enregistrement et de connexion. Vous activerez ensuite passkey pour une application Spring Boot 3.x avec Spring Security. À la fin, vous découvrirez les erreurs courantes et les cas où passkey ne devrait pas être utilisé seul.

2. Qu’est-ce qu’un Passkey ?

Un passkey est une credential WebAuthn, c’est-à-dire une credential conforme à la norme FIDO2. En essence, c’est une paire de clés :

  • Clé privée résidant sur l’appareil de l’utilisateur (téléphone, ordinateur portable, clé de sécurité USB) ou dans un gestionnaire de mots de passe. Cette clé ne quitte jamais ce lieu.
  • Clé publique envoyée au serveur lors de l’enregistrement. Le serveur la sauvegarde avec le compte.

Chaque paire de clés est liée à une Relying Party (RP), c’est-à-dire votre site web ou application, via un RP ID. Le RP ID est généralement un nom de domaine, par exemple shop.example.com.

Il y a deux types principaux de passkey :

Type Caractéristiques
Synced passkey Synchronisé via le compte cloud du système d’exploitation ou du gestionnaire de mots de passe. La perte d’un appareil ne perd pas accès.
Device-bound passkey Existe seulement sur un appareil, par exemple une clé de sécurité matérielle. Plus sûr mais la perte de l’appareil signifie la perte de la clé.

L’utilisateur déverrouille le passkey avec une empreinte digitale, la reconnaissance faciale ou un code PIN de l’appareil. Les données biométriques ne servent qu’à déverrouiller localement. Elles ne sont jamais envoyées au serveur.

3. Comment fonctionne le passkey

3.1. Enregistrement (registration)

  1. Le serveur crée un challenge aléatoire. Le serveur envoie le challenge avec les informations RP et utilisateur au navigateur.
  2. Le navigateur appelle navigator.credentials.create(). L’authenticator génère une nouvelle paire de clés pour ce RP ID précis.
  3. Le navigateur envoie la clé publique, l’ID credential et les données signées au serveur.
  4. Le serveur vérifie le challenge et l’origin, puis sauvegarde la clé publique.

3.2. Authentification (authentication)

  1. Le serveur crée un nouveau challenge.
  2. Le navigateur appelle navigator.credentials.get(). L’authenticator signe le challenge avec la clé privée.
  3. Le serveur utilise la clé publique sauvegardée pour vérifier la signature.

La partie essentielle est “signer avec la clé privée, vérifier avec la clé publique”. Le code Java pur ci-dessous simule exactement cette étape :

import java.security.*;

public class PasskeyConcept {

    public static void main(String[] args) throws Exception {
        // L'appareil génère une paire de clés lors de l'enregistrement (EC P-256, correspondant au ES256 courant dans WebAuthn)
        KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
        generator.initialize(256);
        KeyPair deviceKeys = generator.generateKeyPair();
        PublicKey storedOnServer = deviceKeys.getPublic();

        // Le serveur crée un challenge aléatoire pour la connexion
        byte[] challenge = randomBytes();

        // L'appareil signe le challenge avec la clé privée
        Signature signer = Signature.getInstance("SHA256withECDSA");
        signer.initSign(deviceKeys.getPrivate());
        signer.update(challenge);
        byte[] signature = signer.sign();

        // Le serveur vérifie avec la clé publique sauvegardée
        System.out.println("Challenge correct : " + verify(storedOnServer, challenge, signature));
        System.out.println("Challenge différent : " + verify(storedOnServer, randomBytes(), signature));
    }

    static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
        Signature verifier = Signature.getInstance("SHA256withECDSA");
        verifier.initVerify(key);
        verifier.update(data);
        return verifier.verify(signature);
    }

    static byte[] randomBytes() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return bytes;
    }
}

Résultat :

Challenge correct : true
Challenge différent : false

De ce résultat, vous pouvez déduire deux choses :

  • Le serveur garde seulement storedOnServer, c’est-à-dire la clé publique. Même si un attaquant récupère la base de données, il ne peut pas s’authentifier.
  • La signature n’est valide que pour le challenge de cette session. Si un attaquant capture une ancienne signature et la rejoue (replay attack), il n’aura pas réussi la vérification.

Dans le vrai WebAuthn, les données signées contiennent aussi l’origin de la page. Le navigateur remplit automatiquement l’origin, la page web ne peut pas le falsifier. Ainsi, si l’utilisateur visite par erreur examp1e.com, le navigateur n’utilisera pas le passkey d’example.com. Le phishing est bloqué au niveau du protocole, indépendamment de la prudence de l’utilisateur.

4. Pourquoi passkey reçoit de l’attention maintenant

  • Prévention du phishing dès la conception. Les mots de passe et les OTP peuvent être volés en les faisant entrer dans une fausse page. Pas passkey.
  • Pas de secret sur le serveur. Une fuite de base de données n’expose pas les identifiants.
  • Meilleure expérience utilisateur. L’utilisateur n’a qu’à toucher une empreinte digitale, pas besoin de mémoriser un mot de passe ou d’attendre un SMS.
  • L’écosystème est prêt. Les systèmes d’exploitation, navigateurs et gestionnaires de mots de passe populaires supportent la synchronisation du passkey. Grâce à cela, la perte d’un appareil n’est plus une barrière majeure comme avant.
  • Les frameworks offrent un support. Vous n’avez pas besoin d’écrire vous-même la vérification cryptographique, qui est très facile à faire mal.

5. Intégration du passkey avec Spring Security

À partir de Spring Security 6.4 (livré avec Spring Boot 3.4 ou ultérieur), le framework a un DSL webAuthn() prêt à l’emploi. Sous le capot, la vérification utilise la bibliothèque WebAuthn4J.

5.1. Dépendance

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.webauthn4j</groupId>
        <artifactId>webauthn4j-core</artifactId>
        <version>${webauthn4j.version}</version>
    </dependency>
</dependencies>

Vous déclarez la propriété webauthn4j.version avec une version compatible avec Spring Security en utilisation. La documentation de Spring Security indique clairement les versions testées.

5.2. Configuration de SecurityFilterChain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpName("Nón Lá Shop")
                .rpId("localhost")
                .allowedOrigins("http://localhost:8080")
            );
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        UserDetails customer = User.withUsername("minh")
                .password("{noop}matkhau-tam")
                .roles("CUSTOMER")
                .build();
        return new InMemoryUserDetailsManager(customer);
    }
}

Signification de chaque partie :

  • rpName : nom affiché dans la boîte de dialogue passkey du système d’exploitation.
  • rpId : nom de domaine lié au passkey. Cette valeur ne contient pas le schéma ou le port. En production, ce serait shop.example.com.
  • allowedOrigins : les origins valides. L’origin doit correspondre exactement au schéma et au port.
  • formLogin : gardé en place. L’utilisateur doit d’abord se connecter avec un mot de passe pour enregistrer un passkey. Le passkey est une credential attachée à un compte existant.

Lorsque vous démarrez l’application, Spring Security crée automatiquement ces endpoints :

GET  /webauthn/register              page de gestion/enregistrement du passkey (défaut)
POST /webauthn/register/options      créer un challenge d'enregistrement
POST /webauthn/register              sauvegarder la nouvelle clé publique
POST /webauthn/authenticate/options  créer un challenge de connexion
POST /login/webauthn                 vérifier la signature et se connecter

La page de connexion par défaut aura maintenant un bouton “Sign in with a passkey”. Le flux de test comprend quatre étapes :

  1. Connectez-vous avec minh / matkhau-tam.
  2. Ouvrez /webauthn/register et créez un passkey.
  3. Déconnectez-vous.
  4. Reconnectez-vous avec le passkey.

5.3. Stockage des credentials

Par défaut, Spring Security stocke les credentials en mémoire. Un redémarrage de l’application perd tous les passkeys. En production, vous devez fournir un bean implémentant deux interfaces :

  • UserCredentialRepository : stocker la clé publique, l’ID credential et le compteur de signatures.
  • PublicKeyCredentialUserEntityRepository : mapper votre utilisateur avec le user handle de WebAuthn.

Les récentes versions de Spring Security ont des implémentations JDBC prêtes pour les deux interfaces. Vous pouvez aussi écrire les vôtres avec JPA. Dans ce cas, chaque credential devrait être un enregistrement séparé, car un utilisateur a généralement plusieurs passkeys sur plusieurs appareils.

6. Du côté du navigateur

Si vous utilisez la page de connexion par défaut, Spring Security gère déjà le JavaScript pour vous. Avec une interface personnalisée, le flux de connexion ressemble à ceci :

async function loginWithPasskey() {
  const csrf = document.querySelector('meta[name="_csrf"]').content;

  // 1. Demander un challenge au serveur
  const optionsRes = await fetch('/webauthn/authenticate/options', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': csrf }
  });
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());

  // 2. Le navigateur affiche une boîte de dialogue, l'authenticator signe le challenge
  const credential = await navigator.credentials.get({ publicKey: options });

  // 3. Envoyer la signature au serveur pour vérification
  const res = await fetch('/login/webauthn', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
    body: JSON.stringify(credential.toJSON())
  });
  if (res.ok) window.location.href = '/';
}

Les étapes du code :

  • L’étape 1 récupère le challenge. La requête doit inclure un token CSRF car c’est un POST.
  • parseRequestOptionsFromJSON convertit les champs base64url en ArrayBuffer. Les anciens navigateurs peuvent ne pas supporter cette fonction. Dans ce cas, vous devez utiliser une librairie helper ou décoder manuellement.
  • L’étape 3 envoie le résultat au serveur. Le format JSON doit correspondre à ce que le serveur attend. Le script dans la page de connexion par défaut de Spring Security est la meilleure référence pour comparer.

7. Tests

Vous ne pouvez pas facilement simuler un vrai authenticator dans un test unitaire. Cependant, vous pouvez quand même tester la partie importante du serveur : l’endpoint de création de challenge fonctionne et chaque appel retourne un challenge différent.

@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void authenticationOptions_containRpIdAndFreshChallenge() {
        String first = requestChallenge();
        String second = requestChallenge();

        assertThat(first).isNotBlank();
        assertThat(second).isNotEqualTo(first);
    }

    @Test
    void authenticationOptions_useConfiguredRpId() {
        assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$.rpId")
                .isEqualTo("localhost");
    }

    private String requestChallenge() {
        MvcTestResult result = mvc.post()
                .uri("/webauthn/authenticate/options")
                .with(csrf())
                .exchange();
        assertThat(result).hasStatusOk();
        return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
    }
}

MockMvcTester (Spring Framework 6.2+) permet d’écrire les assertions dans le style AssertJ. csrf() provient de SecurityMockMvcRequestPostProcessors. Le premier test confirme que le challenge n’est pas réutilisé, c’est le mécanisme de protection contre les rejeux. Le second test garantit que rpId est correctement configuré. C’est l’erreur de configuration la plus courante lors du déploiement.

Pour les tests de bout en bout, utilisez l’authenticator virtuel du navigateur. Chrome DevTools a un onglet WebAuthn, et WebDriver/Playwright supportent aussi cette fonctionnalité.

8. Erreurs courantes et quand ne pas utiliser passkey

8.1. Erreurs courantes

  • rpId ou allowedOrigins incorrect. Écrire https:// dans rpId, ou oublier le port dans origin, fera rejeter par le navigateur ou le serveur. Vous devriez externaliser ces deux valeurs dans application.yaml selon l’environnement.
  • Pas d’HTTPS. WebAuthn ne fonctionne que dans un contexte sécurisé. localhost est la seule exception.
  • Changement de nom de domaine. Le passkey est lié au RP ID. Changer le domaine principal rend tous les anciens passkeys inutilisables.
  • Stockage en mémoire en production. Un redémarrage fait perdre les passkeys aux utilisateurs.
  • Permettre l’enregistrement d’un seul passkey. Les utilisateurs ont besoin de plusieurs passkeys sur plusieurs appareils. Ayez une page listant et permettant la suppression de chaque passkey.

Note : Passkey n’est puissant que si le flux de récupération de compte est aussi sûr. Si “oublier le passkey” signifie juste envoyer un lien par email, les attaquants viseront l’email au lieu du passkey.

8.2. Quand ne pas utiliser (ou ne pas utiliser seul)

  • Utilisateurs sur des appareils anciens ou partagés, comme un ordinateur au comptoir ou un kiosk. Vous devez garder une méthode de secours.
  • Systèmes non basés sur navigateur ou app, comme les API entre services, CLI ou jobs de fond. Ces cas devraient utiliser OAuth2 client credentials, mTLS ou clés API.
  • Les exigences de conformité n’acceptent que les clés matérielles. Les synced passkeys pourraient ne pas satisfaire les exigences. Vous avez besoin de device-bound passkeys et de vérifier l’attestation.
  • L’équipe n’a pas de processus de récupération de compte. Vous devriez d’abord déployer passkey comme une option supplémentaire, puis considérer de retirer les mots de passe.

9. Conclusion

Passkey remplace un secret partagé (mot de passe) par une paire de clés publiques liée au nom de domaine. Grâce à cela, passkey élimine le phishing, le credential stuffing et les risques de fuite de base de données avec un seul mécanisme. Avec Spring Security, vous n’avez qu’à ajouter webAuthn() à SecurityFilterChain, configurer correctement rpId et allowedOrigins, puis remplacer le stockage en mémoire par un stockage persistant.

La façon la plus sûre de déployer est d’aller pas à pas : permettre aux utilisateurs d’ajouter le passkey à leur compte existant, concevoir un flux de récupération strict, puis considérer de désactiver la connexion par mot de passe.

passkeywebauthnspring-securitysécuritétendances-tech