Torna al blog
7 ottobre 2026Sergei Solod13 min di lettura

Come implementare un SSO tra domini radice indipendenti

Un’architettura pratica per un’autenticazione unificata tra domini radice indipendenti, basata su sessioni limitate all’host, un emittente di autenticazione noto e token di trasferimento monouso e di breve durata, senza costringere ogni visitatore a passare da un reindirizzamento di autenticazione.

SSO tra dominiAutenticazioneSicurezza delle sessioniSicurezza webArchitettura web

Avevo bisogno che più siti web indipendenti si comportassero come un unico sistema di account, senza indebolire il normale isolamento del browser. Un utente poteva accedere su un dominio, aprire in seguito direttamente un altro dominio, fare clic su Accedi e continuare da lì senza inserire di nuovo le credenziali.

Il vincolo importante era che si trattava di domini radice indipendenti. Un cookie di sessione creato da un dominio radice non può essere semplicemente esteso a un altro dominio radice non correlato. Il progetto è diventato molto più facile da ragionare quando ho separato due concetti che spesso vengono confusi: identità globale e sessione locale del browser.

Il modello risultante è semplice: il backend riconosce un’unica identità di account condivisa, ogni dominio possiede la propria sessione vincolata all’host e un trasferimento monouso e di breve durata consente a un’origine attendibile già autenticata di autorizzare la creazione di una nuova sessione su un’altra origine attendibile.

Un’identità globale non richiede un cookie condiviso

L’account può essere globale anche quando le sessioni del browser sono locali.

Ogni sito mantiene un cookie di sessione limitato all’host. Un cookie emesso da un dominio radice non può essere esteso a un dominio radice non correlato tramite l’attributo Domain. Quell’attributo può ampliare l’ambito di un cookie solo all’interno della gerarchia di domini che l’host emittente è autorizzato a usare.

Per una sessione che deve rimanere vincolata a un solo host, un cookie con prefisso __Host- è una buona scelta. Concettualmente:

Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax

Le proprietà importanti sono i confini:

  • Il sito A non può leggere il cookie di sessione limitato all’host del sito B.
  • Il sito B non può leggere il cookie di sessione limitato all’host del sito A.
  • Entrambe le sessioni possono comunque risolversi nella stessa identità lato backend.

Il SSO tra domini non dovrebbe quindi tentare di spostare un cookie da un sito all’altro. Dovrebbe trasferire una prova temporanea del fatto che una fonte attendibile ha già autenticato l’utente.

Il pattern del trasferimento monouso

La fonte autenticata espone una piccola operazione di trasferimento. Il browser la richiama solo quando è necessaria una transizione di autenticazione tra siti.

Il backend genera un ticket casuale generato in modo crittograficamente sicuro, restituisce una sola volta il valore grezzo al browser e memorizza soltanto un hash insieme alle informazioni minime necessarie per validare il trasferimento:

{
  tokenHash,
  userId,
  sourceOrigin,
  targetSite,
  returnPath,
  expiresAt
}

Il ticket deve avere una durata breve ed essere valido per una sola destinazione. La richiesta di emissione dal lato sorgente deve essere già autenticata e protetta contro il CSRF. La destinazione richiesta e il percorso di ritorno devono essere convalidati prima che venga emesso alcunché.

Il browser esegue quindi un POST di primo livello verso la destinazione. La destinazione consuma il ticket, risolve l’identità utente condivisa, crea la propria sessione locale e reindirizza il browser al percorso previsto.

Concettualmente:

record = consumeOnce({
  tokenHash: hash(ticket),
  targetSite: currentSite,
  sourceOrigin: requestOrigin,
  notExpired: true
})

if (!record) deny()

user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)

Il ticket non è la sessione di destinazione. È un’autorizzazione monouso a creare quella sessione di destinazione.

Il consumo atomico chiude la finestra di replay

Un’implementazione più debole caricherebbe il ticket, lo convaliderebbe, creerebbe una sessione e infine eliminerebbe il ticket. Questo crea una finestra nella quale due richieste possono osservare lo stesso record ancora valido.

La soluzione più sicura consiste nel combinare validazione e invalidazione in un’unica operazione atomica di consumo. Se nessun record corrisponde, l’autenticazione fallisce. Se viene restituito un record, quel ticket è già stato rimosso dall’insieme utilizzabile prima della creazione della sessione.

Questo permette inoltre alla destinazione di vincolare più condizioni alla stessa operazione: destinazione attesa, origine sorgente attesa, hash del token e scadenza.

La validazione dell’origine deve fallire in modo chiuso

Il vincolo all’origine aggiunge un’altra proprietà utile: un ticket emesso tramite una fonte attendibile non dovrebbe essere consumabile da un’origine non correlata.

Il controllo dell’origine dovrebbe far parte della condizione di consumo stessa. In questo modo una richiesta proveniente dall’origine sbagliata non consuma il ticket valido, mentre la fonte legittima può comunque completare il trasferimento in seguito.

C’è un’avvertenza importante: non è garantito che un header Origin contenga una normale stringa di origine in ogni contesto del browser. Alcune richieste possono arrivare senza un valore utilizzabile e i contesti opachi possono produrre Origin: null.

La mia regola è trattare un Origin mancante o opaco come un controllo dell’origine fallito, a meno che l’applicazione non disponga di un meccanismo alternativo esplicito di validazione. Il valore letterale null non dovrebbe diventare un’origine attendibile soltanto per ragioni di compatibilità.

È una scelta fail-closed. Se un flusso legittimo deve supportare un contesto privo di un’origine utilizzabile, quell’eccezione dovrebbe essere progettata esplicitamente invece di indebolire silenziosamente il controllo generale.

Mantieni la credenziale di trasferimento fuori dall’URL

Preferisco inviare il ticket temporaneo in un modulo POST di primo livello invece di inserirlo nella stringa di query.

Una versione generalizzata lato client è:

const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';

const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;

form.append(input);
document.body.append(form);
form.submit();

La destinazione accetta il ticket dal corpo POST previsto e rifiuta i tentativi equivalenti di fornirlo tramite l’URL.

Questo non rende la credenziale innocua, ma la mantiene fuori dagli URL di navigazione ordinari e riduce l’esposizione accidentale tramite cronologia degli URL, link copiati, referrer e registrazione orientata agli URL.

I link tra siti possono attivare il trasferimento quando l’utente è autenticato

Se il sito corrente sa già che l’utente è autenticato, un normale link verso un altro sito attendibile può eseguire il trasferimento quando opportuno.

Il link dovrebbe comunque contenere un vero href. Il SSO è un miglioramento della navigazione normale, non un suo sostituto.

Un flusso client ridotto appare così:

if (session.status !== 'authenticated') {
  return; // ordinary browser navigation
}

if (!isTrustedTarget(destination)) {
  return;
}

event.preventDefault();

const ticket = await requestHandoff({
  targetSite,
  returnPath: destinationPath
});

postTicketToTarget(ticket);

La versione di produzione dovrebbe inoltre proteggere dagli invii duplicati, convalidare la destinazione rispetto a un registro attendibile e ripiegare sulla navigazione normale se non è possibile creare il trasferimento.

Il comportamento nativo del browser dovrebbe restare nativo. Il clic con il pulsante centrale, i clic con tasti modificatori, i download e i link destinati a un altro contesto di navigazione non dovrebbero essere convertiti silenziosamente in richieste di autenticazione.

Il pulsante Accedi sul secondo sito richiede un emittente noto

Il caso più difficile inizia quando l’utente apre direttamente il sito B. Il sito B non ha una sessione locale, quindi considera correttamente il visitatore anonimo anche se il browser conserva ancora una sessione valida sul sito A.

Il sito B non può ispezionare il cookie limitato all’host del sito A. Inoltre non può reindirizzare a un sito C arbitrario aspettandosi che quel sito scopra le sessioni appartenenti al resto del sistema. Ogni dominio radice non correlato può osservare solo i cookie che gli appartengono.

Questo significa che un flusso anonimo di Accedi richiede un emittente noto: un’origine di autenticazione canonica oppure un sito attendibile selezionato esplicitamente e che dovrebbe conservare la sessione di accesso riutilizzabile.

Per il semplice caso Sito A → Sito B, la sequenza è:

  1. L’utente accede sul sito A.
  2. In seguito, l’utente apre direttamente il sito B.
  3. Il sito B non ha una sessione, quindi mostra Accedi.
  4. L’utente fa clic su Accedi.
  5. Il browser esegue una navigazione di primo livello verso il sito A, che è l’emittente selezionato.
  6. Il sito A può leggere il proprio cookie di sessione esistente e limitato all’host.
  7. Poiché quella sessione è ancora valida, il sito A emette immediatamente un trasferimento monouso per il sito B.
  8. Il browser invia il ticket al sito B tramite POST.
  9. Il sito B crea la propria sessione locale e riporta l’utente alla pagina richiesta.

Dal punto di vista dell’utente, si tratta di un rapido reindirizzamento verso il sito A e ritorno. Non è necessaria alcuna password perché il sito A possiede già la sessione che è autorizzato a osservare.

La limitazione è altrettanto importante: se l’utente ha effettuato l’accesso soltanto sul sito C, reindirizzare il sito B verso il sito A non aiuta, a meno che il sito A non sia un vero broker centrale di autenticazione con una propria conoscenza indipendente dello stato di accesso. Un database utenti condiviso non rende condivise le sessioni del browser.

Perché un emittente canonico semplifica il sistema

Con l’aumentare del numero di siti indipendenti, un emittente canonico elimina la necessità di scoprire le sessioni tra domini arbitrari.

Senza di esso, un sito B anonimo dovrebbe in qualche modo decidere quale altro sito potrebbe avere in quel momento un cookie utile. Il sito A non può ispezionare il cookie del sito C, il sito C non può ispezionare quello del sito A e il sito B non può ispezionare nessuno dei due.

Un emittente canonico offre a ogni tentativo di accesso anonimo un percorso prevedibile:

Sito B
  → emittente di autenticazione
  → esiste una sessione dell’emittente?
      sì → crea un trasferimento monouso
      no → mostra l’accesso normale
  → Sito B consuma il trasferimento
  → Sito B crea una sessione locale

L’emittente non deve servire il resto dell’applicazione. La sua responsabilità rilevante è possedere la sessione di autenticazione riutilizzabile ed emettere trasferimenti con ambito strettamente limitato verso siti attendibili.

Per un sistema più piccolo, un sito esistente può svolgere quel ruolo. Per un’architettura più ampia, un’origine di autenticazione dedicata può rendere il modello di fiducia più facile da comprendere.

Non far passare ogni visitatore attraverso l’emittente

Il flusso dell’emittente non dovrebbe essere eseguito automaticamente per ogni visualizzazione anonima di pagina.

Reindirizzare ogni visitatore verso un altro dominio solo per verificare l’esistenza di una sessione crea navigazioni inutili, latenza aggiuntiva, più modalità di errore, dati analitici più rumorosi e un comportamento della cache più complicato.

È anche poco adatto alle pagine pubbliche sensibili alla SEO. Un crawler o un normale visitatore che richiede un URL pubblico dovrebbe ricevere direttamente quella pagina pubblica, invece di essere costretto prima a passare da una deviazione di autenticazione.

La regola che uso è:

Non interrogare l’emittente al caricamento della pagina. Avvia l’autenticazione tra domini solo dopo un’azione esplicita di Accedi o una navigazione intenzionale tra siti da uno stato autenticato.

Questo mantiene semplice il traffico pubblico, pur rendendo rapida l’autenticazione per un utente che dispone già di una sessione valida sull’emittente selezionato.

La validazione del percorso di ritorno merita più attenzione di un controllo del prefisso

Il trasferimento deve solitamente ricordare dove dovrà arrivare il browser in seguito. Quel valore può trasformarsi in un reindirizzamento aperto se viene trattato come un URL arbitrario controllato dal client.

Dove possibile, la soluzione più sicura consiste nel non accettare affatto un URL di destinazione completo. Un identificatore breve o una mappatura lato server per destinazioni note riduce la quantità di analisi e validazione degli URL necessaria.

Se è necessario accettare un percorso relativo, convalidalo attentamente e mantienilo limitato al sito di destinazione.

Un esempio volutamente ridotto — non una routine completa di validazione dei reindirizzamenti — è:

function isSafeReturnPath(value) {
  if (!value.startsWith('/')) return false;
  if (value.startsWith('//')) return false;
  if (value.includes('\\\\')) return false;

  const base = 'https://example.invalid';
  const parsed = new URL(value, base);

  return parsed.origin === base;
}

Un’implementazione reale deve inoltre considerare il comportamento di decodifica, gli input malformati, i caratteri di controllo, la normalizzazione, le regole di routing dell’applicazione e qualsiasi trasformazione operata da proxy o framework prima che venga prodotto il reindirizzamento.

L’invariante di sicurezza è più semplice dei dettagli dell’analizzatore: il trasferimento può selezionare una posizione consentita nell’applicazione di destinazione, mai una destinazione esterna arbitraria.

I casi di errore contano più del percorso ideale

Un accesso riuscito dimostra soltanto che il percorso di base funziona. I test più utili esercitano i confini di fiducia.

Ho verificato il comportamento in casi come:

  • una sorgente non autenticata che tenta di emettere un trasferimento;
  • protezione CSRF mancante;
  • un’origine mancante, opaca o inattesa;
  • una destinazione non attendibile;
  • una destinazione di ritorno non sicura;
  • un ticket presentato alla destinazione sbagliata;
  • un tentativo di consumo da un’origine errata;
  • replay del ticket;
  • scadenza del ticket;
  • un utente che non esiste più;
  • un ticket fornito tramite URL anziché nel corpo POST previsto.

Due invarianti sono particolarmente utili da verificare direttamente: una richiesta rifiutata non deve distruggere accidentalmente un ticket ancora valido per il flusso legittimo, e un consumo riuscito deve rendere quel ticket immediatamente inutilizzabile.

Il protocollo scala senza integrazioni a coppie

La progettazione del trasferimento non cambia in modo sostanziale quando si aggiungono altri siti.

Ogni sito partecipante ha bisogno dello stesso piccolo contratto:

  • un identificatore logico stabile;
  • un’origine pubblica attendibile;
  • accesso all’identità account condivisa;
  • un’operazione di emissione del trasferimento per sorgenti autenticate;
  • un’operazione di consumo del trasferimento;
  • un registro dei siti attendibili;
  • una sessione locale vincolata all’host;
  • una politica sicura per la destinazione di ritorno;
  • e un emittente noto per i flussi anonimi di Accedi.

La scelta importante per la scalabilità è evitare una logica di autenticazione personalizzata per ogni coppia di siti. Un sito già autenticato può emettere lo stesso tipo di trasferimento verso un altro sito attendibile. Un sito anonimo può inviare il browser all’emittente noto.

In questo modo il sistema resta basato su un unico protocollo invece che su una matrice crescente di casi speciali.

La portabilità dell’accesso è separata dalla propagazione della disconnessione

Le sessioni indipendenti vincolate all’host introducono un’altra distinzione utile: rendere portabile l’accesso non definisce automaticamente una disconnessione globale.

Se l’utente effettua la disconnessione dal sito A, la sessione locale di un altro sito può rimanere valida a meno che il backend non la revochi intenzionalmente. Questa è una politica di gestione delle sessioni, non un fallimento del trasferimento SSO.

Un sistema può scegliere tra disconnessione locale, disconnessione globale o gestione esplicita delle sessioni. Tenere questa decisione separata dal trasferimento rende entrambi i meccanismi più facili da comprendere.

Il modello che uso

L’architettura è diventata più semplice quando ho smesso di descrivere il problema come «condividere i cookie di accesso tra domini».

Esiste un’unica identità globale, ogni dominio possiede la propria sessione del browser e un trasferimento monouso e di breve durata consente a un’origine attendibile autenticata di autorizzare la creazione di un’altra sessione locale.

Per l’accesso diretto a un sito anonimo conta un’ulteriore regola: il browser deve essere inviato a un emittente che possa effettivamente osservare la sessione esistente dell’utente. Se l’utente ha effettuato in precedenza l’accesso su quell’emittente, il viaggio di andata e ritorno può essere quasi invisibile. Se l’emittente non dispone di una sessione valida, deve ripiegare sull’autenticazione normale invece di fingere che la sessione di un altro dominio non correlato sia visibile.

Questo modello preserva i confini di dominio del browser, evita reindirizzamenti di autenticazione non necessari per i visitatori pubblici e offre comunque l’esperienza desiderata: accedere sull’emittente, aprire in seguito un altro sito attendibile, fare clic su Accedi e tornare con una nuova sessione locale senza inserire di nuovo la password.