Passkeys et WebAuthn avec Next.js 15 : authentification sans mot de passe en 2026

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

La fin des mots de passe approche. Les Passkeys — basées sur le standard WebAuthn — permettent aux utilisateurs de se connecter avec la biométrie, un code PIN ou une clé de sécurité physique au lieu de mots de passe. Dans ce tutoriel, vous construirez un système complet sans mot de passe avec Next.js 15, en utilisant la bibliothèque SimpleWebAuthn.

Ce que vous apprendrez

À la fin de ce tutoriel, vous saurez :

  • Comprendre le fonctionnement des Passkeys et de WebAuthn API en profondeur
  • Configurer un projet Next.js 15 avec TypeScript et Prisma
  • Implémenter la création de passkeys (enregistrement de credentials)
  • Construire une connexion sans mot de passe avec biométrie ou code PIN
  • Gérer la cérémonie WebAuthn complète (challenge, attestation, assertion)
  • Stocker et vérifier les credentials dans une base PostgreSQL
  • Ajouter une authentification de secours pour les appareils sans support passkey
  • Déployer un système prêt pour la production

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 20+ installé (node --version)
  • Expérience en TypeScript (types, génériques, async/await)
  • Familiarité avec Next.js 15 (App Router, Server Components, Route Handlers)
  • PostgreSQL en local ou une base de données cloud (Neon, Supabase, ou similaire)
  • Un navigateur moderne supportant WebAuthn (Chrome, Safari, Firefox, Edge)
  • Un appareil avec capacité biométrique (Touch ID, Face ID, Windows Hello) ou une clé de sécurité physique

Pourquoi les Passkeys ?

Les mots de passe traditionnels sont le maillon faible de la sécurité web. Les utilisateurs les réutilisent, les oublient et tombent dans les pièges de phishing. Les Passkeys résolvent ces trois problèmes :

CaractéristiqueMots de passePasskeys
Résistance au phishingNonOui — liées au domaine
Rien à mémoriserNonOui — biométrie ou code PIN
Réutilisation entre sitesCouranteImpossible — unique par site
Risque de fuite serveurÉlevé — fuite de hashAucun — seules les clés publiques sont stockées
Expérience utilisateurFrictionUn seul geste

Les principales plateformes supportent nativement les passkeys : Apple iCloud Keychain les synchronise entre appareils, Google Password Manager fait de même sur Android et Chrome, et Windows Hello les gère sur les appareils Microsoft. Mi-2026, plus de 75 % des appareils grand public supportent les passkeys.


Comment fonctionne WebAuthn

WebAuthn est le standard W3C qui propulse les passkeys. Le processus implique trois acteurs :

  1. Le Relying Party (RP) — votre serveur
  2. Le Client — le navigateur
  3. Le Authenticator — le capteur biométrique ou la clé physique

Flux de création

  1. Le serveur génère un challenge (octets aléatoires)
  2. Le navigateur appelle navigator.credentials.create() avec le challenge
  3. Le authenticator crée une paire de clés publique/privée, stocke la clé privée
  4. Le navigateur envoie la clé publique et attestation au serveur
  5. Le serveur vérifie et stocke la clé publique

Flux de connexion

  1. Le serveur génère un nouveau challenge
  2. Le navigateur appelle navigator.credentials.get() avec le challenge
  3. Le authenticator signe le challenge avec la clé privée
  4. Le navigateur envoie assertion signée au serveur
  5. Le serveur vérifie la signature contre la clé publique stockée

La clé privée ne quitte jamais le appareil. Même en cas de fuite de base de données, les attaquants ne récupèrent que des clés publiques — inutilisables sans les clés privées verrouillées dans les appareils des utilisateurs.


Étape 1 : Configuration du projet

Créez un nouveau projet Next.js 15 avec TypeScript :

npx create-next-app@latest passkeys-demo --typescript --tailwind --app --src-dir --use-npm
cd passkeys-demo

Installez les dépendances :

npm install @simplewebauthn/server @simplewebauthn/browser
npm install @prisma/client
npm install -D prisma @simplewebauthn/types

Voici le rôle de chaque package :

  • @simplewebauthn/server — Vérification WebAuthn côté serveur (attestation et assertion)
  • @simplewebauthn/browser — Helpers côté client pour les appels navigator.credentials
  • @prisma/client — ORM de base de données type-safe
  • @simplewebauthn/types — Types TypeScript partagés

Étape 2 : Schéma de base de données avec Prisma

Initialisez Prisma :

npx prisma init --datasource-provider postgresql

Mettez à jour prisma/schema.prisma pour définir les utilisateurs et leurs credentials passkey :

generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id          String       @id @default(cuid())
  email       String       @unique
  name        String?
  credentials Credential[]
  challenges  Challenge[]
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt
}
 
model Credential {
  id                  String   @id @default(cuid())
  credentialId        String   @unique
  credentialPublicKey Bytes
  counter             BigInt   @default(0)
  credentialDeviceType String
  credentialBackedUp  Boolean  @default(false)
  transports          String[] @default([])
  userId              String
  user                User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt           DateTime @default(now())
}
 
model Challenge {
  id        String   @id @default(cuid())
  challenge String
  userId    String?
  user      User?    @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now())
}

Décisions de conception clés :

  • credentialPublicKey stocké en Bytes — la clé publique COSE brute
  • counter suit le nombre de signatures pour détecter les authenticators clonés
  • credentialDeviceType distingue les credentials mono-appareil vs multi-appareils (synchronisées)
  • credentialBackedUp indique si le credential est synchronisé via le cloud
  • transports stocke le mode de communication de authenticator (USB, BLE, NFC, interne)
  • Challenge a une expiration pour prévenir les attaques par rejeu

Exécutez la migration :

npx prisma migrate dev --name init

Créez un singleton Prisma client dans src/lib/prisma.ts :

import { PrismaClient } from "@prisma/client";
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
 
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

Étape 3 : Configuration WebAuthn

Créez src/lib/webauthn.ts pour centraliser la configuration du relying party :

import type {
  GenerateRegistrationOptionsOpts,
  GenerateAuthenticationOptionsOpts,
} from "@simplewebauthn/server";
 
// Configuration du Relying Party
export const rpName = "Passkeys Demo";
export const rpID = process.env.WEBAUTHN_RP_ID || "localhost";
export const origin =
  process.env.WEBAUTHN_ORIGIN || `http://localhost:3000`;
 
// Helper pour obtenir les origines attendues (supporte plusieurs)
export function getExpectedOrigins(): string[] {
  const origins = [origin];
  if (process.env.WEBAUTHN_ADDITIONAL_ORIGINS) {
    origins.push(
      ...process.env.WEBAUTHN_ADDITIONAL_ORIGINS.split(",")
    );
  }
  return origins;
}

Ajoutez les variables dans .env :

DATABASE_URL="postgresql://user:password@localhost:5432/passkeys_demo"
WEBAUTHN_RP_ID="localhost"
WEBAUTHN_ORIGIN="http://localhost:3000"

Important : Le rpID doit correspondre exactement à votre domaine. Pour le développement local, utilisez "localhost". En production, utilisez votre domaine sans le protocole — par exemple "example.com". Les passkeys sont liées à cette origine et ne fonctionneront pas si le rpID change.


Étape 4 : Routes API de création

Créez le flux de création avec deux endpoints : un pour générer les options et un pour vérifier la réponse.

Génération des options de création

Créez src/app/api/auth/register/options/route.ts :

import { NextRequest, NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpName, rpID } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { email, name } = await request.json();
 
    if (!email) {
      return NextResponse.json(
        { error: "Email requis" },
        { status: 400 }
      );
    }
 
    // Trouver ou créer utilisateur
    let user = await prisma.user.findUnique({
      where: { email },
      include: { credentials: true },
    });
 
    if (!user) {
      user = await prisma.user.create({
        data: { email, name: name || email.split("@")[0] },
        include: { credentials: true },
      });
    }
 
    // Obtenir les credentials existants à exclure
    const excludeCredentials = user.credentials.map((cred) => ({
      id: cred.credentialId,
      type: "public-key" as const,
      transports: cred.transports as AuthenticatorTransport[],
    }));
 
    // Générer les options de création
    const options = await generateRegistrationOptions({
      rpName,
      rpID,
      userName: user.email,
      userDisplayName: user.name || user.email,
      attestationType: "none",
      excludeCredentials,
      authenticatorSelection: {
        residentKey: "preferred",
        userVerification: "preferred",
        authenticatorAttachment: "platform",
      },
      supportedAlgorithmIDs: [-7, -257],
    });
 
    // Stocker le challenge avec expiration
    await prisma.challenge.create({
      data: {
        challenge: options.challenge,
        userId: user.id,
        expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      },
    });
 
    return NextResponse.json({
      options,
      userId: user.id,
    });
  } catch (error) {
    console.error("Erreur options de création :", error);
    return NextResponse.json(
      { error: "Échec de la génération des options" },
      { status: 500 }
    );
  }
}

Vérification de la création

Créez src/app/api/auth/register/verify/route.ts :

import { NextRequest, NextResponse } from "next/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { credential, userId } = await request.json();
 
    // Trouver le challenge stocké
    const storedChallenge = await prisma.challenge.findFirst({
      where: {
        userId,
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: "desc" },
    });
 
    if (!storedChallenge) {
      return NextResponse.json(
        { error: "Challenge expiré ou introuvable" },
        { status: 400 }
      );
    }
 
    // Vérifier la réponse de création
    const verification = await verifyRegistrationResponse({
      response: credential,
      expectedChallenge: storedChallenge.challenge,
      expectedOrigin: getExpectedOrigins(),
      expectedRPID: rpID,
      requireUserVerification: false,
    });
 
    if (!verification.verified || !verification.registrationInfo) {
      return NextResponse.json(
        { error: "Échec de la vérification" },
        { status: 400 }
      );
    }
 
    const {
      credential: registrationCredential,
      credentialDeviceType,
      credentialBackedUp,
    } = verification.registrationInfo;
 
    // Stocker le credential
    await prisma.credential.create({
      data: {
        credentialId: registrationCredential.id,
        credentialPublicKey: Buffer.from(
          registrationCredential.publicKey
        ),
        counter: registrationCredential.counter,
        credentialDeviceType,
        credentialBackedUp,
        transports: credential.response.transports || [],
        userId,
      },
    });
 
    // Nettoyer le challenge utilisé
    await prisma.challenge.delete({
      where: { id: storedChallenge.id },
    });
 
    return NextResponse.json({
      verified: true,
      credentialDeviceType,
      credentialBackedUp,
    });
  } catch (error) {
    console.error("Erreur de vérification :", error);
    return NextResponse.json(
      { error: "Échec de la vérification" },
      { status: 500 }
    );
  }
}

Étape 5 : Routes API de connexion

Génération des options de connexion

Créez src/app/api/auth/login/options/route.ts :

import { NextRequest, NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID } from "@/lib/webauthn";
 
export async function POST(request: NextRequest) {
  try {
    const { email } = await request.json();
 
    let allowCredentials: {
      id: string;
      type: "public-key";
      transports?: AuthenticatorTransport[];
    }[] = [];
 
    if (email) {
      const user = await prisma.user.findUnique({
        where: { email },
        include: { credentials: true },
      });
 
      if (user) {
        allowCredentials = user.credentials.map((cred) => ({
          id: cred.credentialId,
          type: "public-key" as const,
          transports: cred.transports as AuthenticatorTransport[],
        }));
      }
    }
 
    const options = await generateAuthenticationOptions({
      rpID,
      userVerification: "preferred",
      allowCredentials:
        allowCredentials.length > 0 ? allowCredentials : undefined,
    });
 
    const user = email
      ? await prisma.user.findUnique({ where: { email } })
      : null;
 
    await prisma.challenge.create({
      data: {
        challenge: options.challenge,
        userId: user?.id || null,
        expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      },
    });
 
    return NextResponse.json({ options });
  } catch (error) {
    console.error("Erreur options connexion :", error);
    return NextResponse.json(
      { error: "Échec de la génération des options" },
      { status: 500 }
    );
  }
}

Vérification de la connexion

Créez src/app/api/auth/login/verify/route.ts :

import { NextRequest, NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { prisma } from "@/lib/prisma";
import { rpID, getExpectedOrigins } from "@/lib/webauthn";
import { cookies } from "next/headers";
 
export async function POST(request: NextRequest) {
  try {
    const { credential } = await request.json();
 
    // Trouver le credential en base
    const storedCredential = await prisma.credential.findUnique({
      where: { credentialId: credential.id },
      include: { user: true },
    });
 
    if (!storedCredential) {
      return NextResponse.json(
        { error: "Credential introuvable" },
        { status: 400 }
      );
    }
 
    // Trouver le challenge stocké
    const storedChallenge = await prisma.challenge.findFirst({
      where: {
        expiresAt: { gt: new Date() },
        OR: [
          { userId: storedCredential.userId },
          { userId: null },
        ],
      },
      orderBy: { createdAt: "desc" },
    });
 
    if (!storedChallenge) {
      return NextResponse.json(
        { error: "Challenge expiré ou introuvable" },
        { status: 400 }
      );
    }
 
    // Vérifier la réponse de connexion
    const verification = await verifyAuthenticationResponse({
      response: credential,
      expectedChallenge: storedChallenge.challenge,
      expectedOrigin: getExpectedOrigins(),
      expectedRPID: rpID,
      credential: {
        id: storedCredential.credentialId,
        publicKey: new Uint8Array(storedCredential.credentialPublicKey),
        counter: Number(storedCredential.counter),
        transports:
          storedCredential.transports as AuthenticatorTransport[],
      },
      requireUserVerification: false,
    });
 
    if (!verification.verified) {
      return NextResponse.json(
        { error: "Échec de la connexion" },
        { status: 400 }
      );
    }
 
    // Mettre à jour le compteur (important pour la sécurité)
    await prisma.credential.update({
      where: { id: storedCredential.id },
      data: {
        counter: verification.authenticationInfo.newCounter,
      },
    });
 
    // Nettoyer le challenge
    await prisma.challenge.delete({
      where: { id: storedChallenge.id },
    });
 
    // Créer une session
    const cookieStore = await cookies();
    cookieStore.set("session", storedCredential.userId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 60 * 60 * 24 * 7,
      path: "/",
    });
 
    return NextResponse.json({
      verified: true,
      user: {
        id: storedCredential.user.id,
        email: storedCredential.user.email,
        name: storedCredential.user.name,
      },
    });
  } catch (error) {
    console.error("Erreur de vérification connexion :", error);
    return NextResponse.json(
      { error: "Échec de la connexion" },
      { status: 500 }
    );
  }
}

La vérification du compteur est cruciale. Le compteur augmente à chaque utilisation du authenticator. Si le serveur voit un compteur inférieur à celui stocké, cela signifie que le credential a potentiellement été cloné. SimpleWebAuthn gère cette vérification automatiquement et lance une erreur en cas de détection.


Étape 6 : Hooks côté client

Créez un hook personnalisé pour gérer le flux WebAuthn dans src/hooks/use-passkey.ts :

"use client";
 
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
} from "@simplewebauthn/browser";
import { useState, useCallback } from "react";
 
interface UsePasskeyReturn {
  isSupported: boolean;
  isLoading: boolean;
  error: string | null;
  register: (email: string, name?: string) => Promise<boolean>;
  login: (email?: string) => Promise<boolean>;
}
 
export function usePasskey(): UsePasskeyReturn {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const isSupported = browserSupportsWebAuthn();
 
  const register = useCallback(
    async (email: string, name?: string): Promise<boolean> => {
      setIsLoading(true);
      setError(null);
 
      try {
        // Étape 1 : Obtenir les options du serveur
        const optionsRes = await fetch(
          "/api/auth/register/options",
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ email, name }),
          }
        );
 
        if (!optionsRes.ok) {
          throw new Error("Échec de récupération des options");
        }
 
        const { options, userId } = await optionsRes.json();
 
        // Étape 2 : Créer le credential via API navigateur
        const credential = await startRegistration({
          optionsJSON: options,
        });
 
        // Étape 3 : Vérifier avec le serveur
        const verifyRes = await fetch(
          "/api/auth/register/verify",
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ credential, userId }),
          }
        );
 
        if (!verifyRes.ok) {
          throw new Error("Échec de la vérification");
        }
 
        const result = await verifyRes.json();
        return result.verified;
      } catch (err) {
        const message =
          err instanceof Error ? err.message : "Échec de la création";
        setError(message);
        return false;
      } finally {
        setIsLoading(false);
      }
    },
    []
  );
 
  const login = useCallback(
    async (email?: string): Promise<boolean> => {
      setIsLoading(true);
      setError(null);
 
      try {
        const optionsRes = await fetch("/api/auth/login/options", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email }),
        });
 
        if (!optionsRes.ok) {
          throw new Error("Échec de récupération des options");
        }
 
        const { options } = await optionsRes.json();
 
        const credential = await startAuthentication({
          optionsJSON: options,
        });
 
        const verifyRes = await fetch("/api/auth/login/verify", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ credential }),
        });
 
        if (!verifyRes.ok) {
          throw new Error("Échec de la connexion");
        }
 
        const result = await verifyRes.json();
        return result.verified;
      } catch (err) {
        const message =
          err instanceof Error ? err.message : "Échec de la connexion";
        setError(message);
        return false;
      } finally {
        setIsLoading(false);
      }
    },
    []
  );
 
  return { isSupported, isLoading, error, register, login };
}

Étape 7 : Composant de création de compte

Créez src/components/passkey-register.tsx :

"use client";
 
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
 
export function PasskeyRegister() {
  const { isSupported, isLoading, error, register } = usePasskey();
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [success, setSuccess] = useState(false);
 
  if (!isSupported) {
    return (
      <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
        <p className="text-yellow-800">
          Votre navigateur ne supporte pas les passkeys. Veuillez
          utiliser un navigateur moderne comme Chrome, Safari ou
          Firefox.
        </p>
      </div>
    );
  }
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await register(email, name);
    if (result) {
      setSuccess(true);
    }
  };
 
  if (success) {
    return (
      <div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
        <h3 className="text-lg font-semibold text-green-800">
          Passkey créée !
        </h3>
        <p className="mt-2 text-green-600">
          Votre passkey a été enregistrée. Vous pouvez maintenant
          vous connecter avec votre empreinte digitale, votre visage
          ou votre code PIN.
        </p>
      </div>
    );
  }
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label
          htmlFor="email"
          className="block text-sm font-medium text-gray-700"
        >
          Email
        </label>
        <input
          id="email"
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="vous@exemple.com"
        />
      </div>
 
      <div>
        <label
          htmlFor="name"
          className="block text-sm font-medium text-gray-700"
        >
          Nom (optionnel)
        </label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="Marie Dupont"
        />
      </div>
 
      {error && (
        <div className="rounded-md bg-red-50 p-3">
          <p className="text-sm text-red-700">{error}</p>
        </div>
      )}
 
      <button
        type="submit"
        disabled={isLoading}
        className="w-full rounded-md bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
      >
        {isLoading ? "Création en cours..." : "Créer une Passkey"}
      </button>
    </form>
  );
}

Étape 8 : Composant de connexion

Créez src/components/passkey-login.tsx :

"use client";
 
import { useState } from "react";
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
 
export function PasskeyLogin() {
  const { isSupported, isLoading, error, login } = usePasskey();
  const [email, setEmail] = useState("");
  const router = useRouter();
 
  if (!isSupported) {
    return (
      <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
        <p className="text-yellow-800">
          Votre navigateur ne supporte pas les passkeys.
        </p>
      </div>
    );
  }
 
  const handleEmailLogin = async (e: React.FormEvent) => {
    e.preventDefault();
    const result = await login(email);
    if (result) {
      router.push("/dashboard");
    }
  };
 
  const handleQuickLogin = async () => {
    const result = await login();
    if (result) {
      router.push("/dashboard");
    }
  };
 
  return (
    <div className="space-y-6">
      <button
        onClick={handleQuickLogin}
        disabled={isLoading}
        className="w-full rounded-md bg-indigo-600 px-4 py-3 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50"
      >
        {isLoading ? (
          "Vérification..."
        ) : (
          <span className="flex items-center justify-center gap-2">
            <svg
              className="h-5 w-5"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4"
              />
            </svg>
            Se connecter avec Passkey
          </span>
        )}
      </button>
 
      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <div className="w-full border-t border-gray-300" />
        </div>
        <div className="relative flex justify-center text-sm">
          <span className="bg-white px-2 text-gray-500">
            Ou entrez votre email
          </span>
        </div>
      </div>
 
      <form onSubmit={handleEmailLogin} className="space-y-4">
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500"
          placeholder="vous@exemple.com"
        />
        <button
          type="submit"
          disabled={isLoading || !email}
          className="w-full rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
        >
          Continuer avec email
        </button>
      </form>
 
      {error && (
        <div className="rounded-md bg-red-50 p-3">
          <p className="text-sm text-red-700">{error}</p>
        </div>
      )}
    </div>
  );
}

Étape 9 : Page de connexion

Créez la page principale dans src/app/auth/page.tsx :

"use client";
 
import { useState } from "react";
import { PasskeyRegister } from "@/components/passkey-register";
import { PasskeyLogin } from "@/components/passkey-login";
 
export default function AuthPage() {
  const [mode, setMode] = useState<"login" | "register">("login");
 
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <div className="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-lg">
        <div className="text-center">
          <h1 className="text-2xl font-bold text-gray-900">
            {mode === "login" ? "Bon retour" : "Créer un compte"}
          </h1>
          <p className="mt-2 text-gray-600">
            {mode === "login"
              ? "Connectez-vous avec votre passkey"
              : "Enregistrez une nouvelle passkey"}
          </p>
        </div>
 
        {mode === "login" ? <PasskeyLogin /> : <PasskeyRegister />}
 
        <div className="text-center">
          <button
            onClick={() =>
              setMode(mode === "login" ? "register" : "login")
            }
            className="text-sm text-indigo-600 hover:text-indigo-500"
          >
            {mode === "login"
              ? "Pas de compte ? Créer un compte"
              : "Déjà une passkey ? Se connecter"}
          </button>
        </div>
      </div>
    </div>
  );
}

Étape 10 : Gestion de session et routes protégées

Créez un helper de session dans src/lib/session.ts :

import { cookies } from "next/headers";
import { prisma } from "./prisma";
 
export async function getSession() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get("session");
 
  if (!sessionCookie?.value) {
    return null;
  }
 
  const user = await prisma.user.findUnique({
    where: { id: sessionCookie.value },
    select: {
      id: true,
      email: true,
      name: true,
      credentials: {
        select: {
          id: true,
          credentialDeviceType: true,
          credentialBackedUp: true,
          createdAt: true,
        },
      },
    },
  });
 
  return user;
}

Créez le middleware pour protéger les routes dans src/middleware.ts :

import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  const session = request.cookies.get("session");
 
  if (request.nextUrl.pathname.startsWith("/dashboard")) {
    if (!session?.value) {
      return NextResponse.redirect(new URL("/auth", request.url));
    }
  }
 
  if (request.nextUrl.pathname === "/auth") {
    if (session?.value) {
      return NextResponse.redirect(
        new URL("/dashboard", request.url)
      );
    }
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/auth"],
};

Étape 11 : Gestion des passkeys

Permettez aux utilisateurs de gérer leurs passkeys. Créez src/components/passkey-manager.tsx :

"use client";
 
import { usePasskey } from "@/hooks/use-passkey";
import { useRouter } from "next/navigation";
 
export function PasskeyManager() {
  const { isLoading, error, register } = usePasskey();
  const router = useRouter();
 
  const handleAddPasskey = async () => {
    const res = await fetch("/api/auth/me");
    if (!res.ok) return;
    const { email } = await res.json();
    await register(email);
    router.refresh();
  };
 
  const handleSignOut = async () => {
    await fetch("/api/auth/logout", { method: "POST" });
    router.push("/auth");
  };
 
  return (
    <div className="mt-6 flex gap-3">
      <button
        onClick={handleAddPasskey}
        disabled={isLoading}
        className="rounded-md border border-indigo-600 px-4 py-2 text-indigo-600 hover:bg-indigo-50 disabled:opacity-50"
      >
        {isLoading ? "Ajout en cours..." : "Ajouter une passkey"}
      </button>
      <button
        onClick={handleSignOut}
        className="rounded-md border border-red-300 px-4 py-2 text-red-600 hover:bg-red-50"
      >
        Se déconnecter
      </button>
      {error && <p className="text-sm text-red-600">{error}</p>}
    </div>
  );
}

Étape 12 : Interface conditionnelle pour le support WebAuthn

Créez un utilitaire pour la dégradation gracieuse dans src/components/webauthn-check.tsx :

"use client";
 
import {
  browserSupportsWebAuthn,
  platformAuthenticatorIsAvailable,
} from "@simplewebauthn/browser";
import { useEffect, useState } from "react";
 
interface WebAuthnStatus {
  webauthnSupported: boolean;
  platformSupported: boolean;
  checked: boolean;
}
 
export function useWebAuthnStatus(): WebAuthnStatus {
  const [status, setStatus] = useState<WebAuthnStatus>({
    webauthnSupported: false,
    platformSupported: false,
    checked: false,
  });
 
  useEffect(() => {
    async function check() {
      const webauthnSupported = browserSupportsWebAuthn();
      const platformSupported = webauthnSupported
        ? await platformAuthenticatorIsAvailable()
        : false;
      setStatus({
        webauthnSupported,
        platformSupported,
        checked: true,
      });
    }
    check();
  }, []);
 
  return status;
}

Test de votre implémentation

Test local

Démarrez le serveur de développement :

npx prisma migrate dev
npm run dev

Ouvrez http://localhost:3000/auth et testez :

  1. Création — Entrez votre email, cliquez "Créer une Passkey", authentifiez avec Touch ID / Windows Hello
  2. Connexion — Cliquez "Se connecter avec Passkey" et vérifiez avec la biométrie
  3. Tableau de bord — Vérifiez que vos passkeys enregistrées apparaissent
  4. Ajout de passkey — Ajoutez une seconde passkey depuis un autre appareil

Test sur mobile

Pour tester sur un appareil mobile en développement :

npx localtunnel --port 3000

WebAuthn nécessite un contexte sécurisé. Il ne fonctionne que sur des origines https:// ou localhost. Pour tester sur mobile, vous avez besoin de HTTPS — utilisez un service de tunnel ou déployez sur un environnement de staging.


Résolution de problèmes

"Opération expirée ou non autorisée"

Cela signifie généralement :

  • Utilisation annulée de la vérification biométrique
  • Expiration du délai du authenticator (60 secondes par défaut)
  • Le rpID ne correspond pas au domaine actuel

"NotAllowedError: Request is not allowed"

Vérifiez ces causes courantes :

  • WebAuthn nécessite un contexte sécurisé (HTTPS ou localhost)
  • La page doit être au premier plan — les onglets en arrière-plan ne peuvent pas déclencher WebAuthn
  • Sur Safari, appel doit provenir de un geste utilisateur (gestionnaire de clic)

Erreurs de désynchronisation du compteur

Un compteur désynchronisé suggère un authenticator cloné. En développement, cela peut arriver si vous réinitialisez la base de données sans effacer les credentials du navigateur.


Checklist de déploiement

Avant la mise en production, vérifiez :

  • WEBAUTHN_RP_ID configuré avec votre domaine de production
  • WEBAUTHN_ORIGIN configuré avec votre URL de production
  • HTTPS activé — WebAuthn ne fonctionnera pas sans
  • Session cookie simplifiée remplacée par une bibliothèque de sessions appropriée
  • Rate limiting ajouté sur les endpoints de connexion
  • Nettoyage des challenges expirés configuré
  • Connexion par mot de passe en secours pour les anciens appareils
  • Tests cross-navigateurs : Chrome, Safari, Firefox, Edge

Prochaines étapes

Maintenant que votre authentification sans mot de passe fonctionne, envisagez ces améliorations :

  • Interface conditionnelle — Utilisez PublicKeyCredential.isConditionalMediationAvailable() pour afficher les passkeys dans la liste de remplissage automatique du navigateur
  • Approche hybride — Permettez aux utilisateurs de créer un mot de passe dabord, puis ajouter une passkey comme upgrade
  • Récupération de compte — Implémentez des codes de récupération ou une récupération par email
  • Journal audit — Suivez les événements de connexion (succès, échecs, nouvelles créations)
  • Authentification multi-facteurs — Combinez passkeys avec un second facteur pour les opérations sensibles

Conclusion

Vous avez construit un système complet de connexion sans mot de passe avec les Passkeys et WebAuthn dans Next.js 15. Votre implémentation gère :

  • La création de credentials avec vérification biométrique ou code PIN
  • La connexion sans mot de passe supportant les flux discoverable et par email
  • La gestion de session avec routes protégées et middleware
  • La gestion des passkeys permettant aux utilisateurs de gérer plusieurs credentials
  • La dégradation gracieuse pour les navigateurs sans support WebAuthn

Les passkeys représentent la plus grande avancée en authentification web depuis des décennies. Elles éliminent entièrement les mots de passe, résistent au phishing par conception, et offrent une meilleure expérience utilisateur que tout système basé sur les mots de passe.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Déployer une application Next.js avec Coolify v4 : guide complet du self-hosting.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes

Construire un Starter Kit SaaS avec Next.js 15, Stripe et Auth.js v5

Apprenez a construire une application SaaS prete pour la production avec Next.js 15, Stripe pour la facturation par abonnement, et Auth.js v5 pour l'authentification. Ce tutoriel pas a pas couvre la configuration du projet, la connexion OAuth, les plans tarifaires, la gestion des webhooks et les routes protegees.

35 min read·