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

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éristique | Mots de passe | Passkeys |
|---|---|---|
| Résistance au phishing | Non | Oui — liées au domaine |
| Rien à mémoriser | Non | Oui — biométrie ou code PIN |
| Réutilisation entre sites | Courante | Impossible — unique par site |
| Risque de fuite serveur | Élevé — fuite de hash | Aucun — seules les clés publiques sont stockées |
| Expérience utilisateur | Friction | Un 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 :
- Le Relying Party (RP) — votre serveur
- Le Client — le navigateur
- Le Authenticator — le capteur biométrique ou la clé physique
Flux de création
- Le serveur génère un challenge (octets aléatoires)
- Le navigateur appelle
navigator.credentials.create()avec le challenge - Le authenticator crée une paire de clés publique/privée, stocke la clé privée
- Le navigateur envoie la clé publique et attestation au serveur
- Le serveur vérifie et stocke la clé publique
Flux de connexion
- Le serveur génère un nouveau challenge
- Le navigateur appelle
navigator.credentials.get()avec le challenge - Le authenticator signe le challenge avec la clé privée
- Le navigateur envoie assertion signée au serveur
- 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-demoInstallez les dépendances :
npm install @simplewebauthn/server @simplewebauthn/browser
npm install @prisma/client
npm install -D prisma @simplewebauthn/typesVoici 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 postgresqlMettez à 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 initCré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 devOuvrez http://localhost:3000/auth et testez :
- Création — Entrez votre email, cliquez "Créer une Passkey", authentifiez avec Touch ID / Windows Hello
- Connexion — Cliquez "Se connecter avec Passkey" et vérifiez avec la biométrie
- Tableau de bord — Vérifiez que vos passkeys enregistrées apparaissent
- 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 3000WebAuthn 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_IDconfiguré avec votre domaine de production -
WEBAUTHN_ORIGINconfiguré 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.
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

Better Auth avec Next.js 15 : Le Guide Complet d'Authentification pour 2026
Apprenez à implémenter une authentification complète dans Next.js 15 avec Better Auth. Ce tutoriel couvre email/mot de passe, OAuth, sessions, protection des routes et contrôle d'accès basé sur les rôles.

Authentifier votre application Next.js 15 avec Auth.js v5 : Email, OAuth et contrôle des rôles
Apprenez à ajouter une authentification prête pour la production à votre application Next.js 15 avec Auth.js v5. Ce guide complet couvre Google OAuth, les identifiants email/mot de passe, les routes protégées, le middleware et le contrôle d'accès basé sur les rôles.

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.