Nel 2026 le password stanno diventando un problema che le aziende non possono più permettersi. Il catalogo CISA KEV ha superato le 1.484 vulnerabilità note come sfruttate, con una crescita del 20% nel solo 2025, e una parte rilevante di questi attacchi passa ancora da credenziali rubate o riutilizzate. Le passkey, basate sullo standard FIDO2/WebAuthn, offrono un’alternativa che elimina il problema alla radice: niente password da rubare, niente phishing che funziona, perché non esiste più un segreto condiviso che un aggressore possa intercettare o replicare su un sito falso.

In questo tutorial costruiamo da zero un sistema di autenticazione passwordless funzionante, passo dopo passo, con Node.js ed Express. Alla fine avrai un progetto completo che gira in locale, un login con passkey testabile dal tuo browser, e la conoscenza per adattarlo a un’app reale o per guidare la migrazione di un intero team aziendale verso FIDO2. Copriamo anche gli errori più comuni, il troubleshooting pratico e come scegliere tra le implementazioni enterprise già pronte (Entra ID, Okta, Google Workspace) quando scrivere il codice da zero non è la strada giusta.

Perché nel 2026 conviene passare alle passkey

Il report State of Passwordless Authentication 2026 segna una crescita del 412% nell’uso delle passkey durante il 2025, con l’autenticazione FIDO2/WebAuthn che ora rappresenta circa il 15,7% di tutti gli eventi di login analizzati, tempi medi sotto il secondo e tassi di successo vicini al 99%. Non è più una tecnologia di nicchia per early adopter: secondo lo State of Passkeys 2026: Global Consumer and Workforce Report, il 90% dei consumatori conosce le passkey e il 75% le ha già attivate su almeno un servizio.

Sul fronte aziendale i numeri sono altrettanto netti. Uno studio condotto da FIDO Alliance e HID su 400 dirigenti di aziende con oltre 500 dipendenti riporta che l’87% delle organizzazioni ha già distribuito le passkey per i propri dipendenti oppure è in fase di rollout, con un incremento di 14 punti percentuali rispetto all’anno precedente. Circa due terzi dei rispondenti classifica il progetto come priorità alta o critica. Anche i vendor di password manager confermano il trend: Bitwarden ha registrato un aumento del 550% nella creazione giornaliera di passkey durante il 2025.

Per un team di sviluppo o di sicurezza, questo significa una cosa concreta: prima o poi qualcuno chiederà di aggiungere il login con passkey a un prodotto, oppure di migrare l’accesso interno via FIDO2. Questo tutorial copre entrambi gli scenari, dalla teoria dello standard alla scrittura del codice, fino alla gestione in produzione e alla conformità con le normative europee.

Cos’è una passkey: come funziona lo standard FIDO2/WebAuthn

Una passkey è una coppia di chiavi crittografiche asimmetriche. La chiave privata resta sempre sul dispositivo dell’utente (o nel suo portachiavi cloud cifrato), mentre la chiave pubblica viene inviata al server durante la registrazione. Al momento del login, il server invia una sfida casuale (challenge), il dispositivo la firma con la chiave privata e il server verifica la firma con la chiave pubblica già salvata. Non viene mai trasmesso nulla che possa essere rubato e riusato altrove: anche se un aggressore compromettesse per intero il database del server, otterrebbe solo chiavi pubbliche, che per definizione non permettono di autenticarsi da sole. È la differenza fondamentale rispetto a un database di password, anche se hashate correttamente con Argon2 o bcrypt: lì il furto dei dati resta comunque un rischio da mitigare, qui semplicemente non esiste nulla di utile da rubare lato server.

Il meccanismo è definito dallo standard WebAuthn del W3C (oggi alla versione Level 3) insieme al protocollo CTAP2 della FIDO Alliance, che regola la comunicazione tra browser e autenticatore (biometria del telefono, Windows Hello, chiave hardware USB). La combinazione dei due è comunemente chiamata FIDO2. Un dettaglio che rende le passkey resistenti al phishing è il legame con l’origine (origin binding): la firma generata dal dispositivo è valida solo per il dominio esatto per cui è stata creata, quindi un sito clone usato per il phishing non riceverà mai una firma valida, anche se l’utente ci casca.

Quasi la metà dei 100 siti web più visitati al mondo supporta oggi le passkey come metodo di accesso, più del doppio rispetto al 2022, secondo i dati aggregati nel Passkey Index della FIDO Alliance. Tra i nomi coinvolti ci sono Amazon, Google, Microsoft, PayPal e TikTok. La documentazione di riferimento per chi implementa lo standard si trova su passkeys.dev e sulla pagina ufficiale FIDO Alliance.

Authenticator platform vs roaming: chi genera la firma

Lo standard distingue due categorie di autenticatori. Un authenticator platform è integrato nel dispositivo stesso: il chip biometrico di uno smartphone, il modulo TPM di un laptop Windows, il Secure Enclave di un Mac. Un authenticator roaming è invece un dispositivo esterno, tipicamente una chiave USB o NFC come YubiKey, che si collega al computer solo al momento del login. Nel nostro progetto non facciamo distinzione tra i due lato server: l’API WebAuthn del browser espone entrambi allo stesso modo tramite navigator.credentials, ed è il sistema operativo a decidere quale mostrare all’utente nel prompt di selezione.

Passkey sincronizzate vs device-bound: la scelta giusta per la tua azienda

Esistono due famiglie di passkey e la differenza conta molto quando si progetta un sistema di autenticazione. Le passkey sincronizzate (sync) vengono replicate tramite un portachiavi cloud cifrato, come iCloud Keychain, Google Password Manager o un password manager di terze parti tipo Bitwarden. Le passkey device-bound restano vincolate a un singolo dispositivo o a una chiave hardware fisica (YubiKey, Titan Security Key) e non escono mai da quell’elemento sicuro.

Le aziende europee non stanno scegliendo un’opzione unica: il 47% adotta una strategia ibrida, usando passkey sincronizzate per la forza lavoro standard (più comode, si ripristinano automaticamente su un nuovo dispositivo) e passkey device-bound per i ruoli privilegiati come amministratori di sistema o personale IT con accesso a infrastrutture critiche, dove il rischio di compromissione dell’account cloud deve restare isolato dal singolo dispositivo fisico.

CaratteristicaPasskey sincronizzatePasskey device-bound
Dove vive la chiave privataPortachiavi cloud cifrato (multi-dispositivo)Singolo dispositivo o chiave hardware
Recupero in caso di perdita del dispositivoAutomatico via account cloudRichiede una passkey di backup registrata
Superficie di attaccoLegata alla sicurezza dell’account cloudLimitata al possesso fisico del dispositivo
Caso d’uso consigliatoUtenti standard, workforce generaleAccount privilegiati, ambienti regolamentati
EsempiiCloud Keychain, Google Password Manager, BitwardenYubiKey, Titan Security Key, TPM aziendale

Per il nostro progetto dimostrativo useremo l’implementazione lato server più diffusa nell’ecosistema Node.js, che supporta entrambe le famiglie senza distinzioni particolari nel codice: la scelta tra sync e device-bound avviene lato browser/sistema operativo, non lato server.

Prerequisiti: strumenti, versioni e competenze richieste

Prima di iniziare, verifica di avere l’ambiente giusto. Il progetto usa la libreria open source SimpleWebAuthn, oggi lo standard de facto per implementare FIDO2/WebAuthn in JavaScript, insieme a Express per il backend.

StrumentoVersione usata in questo tutorialNote
Node.js24.19.0 LTS (nome in codice “Krypton”)Usa la versione LTS, non quella corrente instabile
npmIncluso con Node.js 24Verifica con npm -v
Express5.2.1Framework HTTP minimale per il backend
@simplewebauthn/server13.3.3Genera e verifica le opzioni WebAuthn lato server
@simplewebauthn/browser13.3.0Wrapper client per l’API WebAuthn del browser
Browser di testChrome, Edge, Safari o Firefox aggiornatiTutti supportano WebAuthn Level 2/3
Connessione HTTPS localemkcert o ngrokWebAuthn richiede un contesto sicuro, anche in locale

Servono inoltre conoscenze base di JavaScript asincrono (Promise/async-await), familiarità con le API REST e un editor di codice. Non è richiesta esperienza pregressa con la crittografia a chiave pubblica: la libreria si occupa di tutta la parte matematica, il nostro compito è orchestrare correttamente le chiamate.

Il progetto: cosa costruiamo in questo tutorial

Costruiremo un piccolo servizio di autenticazione passwordless completo: un backend Express che espone quattro endpoint (opzioni di registrazione, verifica registrazione, opzioni di login, verifica login) e un frontend statico in JavaScript puro che richiama l’API WebAuthn del browser. Per semplicità useremo uno store in memoria al posto di un database vero: in produzione andrà sostituito con una tabella reale (utenti, credenziali, contatore), ma la logica di autenticazione resta identica.

Il percorso è diviso in 12 passi operativi, raggruppati in sei blocchi:

  1. Creare la struttura del progetto
  2. Installare le dipendenze
  3. Configurare il Relying Party (RP)
  4. Generare le opzioni di registrazione
  5. Creare l’endpoint di verifica della registrazione
  6. Salvare la credenziale pubblica dell’utente
  7. Costruire la pagina frontend
  8. Avviare la registrazione WebAuthn dal browser
  9. Generare le opzioni di autenticazione (login)
  10. Verificare la risposta di login e aggiornare il contatore
  11. Gestire più dispositivi per lo stesso utente
  12. Implementare la revoca di una passkey

Prima di passare al codice, due avvertenze sulla differenza tra questo progetto didattico e un sistema pronto per la produzione. Primo: lo store Map() in memoria va sostituito con un database reale (PostgreSQL, MySQL o anche solo SQLite per progetti piccoli), con la tabella delle credenziali che salva id, publicKey, counter e un riferimento all’utente, oltre a un indice sull’id della credenziale per le query di login. Secondo: le challenge temporanee, che qui teniamo in una seconda Map(), in un ambiente con più istanze del server dietro un load balancer devono vivere in uno store condiviso come Redis, con una scadenza (TTL) di pochi minuti, altrimenti una richiesta di verifica gestita da un’istanza diversa da quella che ha generato la challenge fallirà sempre.

Vale anche la pena applicare rate limiting sugli endpoint /register/options e /login/options: anche se WebAuthn non è vulnerabile al credential stuffing nel senso tradizionale, un attacco automatizzato che genera migliaia di username casuali può comunque saturare il database di challenge temporanee ed è un pattern di attacco banale da bloccare con un middleware come express-rate-limit.

Passo 1 e 2: struttura del progetto e installazione delle dipendenze

Crea una cartella per il progetto e inizializza il pacchetto npm. La struttura finale sarà composta da un file server, una cartella public per il frontend statico e un file di configurazione.

mkdir passkey-demo && cd passkey-demo
npm init -y
npm install [email protected] @simplewebauthn/[email protected]
npm install --save-dev nodemon

mkdir public
touch server.js public/index.html public/app.js

Il pacchetto @simplewebauthn/browser non va installato via npm sul server: verrà caricato direttamente nel frontend tramite un bundle o un CDN, dato che gira nel browser dell’utente finale. La struttura finale del progetto è questa:

passkey-demo/
├── server.js
├── package.json
└── public/
    ├── index.html
    └── app.js

Passo 3 e 4: configurare il Relying Party e generare le opzioni di registrazione

Il Relying Party (RP) è semplicemente il tuo servizio: la sua identità è definita da un nome e da un rpID, che deve corrispondere esattamente al dominio (senza protocollo né porta) su cui gira l’applicazione. Questo è il primo punto in cui la maggior parte degli sviluppatori sbaglia, quindi tienilo a mente per il capitolo dedicato agli errori comuni più avanti.

// server.js
const express = require('express');
const {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} = require('@simplewebauthn/server');

const app = express();
app.use(express.json());
app.use(express.static('public'));

const rpName = 'Passkey Demo';
const rpID = 'localhost'; // in produzione: il dominio esatto, es. app.tuosito.it
const origin = `https://${rpID}:3000`;

// Store in memoria - sostituire con un database reale in produzione
const users = new Map(); // username -> { id, credentials: [] }
const challenges = new Map(); // username -> challenge temporanea

app.post('/register/options', async (req, res) => {
  const { username } = req.body;
  let user = users.get(username);
  if (!user) {
    user = { id: crypto.randomUUID(), username, credentials: [] };
    users.set(username, user);
  }

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: username,
    attestationType: 'none', // 'none' preserva la privacy dell'utente
    excludeCredentials: user.credentials.map((c) => ({
      id: c.id,
      transports: c.transports,
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
  });

  challenges.set(username, options.challenge);
  res.json(options);
});

Nota l’uso di attestationType: 'none': chiedere l’attestazione completa del dispositivo (che rivela marca e modello dell’autenticatore) è utile solo in scenari altamente regolamentati. Per la maggior parte delle applicazioni consumer, richiederla peggiora l’esperienza utente senza benefici reali di sicurezza.

Se richiami l’endpoint con un client REST prima ancora di collegare il frontend, l’output atteso è un oggetto JSON pronto per essere passato tale e quale alla libreria browser:

{
  "rp": { "name": "Passkey Demo", "id": "localhost" },
  "user": {
    "id": "d3f4b2a1-...",
    "name": "mario.rossi",
    "displayName": "mario.rossi"
  },
  "challenge": "Y2hhbGxlbmdlLXJhbmRvbS1iYXNlNjR1cmw",
  "pubKeyCredParams": [
    { "alg": -7, "type": "public-key" },
    { "alg": -257, "type": "public-key" }
  ],
  "timeout": 60000,
  "attestation": "none",
  "excludeCredentials": [],
  "authenticatorSelection": {
    "residentKey": "preferred",
    "userVerification": "preferred"
  }
}

I due algoritmi in pubKeyCredParams (-7 ed -257) corrispondono rispettivamente a ECDSA con curva P-256 e RSASSA-PKCS1-v1_5: sono i due schemi di firma più supportati dagli autenticatori in circolazione nel 2026, ed è buona norma includerli entrambi per massimizzare la compatibilità.

Passo 5 e 6: endpoint di verifica e salvataggio della credenziale

Quando il browser restituisce la risposta firmata, il server deve verificarla confrontandola con la challenge emessa in precedenza e con l’origin atteso, poi salvare la chiave pubblica e il contatore iniziale.

app.post('/register/verify', async (req, res) => {
  const { username, response } = req.body;
  const user = users.get(username);
  const expectedChallenge = challenges.get(username);

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

    if (verification.verified) {
      const { credential } = verification.registrationInfo;
      user.credentials.push({
        id: credential.id,
        publicKey: credential.publicKey,
        counter: credential.counter,
        transports: response.response.transports,
      });
      challenges.delete(username);
      return res.json({ verified: true });
    }
    res.status(400).json({ verified: false });
  } catch (error) {
    console.error('Errore di verifica registrazione:', error.message);
    res.status(400).json({ verified: false, error: error.message });
  }
});

Il counter salvato qui è cruciale: ogni autenticatore lo incrementa a ogni utilizzo, e serve al server per rilevare eventuali cloni della chiave privata (un caso raro ma che lo standard prevede esplicitamente). Ne parliamo più a fondo nella sezione sul troubleshooting. In caso di successo, la risposta del server è minimale di proposito:

// Output atteso su registrazione riuscita
{ "verified": true }

// Output atteso su challenge scaduta o firma non valida
{ "verified": false, "error": "Registration verification failed" }

Evita di restituire dettagli tecnici granulari sull’errore al client in produzione (tipo di algoritmo atteso, contenuto della challenge): sono informazioni utili solo per il debug interno e vanno loggate lato server, non esposte nella risposta HTTP.

Passo 7 e 8: frontend, avviare la registrazione WebAuthn nel browser

Sul lato client, la libreria @simplewebauthn/browser nasconde la complessità delle API native navigator.credentials, gestendo anche le conversioni base64url che altrimenti andrebbero scritte a mano.

// public/app.js
import { startRegistration } from 'https://unpkg.com/@simplewebauthn/[email protected]/dist/bundle/index.js';

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

  let attestationResponse;
  try {
    attestationResponse = await startRegistration({ optionsJSON: options });
  } catch (error) {
    if (error.name === 'InvalidStateError') {
      alert('Una passkey per questo account esiste già su questo dispositivo.');
    } else {
      alert('Registrazione annullata o non supportata: ' + error.message);
    }
    return;
  }

  const verifyResp = await fetch('/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, response: attestationResponse }),
  });
  const result = await verifyResp.json();
  console.log('Passkey registrata:', result.verified);
}

La chiamata a startRegistration è quella che apre il prompt nativo del sistema operativo: Touch ID su Mac, Windows Hello su PC, l’impronta o il volto su Android, oppure la richiesta di inserire una chiave di sicurezza fisica. Tutta questa interfaccia è gestita dal browser, non dal tuo codice.

Passo 9 e 10: login passwordless end-to-end

Il flusso di login ricalca quello di registrazione ma è più snello: non serve creare una nuova credenziale, basta dimostrare il possesso di quella già registrata firmando una nuova challenge.

app.post('/login/options', async (req, res) => {
  const { username } = req.body;
  const user = users.get(username);
  if (!user) return res.status(404).json({ error: 'Utente non trovato' });

  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: user.credentials.map((c) => ({
      id: c.id,
      transports: c.transports,
    })),
    userVerification: 'preferred',
  });

  challenges.set(username, options.challenge);
  res.json(options);
});

app.post('/login/verify', async (req, res) => {
  const { username, response } = req.body;
  const user = users.get(username);
  const expectedChallenge = challenges.get(username);
  const credential = user.credentials.find((c) => c.id === response.id);

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential,
  });

  if (verification.verified) {
    credential.counter = verification.authenticationInfo.newCounter;
    challenges.delete(username);
    return res.json({ verified: true });
  }
  res.status(400).json({ verified: false });
});

Aggiornare credential.counter dopo ogni login riuscito non è opzionale: se il nuovo contatore ricevuto dal dispositivo fosse minore o uguale a quello salvato, la libreria solleva un errore, perché è il segnale tipico di una chiave privata clonata o di un attacco replay.

Passo 11 e 12: gestione multi-dispositivo e revoca delle chiavi

Un utente reale ha più dispositivi: telefono, laptop di lavoro, laptop personale. Il nostro schema dati già supporta più credenziali per utente grazie all’array credentials, quindi basta permettere di ripetere il flusso di registrazione da un dispositivo diverso mentre si è già autenticati (non da anonimi, altrimenti chiunque potrebbe aggiungere una propria chiave all’account di un altro).

// Richiede una sessione già autenticata (middleware omesso per brevità)
app.delete('/credentials/:credentialId', requireAuth, (req, res) => {
  const user = users.get(req.session.username);
  const before = user.credentials.length;

  user.credentials = user.credentials.filter(
    (c) => c.id !== req.params.credentialId
  );

  if (user.credentials.length === before) {
    return res.status(404).json({ error: 'Credenziale non trovata' });
  }
  if (user.credentials.length === 0) {
    console.warn(`Attenzione: ${req.session.username} non ha più passkey attive`);
  }
  res.json({ removed: true, remaining: user.credentials.length });
});

La revoca è il pezzo che nella pratica viene dimenticato più spesso, ma è essenziale: se un dipendente perde il telefono, l’amministratore deve poter invalidare quella specifica passkey senza toccare le altre. Un buon runbook di incident response aziendale, allineato ai playbook ENISA su “secure by design and default”, include sempre questo passaggio come procedura standard in caso di smarrimento dispositivo.

Avviare e testare il progetto completo

A questo punto il backend espone già tutta la logica necessaria. Manca solo un’interfaccia minima e un server HTTPS locale, dato che l’API WebAuthn del browser rifiuta di funzionare fuori da un contesto sicuro (con l’unica eccezione di localhost in HTTP puro, comoda ma non sempre disponibile a seconda del setup).

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="it">
<head>
  <meta charset="UTF-8" />
  <title>Passkey Demo</title>
</head>
<body>
  <h1>Accesso passwordless</h1>
  <input id="username" autocomplete="username webauthn" placeholder="Nome utente" />
  <button id="register-btn">Registra passkey</button>
  <button id="login-btn">Accedi con passkey</button>
  <p id="status"></p>
  <script type="module" src="/app.js"></script>
</body>
</html>

Per generare un certificato HTTPS valido in locale, lo strumento più semplice è mkcert. Genera il certificato, poi aggiorna server.js per usare https invece del server HTTP di default di Express, e aggiungi gli script in package.json per l’avvio in sviluppo.

mkcert -install
mkcert localhost

// package.json (estratto)
"scripts": {
  "dev": "nodemon server.js"
}

// server.js (in fondo al file, al posto di app.listen)
const https = require('https');
const fs = require('fs');

https.createServer({
  key: fs.readFileSync('localhost-key.pem'),
  cert: fs.readFileSync('localhost.pem'),
}, app).listen(3000, () => {
  console.log('Passkey demo attiva su https://localhost:3000');
});

Avvia il progetto con npm run dev. Se tutto è configurato correttamente, il terminale mostra un output simile a questo:

$ npm run dev

> [email protected] dev
> nodemon server.js

[nodemon] starting `node server.js`
Passkey demo attiva su https://localhost:3000
[nodemon] watching path(s): *.*

Apri https://localhost:3000 nel browser, accetta il certificato locale se richiesto, inserisci un nome utente e premi “Registra passkey”: il sistema operativo mostrerà il prompt biometrico nativo. Una volta completata la registrazione, ricarica la pagina e premi “Accedi con passkey” per verificare l’intero ciclo end-to-end. Se preferisci testare gli endpoint senza passare dal browser, puoi comunque ispezionare la forma della risposta con una chiamata diretta:

curl -k -X POST https://localhost:3000/register/options \
  -H "Content-Type: application/json" \
  -d '{"username":"mario.rossi"}'

# Output: un oggetto JSON con rp, user, challenge e pubKeyCredParams
# (la risposta completa firmata dall'autenticatore si ottiene solo dal browser,
# perché richiede l'interazione con l'hardware dell'utente)

Errori comuni da evitare durante la migrazione a passkey

Prima di passare al troubleshooting vero e proprio, ecco gli errori che nella pratica causano il maggior numero di ticket di supporto e bug report.

  • rpID diverso dal dominio effettivo. Se l’app gira su app.tuosito.it ma il codice imposta rpID: 'tuosito.it' senza sottodominio configurato correttamente, la verifica fallisce silenziosamente in produzione pur funzionando in locale.
  • Testare via HTTP invece di HTTPS. L’API WebAuthn richiede un “contesto sicuro”: funziona su localhost anche in HTTP per comodità di sviluppo, ma su qualsiasi altro host serve HTTPS reale, altrimenti navigator.credentials non è nemmeno disponibile.
  • Un solo punto di recupero per utente. Registrare una sola passkey senza offrire un metodo di backup (una seconda passkey, un codice di recupero monouso) trasforma la perdita del dispositivo in un blocco totale dell’account.
  • Ignorare il contatore delle credenziali. Alcuni autenticatori (soprattutto passkey sincronizzate multi-dispositivo) mantengono il contatore fisso a zero per design: un controllo troppo rigido sul contatore genera falsi positivi di clonazione e blocca utenti legittimi.
  • Forzare l’attestazione diretta senza motivo. Richiedere attestationType: 'direct' espone dati sul dispositivo dell’utente che nella grande maggioranza dei casi non servono e sollevano dubbi di privacy inutili.
  • Non gestire il fallback per dispositivi non compatibili. Una minoranza di utenti userà browser datati o dispositivi aziendali con criteri MDM restrittivi: senza un metodo alternativo (magic link via email, TOTP), quegli utenti restano fuori.

Nessuno di questi errori richiede una riscrittura del sistema: sono tutti correggibili con piccole modifiche alla configurazione o al flusso di onboarding, a patto di individuarli prima del rilascio in produzione e non dopo le prime segnalazioni degli utenti. Vale la pena tenerli a mente anche in ottica OWASP Top 10: un flusso di autenticazione mal configurato resta uno dei vettori più sfruttati, passkey o meno.

Troubleshooting: problemi frequenti e soluzioni

Anche seguendo tutti i passaggi correttamente, capiterà di incontrare errori nei log del browser o del server. Ecco una lista pratica di riferimento.

SintomoCausa più probabileSoluzione
Errore “origin mismatch” in fase di verificaL’origin configurato lato server non coincide con l’URL reale del browser (porta o protocollo diversi)Allinea expectedOrigin esattamente all’URL visibile nella barra indirizzi, porta inclusa
NotAllowedError nel browserL’utente ha annullato il prompt, oppure è scaduto il timeout (default 60 secondi)Aumenta il timeout nelle opzioni e mostra un messaggio d’errore chiaro invece di un fallimento silenzioso
La passkey non compare tra i dispositivi disponibiliSincronizzazione iCloud Keychain o Google Password Manager non ancora propagataAttendi qualche minuto oppure verifica che l’utente sia loggato con lo stesso account cloud su entrambi i dispositivi
API WebAuthn non definita (undefined)Pagina servita su HTTP invece di HTTPS, oppure browser non aggiornatoUsa mkcert in locale per un certificato HTTPS valido, aggiorna il browser
Errore di decodifica base64urlConversione manuale della challenge invece di usare le utility della libreriaLascia che sia SimpleWebAuthn a gestire le conversioni, non manipolare i buffer a mano
Falso positivo di “possibile clonazione” al loginContatore del nuovo login minore o uguale a quello salvatoSu passkey sincronizzate, considera di rilassare il controllo del contatore o ignorarlo se resta fisso a zero
Errori CORS tra frontend e backendFrontend e API servite da origin diversi durante lo sviluppoServi frontend e backend dallo stesso origin, oppure configura correttamente cors() con credenziali
Utente bloccato fuori dall’account dopo aver perso il telefonoNessuna passkey di backup né metodo di recupero alternativo registratoImplementa un secondo fattore di recupero (codici monouso) fin dal primo rilascio in produzione
L’autofill delle passkey (conditional UI) non si attivaManca l’attributo autocomplete="username webauthn" sul campo inputAggiungi l’attributo e richiama startAuthentication con useBrowserAutofill: true

Quando un errore non rientra in questa lista, il primo posto dove guardare è la console DevTools del browser: la maggior parte delle eccezioni sollevate dall’API WebAuthn arriva con un name standard (NotAllowedError, InvalidStateError, NotSupportedError, SecurityError) che identifica la categoria del problema molto più rapidamente di un messaggio generico lato server.

Passkey in azienda: Entra ID, Okta e Google Workspace a confronto

Se l’obiettivo non è costruire un login custom ma abilitare le passkey per l’accesso interno dei dipendenti, quasi certamente userai un identity provider già esistente invece di scrivere il flusso da zero. I tre principali player europei per adozione enterprise nel 2026 sono Microsoft Entra ID, Okta e Google Workspace, a cui si aggiungono provider specializzati come Ping Identity, ForgeRock, Auth0 e Descope per scenari più verticali. In tutti questi casi il codice che abbiamo scritto in questo tutorial resta comunque utile: capire come funziona il flusso a basso livello aiuta a interpretare correttamente log ed errori quando qualcosa nella console amministrativa del provider non torna, invece di trattare l’identity provider come una scatola nera.

PiattaformaSupporto passkeyNota per il deployment
Microsoft Entra IDNativo, incluso in Conditional AccessIntegra bene con Windows Hello for Business per i device aziendali
OktaNativo tramite Okta Verify e FastPassPolicy di enrollment granulari per gruppo o ruolo
Google WorkspaceLogin utenti e amministratoriEnforcement passwordless a livello di organizzazione

Checklist operativa per il rollout

Una guida tecnica italiana sulla migrazione FIDO2 in azienda suggerisce un percorso in quattro fasi: inventario delle applicazioni critiche e verifica della compatibilità WebAuthn, definizione della policy di enrollment (obbligatorio o facoltativo per fase iniziale), scelta tra passkey sincronizzate e device-bound in base al profilo di rischio del ruolo, e aggiornamento dei runbook di incident response per coprire perdita del dispositivo e revoca delle chiavi. Saltare l’ultimo punto è l’errore più comune osservato nei rollout falliti.

Consigli avanzati e conformità GDPR, ENISA e Cyber Resilience Act

Una volta che il flusso base funziona, ci sono alcune ottimizzazioni che fanno una differenza reale in produzione.

  • Conditional UI (autofill). Permette al browser di suggerire la passkey direttamente nel campo username, senza bisogno di un bottone “accedi con passkey” separato: riduce l’attrito e aumenta l’adozione.
  • Step-up authentication. Per operazioni ad alto rischio (bonifico, cambio password, export dati) richiedi una nuova verifica WebAuthn anche se la sessione è già attiva, invece di fidarti di un cookie di sessione lungo.
  • Estensione PRF di WebAuthn Level 3. Permette di derivare materiale crittografico dalla passkey stessa, utile per cifrare dati lato client senza gestire password aggiuntive.
  • Trasporto hybrid (QR code). Consente di autenticarsi su un desktop usando il telefono come autenticatore via Bluetooth, utile per postazioni condivise o kiosk.
  • Logging degli eventi di autenticazione. Registra ogni tentativo di registrazione e login (esito, timestamp, user agent) in un sistema separato dai dati della credenziale stessa: è la base per qualsiasi indagine di incident response futura e per individuare pattern anomali, come tentativi ripetuti di login falliti da IP diversi nello stesso breve intervallo.

Sul fronte normativo, l’adozione di FIDO2/WebAuthn si allinea bene con i principi “secure by design and default” promossi da ENISA e con lo spirito del Cyber Resilience Act europeo, che spinge produttori e sviluppatori verso un’autenticazione forte di default invece che come opzione avanzata. Dal punto di vista del Garante per la protezione dei dati personali, ricorda che l’attestationType: 'none' visto nel codice sopra non è solo una scelta tecnica: riduce anche la quantità di dati sul dispositivo dell’utente che il tuo backend riceve e deve trattare, semplificando la valutazione d’impatto sulla protezione dei dati per chi implementa il sistema.

Domande frequenti sulle passkey

Le passkey sostituiscono completamente le password?
Sul lato utente sì, non serve più digitare o ricordare nulla. Sul lato architetturale, molti sistemi mantengono comunque un metodo di recupero secondario (email, codici monouso) per i casi limite in cui l’utente perde tutti i dispositivi registrati.

Cosa succede se perdo il telefono con le mie passkey?
Se usi passkey sincronizzate (iCloud Keychain, Google Password Manager), basta accedere con lo stesso account cloud su un nuovo dispositivo per recuperarle. Se usi passkey device-bound senza backup, serve una seconda passkey già registrata o una procedura di recupero manuale lato servizio.

Le passkey funzionano offline?
La generazione della firma crittografica avviene in locale sul dispositivo e non richiede connessione, ma il server deve comunque essere raggiungibile per inviare la challenge e verificare la risposta, quindi il login in sé richiede rete.

Serve per forza una chiave hardware come YubiKey?
No. Le chiavi hardware sono una delle implementazioni possibili di FIDO2, ma smartphone e laptop moderni con biometria integrata (Touch ID, Windows Hello, impronta Android) funzionano altrettanto bene come autenticatori per l’uso quotidiano.

Le passkey sono compatibili con tutti i browser?
Le versioni recenti di Chrome, Edge, Safari e Firefox supportano WebAuthn Level 2/3. I problemi residui riguardano principalmente browser datati o ambienti aziendali con criteri di sicurezza che disabilitano l’API.

Cosa cambia rispetto a TOTP o alle app di autenticazione?
Il TOTP genera un codice numerico che tecnicamente può ancora essere intercettato o inserito su un sito di phishing (l’utente potrebbe non accorgersene). Le passkey sono legate crittograficamente al dominio esatto, quindi un sito clone non riceve mai una firma valida, anche se l’utente prova a autenticarsi.

Posso usare le passkey insieme a un secondo fattore aggiuntivo?
Sì, ma spesso non serve: le passkey già incorporano una verifica dell’utente (biometria o PIN del dispositivo) come parte del protocollo, quindi il modello di sicurezza è già multi-fattore per costruzione.

Quanto costa implementare le passkey rispetto a un sistema di password tradizionale?
Il costo di implementazione è comparabile a un sistema OAuth ben fatto, dato che librerie come SimpleWebAuthn sono gratuite e open source. Il risparmio reale arriva dopo il lancio, con la riduzione dei ticket di supporto legati a reset password e blocchi 2FA tradizionali riportata da diverse aziende che hanno già completato la migrazione.

Quali dati sulle passkey devo conservare per essere in regola con il GDPR?
Il server salva solo la chiave pubblica, un identificatore della credenziale e il contatore d’uso: nessun dato biometrico lascia mai il dispositivo dell’utente, perché l’impronta o il volto vengono usati solo localmente per sbloccare la chiave privata. Questo riduce sensibilmente la superficie di dati personali sensibili da trattare rispetto a un sistema che, per esempio, salva domande di sicurezza o numeri di telefono per l’SMS.

Posso migrare gradualmente invece di sostituire le password in un colpo solo?
Sì, ed è l’approccio che la maggior parte delle aziende sceglie: si offre la passkey come opzione aggiuntiva accanto alla password esistente, si misura il tasso di adozione per qualche mese, e solo dopo si valuta se rendere le passkey obbligatorie o se disattivare del tutto il login via password per i nuovi account.

Contenuti correlati