Autenticazione passwordless in Laravel: passkey, magic link e WebAuthn

Autenticazione passwordless in Laravel: passkey, magic link e WebAuthn

A ottobre 2025 ho ricevuto dal CTO di un'azienda SaaS del settore servizi legali italiano una lista di incident di sicurezza degli ultimi 12 mesi che aveva il seguente profilo: tre casi di password riutilizzate ed esposte in breach di servizi esterni (uno studio legale intero compromesso perché l'admin usava la stessa password dovunque), due casi di phishing riuscito che aveva portato alla compromissione di utenze finali (email fake che imitava perfettamente un avviso di scadenza fattura e che aveva catturato credenziali di 14 utenti in 36 ore), un caso di brute force distribuito che aveva saturato il sistema di rate limiting con 250.000 tentativi in una notte da 18.000 IP diversi. Nessuno di questi incidenti era stato catastrofico singolarmente, ma insieme avevano consumato circa 80 giornate-uomo di incident response e avevano eroso la fiducia dei clienti enterprise dello studio legale nel rinnovo contratti. La richiesta del CTO era pragmatica: "vogliamo uscire dal gioco delle password. Passkey, MFA rigoroso, qualcosa che elimini la causa radice di questi incident".

In otto settimane ho implementato un sistema di autenticazione passwordless nativo su Laravel 10, basato su passkey WebAuthn come metodo primario e magic link via email come fallback per i browser o dispositivi che non supportano WebAuthn. Il rollout è stato graduato: optional per tutti gli utenti per due mesi, obbligatorio per admin e gestori contratti al mese 3, obbligatorio per tutti gli utenti enterprise al mese 4. Al mese 6, il 78% degli utenti attivi usa passkey, il 18% magic link, il 4% ha richiesto esenzione contrattuale con MFA TOTP come fallback. Negli ultimi 180 giorni dal rollout completo: zero incidenti di credential stuffing (contro 250.000 tentativi al mese del periodo peggiore), zero phishing riuscito (perché passkey non può essere "phishata" per design), tre tentativi di attacco sofisticato rilevati che comunque non hanno prodotto compromissioni. Questo articolo descrive l'architettura, le scelte di design, e i trade-off onesti dell'autenticazione passwordless - incluso il suo limite principale, che non è tecnico ma è di UX e di gestione del supporto utenti.

Perché passkey è qualitativamente diverso dalle password con MFA

La differenza fra una password protetta da MFA e un passkey WebAuthn è strutturale, non incrementale. La password con MFA rimane vulnerabile a tre vettori che passkey elimina alla radice. Primo: il phishing. Un utente che riceve un'email convincente che lo porta a un sito falso inserisce password e codice MFA sul sito dell'attaccante, che a sua volta li usa in tempo reale sul sito reale per autenticarsi. Passkey è legato al dominio crittograficamente - il browser non invia mai il passkey a un dominio diverso da quello di registrazione, quindi il phishing non funziona. Secondo: il credential stuffing. Un attaccante che ha una lista di password e codici MFA (rubati da altri breach) prova credenziali su molti servizi. Passkey non viene mai "copiato" perché la chiave privata non lascia mai il dispositivo, quindi non esiste una lista di passkey rubabile. Terzo: il riuso di password. Passkey sono generati automaticamente e uniche per dominio, nessun utente può "riusare la stessa passkey ovunque".

WebAuthn è lo standard W3C che implementa passkey al livello browser. Il meccanismo è elegante: al momento della registrazione, il browser genera una coppia chiave pubblica/privata, mantiene la privata protetta nel secure enclave del dispositivo (Face ID/Touch ID su iOS, Windows Hello su Windows, password di sistema su Linux, YubiKey fisica come opzione avanzata), e invia al server solo la chiave pubblica. Al momento del login, il server invia una challenge random, il browser firma la challenge con la chiave privata (previa autenticazione biometrica dell'utente), il server verifica la firma con la chiave pubblica. Nessun segreto viaggia mai in rete oltre alla chiave pubblica iniziale. La specifica WebAuthn Level 3 pubblicata dal W3C come standard ufficiale è la referenza completa del protocollo, supportata ormai da tutti i browser moderni e documentata in dettaglio nelle guide di Apple, Google e Microsoft per gli sviluppatori.

Il passaggio operativo chiave è che passkey funziona solo su connessioni HTTPS (nessuna eccezione, nemmeno su localhost per testing oltre a certi casi specifici) e richiede che il dominio del server sia incluso nella Relying Party ID configurata al momento della registrazione. Questo significa che l'applicazione deve servire su HTTPS valido in sviluppo, staging e produzione - un requisito già presente nelle installazioni serie ma che su progetti legacy a volte richiede lavoro aggiuntivo.

L'implementazione Laravel: bibliothek dedicata e flusso registrazione

L'implementazione di WebAuthn in Laravel richiede una libreria che gestisca la crittografia del protocollo. La scelta canonica è web-auth/webauthn-lib del progetto web-authn-framework, mantenuto attivamente con documentazione completa su webauthn-doc.spomky-labs.com, che implementa la specifica W3C completa e ha supporto Symfony/Laravel native. Il pacchetto gestisce challenge generation, serializzazione CBOR, verifica delle firme con ECDSA/Ed25519, e tutti i dettagli crittografici che non dovrebbero mai essere implementati manualmente.

Il setup iniziale richiede installazione del pacchetto, pubblicazione delle migration per le tabelle, configurazione del provider. Le due tabelle principali sono: webauthn_credentials (registro delle chiavi pubbliche dei passkey registrati per ogni utente, con metadata come data di registrazione, nome del device, counter di utilizzo), e webauthn_challenges (storage temporaneo delle challenge attive durante un flusso di registrazione o login, con TTL di 5 minuti).

Il flusso di registrazione di un passkey per un utente già loggato si articola in due step. Step 1: il backend genera una challenge e la invia al frontend insieme ai parametri Relying Party:

<?php
// app/Http/Controllers/Auth/PasskeyController.php
namespace App\Http\Controllers\Auth;

use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialParameters;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;

class PasskeyController extends Controller
{
    public function startRegistration(Request $request)
    {
        $user = $request->user();

        $rp = new PublicKeyCredentialRpEntity(
            name: 'Nome Azienda',
            id: 'app.azienda.local'  // dominio canonico
        );

        $userEntity = new PublicKeyCredentialUserEntity(
            name: $user->email,
            id: (string) $user->id,
            displayName: $user->name,
        );

        $options = new PublicKeyCredentialCreationOptions(
            rp: $rp,
            user: $userEntity,
            challenge: random_bytes(32),
            pubKeyCredParams: [
                new PublicKeyCredentialParameters('public-key', -7),   // ES256
                new PublicKeyCredentialParameters('public-key', -257), // RS256
                new PublicKeyCredentialParameters('public-key', -8),   // Ed25519
            ],
            authenticatorSelection: [
                'userVerification' => 'required',
                'residentKey' => 'preferred',
            ],
            timeout: 60000,
        );

        // Salva la challenge temporaneamente per verifica successiva
        session(['webauthn_challenge' => $options->challenge]);

        return response()->json($options);
    }
}

Il frontend riceve questi parametri e chiama l'API browser navigator.credentials.create(), che innesca la UI di registrazione biometrica: Face ID, Touch ID, Windows Hello, o YubiKey connessa. L'utente autentica, il browser genera la coppia di chiavi, e restituisce al frontend l'oggetto PublicKeyCredential contenente la chiave pubblica firmata.

Step 2: il frontend invia al backend l'oggetto PublicKeyCredential, il backend verifica la firma, salva la chiave pubblica:

<?php
public function completeRegistration(Request $request)
{
    $challenge = session('webauthn_challenge');
    if (!$challenge) {
        return response()->json(['error' => 'Nessuna challenge attiva'], 400);
    }

    // Il pacchetto webauthn-lib valida la firma e la struttura
    $credentialSource = $this->webauthnServer->loadAndCheckAttestationResponse(
        $request->input('attestation'),
        $challenge,
    );

    // Salva la chiave pubblica associata all'utente
    WebauthnCredential::create([
        'user_id' => $request->user()->id,
        'credential_id' => base64_encode($credentialSource->publicKeyCredentialId),
        'public_key' => $credentialSource->publicKey,
        'counter' => 0,
        'aaguid' => $credentialSource->aaguid,
        'device_name' => $request->input('device_name', 'Dispositivo'),
        'registered_at' => now(),
    ]);

    session()->forget('webauthn_challenge');
    return response()->json(['success' => true]);
}

Il flusso di login è simmetrico: il backend genera una challenge, il frontend chiama navigator.credentials.get(), l'utente autentica con biometria, il browser firma la challenge, il backend verifica la firma contro la chiave pubblica registrata e autentica l'utente.

Stai cercando un Consulente Informatico esperto per implementare autenticazione passwordless moderna su applicazioni Laravel con WebAuthn e passkey, eliminando le categorie di vulnerabilità legate alle password tradizionali? Nel mio profilo professionale trovi l'esperienza concreta su autenticazione sicura, integrazione FIDO2/WebAuthn, migrazione progressiva di piattaforme B2B dalla password al passwordless.

Non tutti gli utenti possono usare passkey. Browser su hardware legacy senza secure enclave, ambienti corporate dove l'IT blocca l'accesso biometrico, sistemi operativi non aggiornati, utenti che operano su dispositivi condivisi (chiosco, postazione reception). Per questa fascia, la soluzione di fallback è magic link - un link cliccabile inviato via email che autentica l'utente al click.

Il magic link è meno sicuro del passkey (la sicurezza dipende dalla sicurezza dell'email dell'utente), ma è comunque sensibilmente migliore della password tradizionale per due ragioni: elimina il problema del riuso (il link è monouso), e sfrutta l'autenticazione dell'email già esistente (chi ha l'email ha accesso al sistema, e il canale email è tipicamente già protetto da MFA dal provider). L'implementazione richiede quattro pezzi: una tabella magic_link_tokens con token univoco, user_id, expires_at (tipicamente 10 minuti di validità); un controller che genera il token e invia l'email; un controller che consuma il token e autentica; un guard Laravel custom che riconosce entrambi i metodi passkey e magic link.

<?php
// app/Http/Controllers/Auth/MagicLinkController.php
namespace App\Http\Controllers\Auth;

use App\Models\User;
use App\Models\MagicLinkToken;
use App\Notifications\MagicLinkNotification;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class MagicLinkController extends Controller
{
    public function requestLink(Request $request)
    {
        $request->validate(['email' => 'required|email']);

        $user = User::where('email', $request->email)->first();

        if ($user) {
            // Genera token monouso con TTL 10 minuti
            $token = Str::random(64);
            MagicLinkToken::create([
                'user_id' => $user->id,
                'token' => hash('sha256', $token),  // hash per non esporre in DB
                'expires_at' => now()->addMinutes(10),
                'ip' => $request->ip(),
            ]);

            // Invia email con link
            $url = route('auth.magic-link.consume', ['token' => $token]);
            $user->notify(new MagicLinkNotification($url));
        }

        // Risposta generica per non disclose quali email sono registrate
        return response()->json([
            'message' => 'Se l\'email e registrata, ti abbiamo inviato un link di accesso.',
        ]);
    }

    public function consume(string $token, Request $request)
    {
        $hashed = hash('sha256', $token);

        $magicToken = MagicLinkToken::where('token', $hashed)
            ->where('expires_at', '>', now())
            ->whereNull('consumed_at')
            ->first();

        if (!$magicToken) {
            return redirect('/login')
                ->withErrors(['email' => 'Link non valido o scaduto']);
        }

        // Marca come consumato (monouso)
        $magicToken->update(['consumed_at' => now()]);

        // Autentica e regenera sessione per prevenire session fixation
        auth()->loginUsingId($magicToken->user_id);
        $request->session()->regenerate();

        return redirect()->intended('/dashboard');
    }
}

Tre dettagli meritano attenzione. Primo: la risposta generica anche quando l'email non è registrata. Questo previene email enumeration - un attaccante non può scoprire quali email sono registrate nel sistema provando a richiedere magic link. Secondo: l'hash del token in database invece del token plain text. Se il database viene compromesso, i token attivi non possono essere utilizzati perché l'attaccante non ha il valore originale. Terzo: il regenerate() della sessione al login previene session fixation attacks. Questi dettagli sono piccoli ma importanti - un magic link mal implementato è peggio di una password, perché dà falsa sicurezza.

La UX della migrazione: come portare utenti esistenti da password a passkey

Il problema più sottovalutato dell'autenticazione passwordless non è tecnico, è di user experience e di supporto. Utenti che usano da anni username/password e che improvvisamente devono "autenticarsi con il dito" percepiscono la transizione come un disturbo. Il team di supporto deve gestire le prime settimane con pazienza. La strategia di migrazione che ho applicato sul cliente legale è stata progressiva su sei mesi, con cinque fasi.

Fase 1 (mese 1, optional): passkey disponibile come opzione alternativa nel pannello profilo. Gli utenti curiosi lo provano, il 22% lo configura entro il primo mese senza alcuna pressione. Il team di supporto riceve feedback iniziale e produce una guida utente illustrata che risponde alle 5 domande più frequenti. Fase 2 (mese 2, incentivato): una notifica in-app al login suggerisce passkey agli utenti che non l'hanno ancora configurato, con un link al wizard di configurazione in due step. Il 30% degli utenti totali adotta. Fase 3 (mese 3, obbligatorio per admin): gli utenti con ruolo admin (i più esposti a attacchi target) vengono forzati a configurare passkey entro due settimane, con comunicazione email personale dal CEO dell'azienda. L'adozione è 100% perché non c'è alternativa. Fase 4 (mese 4, obbligatorio per gestori contratti): i gestori contratti che operano sui dati più sensibili (anagrafiche clienti, importi fatturazione) vengono forzati con la stessa procedura. Fase 5 (mese 5-6, rollout completo): tutti gli utenti rimanenti vengono portati al passkey obbligatorio, con periodo di grazia di 30 giorni e supporto telefonico dedicato.

La chiave di una migrazione senza drammi è la combinazione di tre elementi: gradualità temporale (sei mesi, non due settimane), supporto dedicato nei primi giorni di ogni fase, e comunicazione chiara del perché (ogni email al cliente inizia con "abbiamo eliminato 350.000 tentativi di attacco al mese con questa tecnologia"). Il pattern è lo stesso che descrivo nel mio articolo sull'incident response in 72 ore per applicazioni Laravel e Symfony NIS2-ready per PMI - la tecnologia è il mezzo, la comunicazione è il moltiplicatore del beneficio.

Le trappole operative: device perso, dimenticato, scambiato

Il passaggio passwordless risolve molti problemi ma ne crea uno nuovo: la gestione dei device. Un utente che perde il cellulare dove ha configurato il passkey non può più autenticarsi. Una password si può reset via email; un passkey no, perché il passkey è il credenziale. Tre pattern di mitigazione che applico.

Primo: passkey multipli per utente. Ogni utente può configurare fino a 5 passkey su device diversi (laptop, smartphone, YubiKey di backup), e il sistema accetta l'autenticazione con uno qualsiasi. Se perdi lo smartphone, autentichi con il laptop. Il pannello profilo mostra la lista dei passkey attivi con nome device e data ultimo utilizzo, permettendo di revocare quelli non più usati.

Secondo: recovery codes generati alla prima configurazione passkey. Questi sono 10 codici monouso che l'utente deve stampare o salvare in password manager al momento del setup. Se perde tutti i device, può autenticarsi con uno dei recovery codes e ripristinare l'accesso (configurando nuovi passkey). I recovery codes sono il classico fallback che FIDO2/WebAuthn spec consiglia ma non impone.

Terzo: procedura di reset supervisionato dal supporto. Per casi estremi (utente che ha perso tutto e non ha recovery codes), il team di supporto può eseguire una procedura di reset previa verifica identità rigorosa - video call con documento d'identità, domande di sicurezza pre-configurate, conferma del manager aziendale per utenti enterprise. Questa procedura è volutamente laboriosa per prevenire social engineering - un attaccante che vuole resettare l'account di un utente target deve superare barriere multiple.

Sul cliente legale, nei sei mesi successivi al rollout, abbiamo gestito 18 casi di reset: 14 via recovery code (procedura istantanea dal portale utente), 3 via passkey di backup (l'utente si è ricordato di averne uno sul vecchio laptop), 1 via reset supervisionato dal supporto (un utente che ha perso laptop e telefono in un furto e non aveva mai salvato i recovery codes). Il tasso di reset è coerente con quello che mi aspettavo - circa 1 reset ogni 80 utenti attivi all'anno - e dimostra che la procedura funziona.

Il confronto onesto con MFA TOTP: quando la password + MFA è ancora accettabile

Passkey non è sempre la scelta giusta. Esistono contesti dove MFA TOTP (codici a 6 cifre generati da Google Authenticator o equivalenti) combinato con password è ancora una scelta valida. Primo: applicazioni interne usate solo da dipendenti su device controllati dall'IT aziendale, dove il rischio di phishing esterno è mitigato dal perimetro di rete. Secondo: applicazioni con user base internazionale dove device e browser possono essere molto eterogenei, e la matrice di supporto WebAuthn non è ancora perfetta (tipicamente utenti di paesi in via di sviluppo con Android molto vecchi). Terzo: applicazioni dove il reset in caso di device perso deve essere istantaneo e senza supporto umano (situazioni di emergenza dove un utente DEVE accedere in 5 minuti da un nuovo dispositivo).

Nel contesto del cliente legale italiano, passkey è la scelta corretta perché gli utenti operano su device corporate moderni (laptop Windows/Mac con Windows Hello/Touch ID attivi, smartphone iOS/Android aggiornati) e i clienti enterprise apprezzano esplicitamente la sicurezza aggiuntiva. In un altro cliente del settore retail con utenti su tablet condivisi in negozio, abbiamo mantenuto la password + MFA TOTP perché la sharing dei device non è compatibile con passkey legato a biometria individuale. La scelta deve essere calibrata, non religiosa. Il pattern di hardening Laravel e Symfony in 14 giorni per le PMI che si preparano a NIS2 dettaglia le misure di sicurezza che si combinano con l'autenticazione forte - passkey è un pezzo, non l'intero puzzle.

Le metriche di impatto: cosa misurare per dimostrare il ROI

Dopo il rollout completo, le metriche che monitoro su base mensile sono tre. Primo: il tasso di tentativi di attacco falliti (credential stuffing, brute force), che misura la pressione esterna sul sistema. Sul cliente legale questo tasso è sceso da ~40.000 tentativi/mese (pre-passkey, dopo i rate limit standard) a ~180 tentativi/mese (post-passkey), un fattore 200x - gli attaccanti semplicemente smettono di provare quando capiscono che non c'è una password da indovinare.

Secondo: il tempo medio di login per utenti legittimi. Era 12 secondi con password+MFA (typing password, apertura app MFA, digitazione codice), è 2,5 secondi con passkey (Face ID/Touch ID single-tap). Moltiplicato per i login giornalieri degli utenti, il risparmio cumulativo è significativo - circa 400 ore-lavoro all'anno per una base utenti di 1200.

Terzo: il costo operativo del supporto. Il numero di ticket di "password dimenticata" è sceso da una media di 35/mese a 2-3/mese (quelli rimanenti sono i reset passkey), con risparmio di circa 18 ore/mese di lavoro del team supporto. Questo costo risparmiato paga ampiamente l'investimento di 8 settimane di implementazione in meno di 9 mesi.

Se gestisci un'applicazione Laravel con autenticazione basata su password tradizionale e stai subendo attacchi regolari di credential stuffing o phishing, oppure vuoi proattivamente uscire dal rischio strutturale delle password prima di subire un incidente serio, contattami per una valutazione: in due settimane valuto la maturità tecnica del tuo stack per l'adozione passkey, dimensiono l'effort di implementazione, disegno la strategia di migrazione progressiva calibrata sulla tua base utenti, e ti consegno un piano che combina passkey come metodo principale, magic link come fallback, MFA TOTP come soluzione di continuità per gli edge case - con metriche baseline per misurare il beneficio nei primi sei mesi post-rollout.

Ultima modifica: