Vai al contenuto principale
Security

Passkeys e WebAuthn: Guida all'Implementazione in Produzione per l'Autenticazione FIDO2 Senza Password

7 min lettura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Disponibile anche in: English, Español

Il Modello Mentale che la Maggior Parte Sbaglia

Tutti spiegano WebAuthn come "chiavi SSH ma per il web." È vicino ma fuorviante. Le chiavi SSH stanno nella vostra directory ~/.ssh e le gestite manualmente. Le passkey sono gestite dalla piattaforma — il vostro OS, il browser, il password manager. Non vedete la chiave privata. Non la esportate. Non ci fate chmod 600.

La cerimonia (sì, è il termine effettivo della spec) funziona così:

  1. Registrazione: Il server invia una challenge. Il browser chiede all'authenticator di creare una coppia di chiavi. L'authenticator conserva la chiave privata e restituisce la chiave pubblica + credential ID al server.
  2. Autenticazione: Il server invia una challenge + il credential ID. Il browser trova l'authenticator corrispondente, che firma la challenge. Il server verifica la firma.

Tutto qui. Nessun segreto condiviso. Nessun hash di password. Nessun seed TOTP. La chiave privata non lascia mai l'hardware dell'authenticator.

Configurare il Server: SimpleWebAuthn

La libreria @simplewebauthn/server è una scelta pratica per evitare di parsare CBOR manualmente. Ecco il setup di registrazione:

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

const rpName = 'La Mia App';
const rpID = 'miaapp.com';
const origin = `https://${rpID}`;

async function handleRegistrationStart(user) {
 // Recuperiamo le credenziali esistenti per poterle escludere
 const existingCreds = await db.credentials.findByUserId(user.id);

 const options = await generateRegistrationOptions({
 rpName,
 rpID,
 userID: user.id,
 userName: user.email,
 // Previene la ri-registrazione dello stesso authenticator
 excludeCredentials: existingCreds.map(cred => ({
 id: cred.credentialID,
 type: 'public-key',
 transports: cred.transports,
 })),
 authenticatorSelection: {
 residentKey: 'required',
 userVerification: 'preferred',
 },
 // ES256 (P-256) prima, poi RS256 come fallback
 supportedAlgorithmIDs: [-7, -257],
 });

 await sessionStore.set(user.id, {
 currentChallenge: options.challenge,
 });

 return options;
}

Il residentKey: 'required' è cruciale. È ciò che lo rende una "passkey" piuttosto che una semplice credenziale WebAuthn. Le resident key (chiamate anche discoverable credentials) sono conservate sull'authenticator stesso, il che significa che l'utente non deve fornire un username prima.

Il Lato Client: Dove le Cose si Fanno Interessanti

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

async function register() {
 const optionsRes = await fetch('/api/auth/register/start', {
 method: 'POST',
 credentials: 'include',
 });
 const options = await optionsRes.json();

 let attestationResponse;
 try {
 attestationResponse = await startRegistration(options);
 } catch (err) {
 if (err.name === 'InvalidStateError') {
 showError('Questo dispositivo è già registrato.');
 return;
 }
 if (err.name === 'NotAllowedError') {
 showError('La registrazione è stata annullata.');
 return;
 }
 throw err;
 }

 const verifyRes = await fetch('/api/auth/register/finish', {
 method: 'POST',
 headers: { 'Content-Type': 'application/json' },
 body: JSON.stringify(attestationResponse),
 credentials: 'include',
 });

 const result = await verifyRes.json();
 if (result.verified) {
 showSuccess('Passkey registrata!');
 }
}

La gestione degli errori qui non è opzionale. NotAllowedError scatta quando l'utente chiude il prompt del browser, e se non lo gestite con grazia, riceverete un muro di bug report da persone che hanno premuto Escape per sbaglio.

Verifica: La Parte che Non Potete Sbagliare

async function handleRegistrationFinish(user, body) {
 const session = await sessionStore.get(user.id);
 const expectedChallenge = session.currentChallenge;

 const verification = await verifyRegistrationResponse({
 response: body,
 expectedChallenge,
 expectedOrigin: origin,
 expectedRPID: rpID,
 requireUserVerification: false,
 });

 const { verified, registrationInfo } = verification;

 if (verified && registrationInfo) {
 const {
 credentialPublicKey,
 credentialID,
 counter,
 credentialDeviceType,
 credentialBackedUp,
 } = registrationInfo;

 await db.credentials.create({
 credentialID: Buffer.from(credentialID).toString('base64url'),
 credentialPublicKey: Buffer.from(credentialPublicKey),
 counter,
 transports: body.response.transports || [],
 userId: user.id,
 deviceType: credentialDeviceType,
 backedUp: credentialBackedUp,
 createdAt: new Date(),
 });
 }

 return { verified };
}

Notate credentialDeviceType e credentialBackedUp. Vengono dal byte dei flag negli authenticator data e vi dicono qualcosa di importante: se questa passkey è sincronizzata tra dispositivi. Una credenziale multiDevice + backedUp: true vive in iCloud Keychain o Google Password Manager. Una singleDevice + backedUp: false è bloccata su una chiave hardware.

Autenticazione Cross-Device: Il Trasporto Hybrid

Qui la cosa diventa seria. Supponiamo che un utente abbia registrato la passkey sul suo iPhone e ora sia seduto a un desktop Windows. La spec FIDO2 definisce un trasporto "hybrid" (precedentemente chiamato caBLE):

  1. Il browser del desktop mostra un QR code
  2. L'utente lo scansiona col telefono
  3. Telefono e desktop stabiliscono un tunnel BLE
  4. Il telefono richiede l'autenticazione biometrica
  5. L'assertion firmata viaggia attraverso il tunnel verso il desktop

Non lo implementate voi. Browser e OS gestiscono il livello di trasporto. Ma dovete sapere che esiste perché:

  • È lento. L'handshake BLE aggiunge 3-8 secondi al flusso normale.
  • È fragile. Il Bluetooth è il Bluetooth. I dati di analytics mostrano che fallisce circa il 12% dei primi tentativi.
  • Gli utenti non capiscono perché il telefono vibra quando stanno cercando di fare il login sul laptop.

L'approccio raccomandato: quando un flusso cross-device è probabile, mostrare una breve UI esplicativa prima di attivare la cerimonia WebAuthn.

La Questione dell'Attestation

La raccomandazione pratica, basata su molteplici implementazioni in produzione: non verificare l'attestation a meno che non ci sia un obbligo legale.

Per il 95% delle applicazioni, attestation: 'none' è la scelta giusta. Ottenete comunque la piena sicurezza crittografica del flusso challenge-response. Semplicemente non sapete se è una YubiKey 5C o un sensore integrato del Pixel 8, e per la maggior parte dei casi d'uso, non vi interessa.

La Migrazione: Coesistenza Password + Passkey

Le password non possono essere semplicemente eliminate. Ecco un approccio per fasi collaudato in produzione:

Fase 1: Aggiungete la registrazione passkey nelle impostazioni account. Mantenete il login con password come primario.

Fase 2: Dopo il login, suggerite agli utenti solo-password di registrare una passkey. Un suggerimento gentile, non un blocco.

Fase 3: Una volta che l'utente ha una passkey, fatela diventare il metodo di login predefinito.

Fase 4: Per gli utenti con passkey, iniziate a richiederla per operazioni sensibili.

async function requirePasskeyEscalation(req, res, next) {
 const user = req.user;
 const hasPasskey = await db.credentials.existsForUser(user.id);

 if (!hasPasskey) return next();

 const lastPasskeyAuth = req.session.lastPasskeyVerification;
 const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;

 if (!lastPasskeyAuth || lastPasskeyAuth < fiveMinutesAgo) {
 return res.status(403).json({
 error: 'passkey_required',
 message: 'Verificate con la vostra passkey per continuare.',
 });
 }

 next();
}

Problemi di Produzione di cui Nessuno vi Avverte

Il problema dell'RP ID: Il vostro RP ID è tipicamente il vostro dominio. Se registrate passkey su app.miosito.com, non funzioneranno su miosito.com o api.miosito.com. Scegliete l'RP ID con cura — potete impostarlo al dominio registrabile (miosito.com) che funzionerà per tutti i sottodomini, ma non potete cambiarlo dopo senza invalidare tutte le passkey esistenti.

Semantica del counter: I passkey sincronizzati spesso hanno un counter di 0 che non incrementa mai. SimpleWebAuthn gestisce questo bene, ma se state facendo la vostra implementazione, non trattate un counter a zero come sospetto.

L'opinione di Safari: Safari su macOS spingerà aggressivamente gli utenti verso iCloud Keychain anche quando hanno una security key hardware collegata. L'unico modo per aggirarlo è la conditional UI:

if (await PublicKeyCredential.isConditionalMediationAvailable()) {
 const options = await fetchAuthOptions();
 const assertion = await navigator.credentials.get({
 publicKey: options,
 mediation: 'conditional',
 });
 await verifyOnServer(assertion);
}

Metriche Dopo 6 Mesi

Dopo il rollout a ~40k utenti:

  • Il 62% degli utenti attivi ha registrato almeno una passkey
  • Il tempo medio di autenticazione è sceso da 14 secondi (email + password + 2FA) a 3 secondi
  • I ticket di blocco account sono diminuiti del 78%
  • Zero incidenti di credential stuffing (prima ~2/mese che colpivano il WAF)

L'impatto business di un login più veloce è difficile da sopravvalutare. I dati di produzione mostrano un aumento misurabile nella frequenza delle sessioni — le persone fanno più login quando non sembra una seccatura.

Dove Sta Andando

La spec sta evolvendo rapidamente. La bozza di WebAuthn Level 3 include signal API per la gestione del ciclo di vita delle credenziali. Le passkey non sono perfette. Il flusso cross-device è macchinoso. Il recupero account è un problema irrisolto. Ma il modello di sicurezza è fondamentalmente solido, la UX è genuinamente migliore per la maggior parte degli utenti, e il momentum dell'ecosistema è reale.

Iniziate con @simplewebauthn/server, distribuite le passkey come secondo fattore opzionale, misurate l'adozione e iterate. La parte difficile non è la crittografia — è la UX di migrazione. E questo è un problema molto migliore di un altro incidente di credential stuffing alle 3 di notte.

passkeyswebauthnfido2passwordlessautenticazionesicurezzacrittografia

Strumenti menzionati in questo articolo

Auth0Prova Auth0
ClerkProva Clerk
Divulgazione: Alcuni link in questo articolo sono link di affiliazione. Se ti registri tramite questi, potrei guadagnare una commissione senza costi aggiuntivi per te. Raccomando solo strumenti che uso e di cui mi fido personalmente.
Condividi
Seguime