Retour au blog
7 octobre 2026Sergei Solod14 min de lecture

Comment mettre en place un SSO interdomaines sur des domaines racine indépendants

Une architecture pratique pour une authentification unifiée entre des domaines racine indépendants, fondée sur des sessions propres à chaque hôte, un émetteur d’authentification connu et des jetons de transfert à usage unique et de courte durée, sans imposer à chaque visiteur une redirection d’authentification.

SSO interdomainesAuthentificationSécurité des sessionsSécurité webArchitecture web

J’avais besoin que plusieurs sites web indépendants se comportent comme un seul système de comptes, sans affaiblir l’isolation normale du navigateur. Un utilisateur pouvait se connecter sur un domaine, ouvrir plus tard un autre domaine directement, cliquer sur Se connecter et y poursuivre sa navigation sans saisir à nouveau ses identifiants.

La contrainte importante était qu’il s’agissait de domaines racine indépendants. Un cookie de session créé par un domaine racine ne peut pas simplement être étendu à un autre domaine racine sans lien. La conception est devenue beaucoup plus facile à raisonner lorsque j’ai séparé deux notions souvent confondues : l’identité globale et la session locale du navigateur.

Le modèle qui en résulte est simple : le backend reconnaît une identité de compte partagée, chaque domaine possède sa propre session liée à l’hôte, et un transfert à usage unique et de courte durée permet à une origine de confiance déjà authentifiée d’autoriser la création d’une nouvelle session sur une autre origine de confiance.

Une identité globale ne nécessite pas de cookie partagé

Le compte peut être global même lorsque les sessions du navigateur sont locales.

Chaque site conserve un cookie de session propre à l’hôte. Un cookie émis par un domaine racine ne peut pas être étendu à un domaine racine sans lien via l’attribut Domain. Cet attribut ne peut élargir la portée d’un cookie qu’au sein de la hiérarchie de domaines que l’hôte émetteur est autorisé à utiliser.

Pour une session qui doit rester liée à un seul hôte, un cookie préfixé par __Host- convient bien. Conceptuellement :

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

Les propriétés importantes sont les limites d’accès :

  • Le site A ne peut pas lire le cookie de session propre à l’hôte du site B.
  • Le site B ne peut pas lire le cookie de session propre à l’hôte du site A.
  • Les deux sessions peuvent néanmoins correspondre à la même identité côté backend.

Le SSO interdomaines ne doit donc pas tenter de déplacer un cookie d’un site vers un autre. Il doit transférer une preuve temporaire indiquant qu’une source de confiance a déjà authentifié l’utilisateur.

Le modèle du transfert à usage unique

La source authentifiée expose une petite opération de transfert. Le navigateur ne l’appelle que lorsqu’une transition d’authentification entre sites est nécessaire.

Le backend génère un ticket aléatoire cryptographiquement sûr, renvoie une seule fois sa valeur brute au navigateur et ne stocke qu’un hash avec le minimum d’informations nécessaires pour valider le transfert :

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

Le ticket doit avoir une courte durée de vie et n’être valable que pour une seule cible. La requête d’émission côté source doit déjà être authentifiée et protégée contre les attaques CSRF. La cible demandée et la destination de retour doivent être validées avant toute émission.

Le navigateur effectue ensuite un POST de premier niveau vers la cible. La cible consomme le ticket, résout l’identité utilisateur partagée, crée sa propre session locale et redirige le navigateur vers le chemin prévu.

Conceptuellement :

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

if (!record) deny()

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

Le ticket n’est pas la session cible. Il s’agit d’une autorisation à usage unique permettant de créer cette session cible.

Une consommation atomique ferme la fenêtre de rejeu

Une implémentation plus faible chargerait le ticket, le validerait, créerait une session, puis supprimerait le ticket. Cela crée une fenêtre pendant laquelle deux requêtes peuvent observer le même enregistrement encore valide.

Une conception plus sûre consiste à combiner validation et invalidation dans une seule opération atomique de consommation. Si aucun enregistrement ne correspond, l’authentification échoue. Si un enregistrement est renvoyé, ce ticket a déjà été retiré de l’ensemble utilisable avant la création de la session.

Cela permet aussi à la cible de lier plusieurs conditions à la même opération : cible attendue, origine source attendue, hash du jeton et expiration.

La validation de l’origine doit échouer par défaut

Le fait de lier le ticket à une origine apporte une autre propriété utile : un ticket émis via une source de confiance ne doit pas pouvoir être consommé depuis une origine sans lien.

La vérification de l’origine doit faire partie de la condition de consommation elle-même. Ainsi, une requête provenant de la mauvaise origine ne consomme pas le ticket valide, tandis que la source légitime peut encore terminer le transfert ensuite.

Il existe une réserve importante : un en-tête Origin n’est pas garanti de contenir une chaîne d’origine normale dans tous les contextes du navigateur. Certaines requêtes peuvent arriver sans valeur exploitable, et les contextes opaques peuvent produire Origin: null.

Ma règle consiste à considérer un Origin absent ou opaque comme un échec de la vérification d’origine, sauf si l’application dispose d’un mécanisme alternatif explicite de validation. La valeur littérale null ne doit pas devenir une origine de confiance uniquement pour des raisons de compatibilité.

C’est un choix de type fail-closed. Si un flux légitime doit prendre en charge un contexte sans origine exploitable, cette exception doit être conçue explicitement plutôt que d’affaiblir silencieusement la vérification générale.

Gardez l’identifiant de transfert hors de l’URL

Je préfère envoyer le ticket temporaire dans un formulaire POST de premier niveau plutôt que de le placer dans une chaîne de requête.

Une version générique côté client ressemble à ceci :

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 cible accepte le ticket dans le corps POST attendu et rejette les tentatives équivalentes qui cherchent à le fournir via l’URL.

Cela ne rend pas l’identifiant inoffensif, mais le maintient hors des URL de navigation ordinaires et réduit les expositions accidentelles via l’historique des URL, les liens copiés, les référents et les journaux centrés sur les URL.

Les liens intersites peuvent déclencher le transfert lorsque l’utilisateur est authentifié

Si le site actuel sait déjà que l’utilisateur est authentifié, un lien ordinaire vers un autre site de confiance peut déclencher le transfert lorsque c’est pertinent.

Le lien doit tout de même contenir un véritable href. Le SSO améliore la navigation normale, il ne la remplace pas.

Un flux client simplifié ressemble à ceci :

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

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

event.preventDefault();

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

postTicketToTarget(ticket);

La version de production doit aussi empêcher les soumissions en double, valider la cible par rapport à un registre de confiance et revenir à la navigation normale si le transfert ne peut pas être créé.

Le comportement natif du navigateur doit rester natif. Les clics avec le bouton du milieu, les clics avec une touche de modification, les téléchargements et les liens destinés à un autre contexte de navigation ne doivent pas être silencieusement transformés en requêtes d’authentification.

Le bouton Se connecter du second site a besoin d’un émetteur connu

Le cas le plus difficile commence lorsque l’utilisateur ouvre directement le site B. Le site B n’a pas de session locale ; il considère donc correctement le visiteur comme anonyme, même si le navigateur possède encore une session valide sur le site A.

Le site B ne peut pas inspecter le cookie propre à l’hôte du site A. Il ne peut pas non plus rediriger vers un site C arbitraire et attendre de ce site qu’il découvre les sessions appartenant au reste du système. Chaque domaine racine indépendant ne peut observer que les cookies qui lui appartiennent.

Cela signifie qu’un flux Se connecter anonyme a besoin d’un émetteur connu : soit une origine d’authentification canonique, soit un site de confiance spécifiquement sélectionné et censé conserver la session de connexion réutilisable.

Pour le cas simple Site A → Site B, la séquence est la suivante :

  1. L’utilisateur se connecte sur le site A.
  2. Plus tard, il ouvre directement le site B.
  3. Le site B n’a pas de session, il affiche donc Se connecter.
  4. L’utilisateur clique sur Se connecter.
  5. Le navigateur effectue une navigation de premier niveau vers le site A, qui est l’émetteur sélectionné.
  6. Le site A peut lire son propre cookie de session existant, lié à son hôte.
  7. Comme cette session est toujours valide, le site A émet immédiatement un transfert à usage unique pour le site B.
  8. Le navigateur envoie le ticket au site B par POST.
  9. Le site B crée sa propre session locale et renvoie l’utilisateur vers la page demandée.

Du point de vue de l’utilisateur, il s’agit d’une redirection rapide vers le site A puis d’un retour. Aucun mot de passe n’est requis, car le site A possède déjà la session qu’il est autorisé à observer.

La limite est tout aussi importante : si l’utilisateur n’est connecté que sur le site C, rediriger le site B vers le site A n’aide pas, sauf si le site A est un véritable courtier central d’authentification disposant de sa propre connaissance indépendante de l’état de connexion. Une base de données utilisateur partagée ne rend pas les sessions du navigateur partagées.

Pourquoi un émetteur canonique simplifie le système

À mesure que le nombre de sites indépendants augmente, un émetteur canonique élimine le besoin de découvrir des sessions sur des domaines arbitraires.

Sans lui, un site B anonyme devrait déterminer d’une manière ou d’une autre quel autre site pourrait actuellement posséder un cookie utile. Le site A ne peut pas inspecter le cookie du site C, le site C ne peut pas inspecter celui du site A, et le site B ne peut inspecter ni l’un ni l’autre.

Un émetteur canonique donne à chaque tentative de connexion anonyme un chemin prévisible :

Site B
  → émetteur d’authentification
  → une session de l’émetteur existe ?
      oui → créer un transfert à usage unique
      non → afficher la connexion normale
  → Site B consomme le transfert
  → Site B crée une session locale

L’émetteur n’a pas besoin de servir le reste de l’application. Sa responsabilité pertinente consiste à posséder la session d’authentification réutilisable et à émettre des transferts strictement limités vers des sites de confiance.

Pour un système plus petit, un site existant peut jouer ce rôle. Pour une architecture plus large, une origine d’authentification dédiée peut rendre le modèle de confiance plus facile à comprendre.

Ne faites pas passer chaque visiteur par l’émetteur

Le flux de l’émetteur ne doit pas s’exécuter automatiquement à chaque affichage de page anonyme.

Rediriger chaque visiteur vers un autre domaine uniquement pour vérifier l’existence d’une session entraîne des navigations inutiles, une latence supplémentaire, davantage de modes de panne, des données analytiques plus bruitées et un comportement de cache plus complexe.

C’est également mal adapté aux pages publiques sensibles au SEO. Un robot d’indexation ou un visiteur ordinaire demandant une URL publique doit recevoir directement cette page publique au lieu d’être forcé à passer d’abord par un détour d’authentification.

La règle que j’applique est la suivante :

N’interrogez pas l’émetteur au chargement de la page. Ne lancez l’authentification interdomaines qu’après une action explicite Se connecter ou une navigation intersite intentionnelle depuis un état authentifié.

Cela maintient le trafic public simple tout en gardant l’authentification rapide pour un utilisateur qui dispose déjà d’une session valide sur l’émetteur sélectionné.

La validation du chemin de retour mérite plus qu’un simple contrôle de préfixe

Le transfert doit généralement mémoriser l’endroit où le navigateur doit arriver ensuite. Cette valeur peut devenir une redirection ouverte si elle est traitée comme une URL arbitraire contrôlée par le client.

Lorsque c’est possible, la conception la plus sûre consiste à ne pas accepter du tout une URL de destination complète. Un identifiant court ou une correspondance côté serveur pour des destinations connues réduit le volume d’analyse et de validation d’URL nécessaire.

Si un chemin relatif doit être accepté, validez-le soigneusement et limitez-le au site cible.

Voici un exemple volontairement réduit — et non une routine complète de validation de redirection :

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;
}

Une implémentation réelle doit également tenir compte du comportement de décodage, des entrées malformées, des caractères de contrôle, de la normalisation, des règles de routage de l’application et de toute transformation effectuée par un proxy ou un framework avant la production de la redirection.

L’invariant de sécurité est plus simple que les détails du parseur : le transfert peut sélectionner un emplacement autorisé dans l’application cible, jamais une destination externe arbitraire.

Les cas d’échec comptent davantage que le chemin idéal

Une connexion réussie prouve seulement que le chemin de base fonctionne. Les tests les plus utiles exercent les frontières de confiance.

J’ai vérifié le comportement dans des cas tels que :

  • une source non authentifiée tentant d’émettre un transfert ;
  • l’absence de protection CSRF ;
  • une origine absente, opaque ou inattendue ;
  • une cible non approuvée ;
  • une destination de retour non sûre ;
  • un ticket présenté à la mauvaise cible ;
  • une tentative de consommation depuis une mauvaise origine ;
  • le rejeu d’un ticket ;
  • l’expiration du ticket ;
  • un utilisateur qui n’existe plus ;
  • un ticket fourni via l’URL plutôt que dans le corps POST attendu.

Deux invariants sont particulièrement utiles à tester directement : une requête rejetée ne doit pas détruire accidentellement un ticket encore valide pour le flux légitime, et une consommation réussie doit rendre ce ticket inutilisable immédiatement.

Le protocole s’étend sans intégrations par paires

La conception du transfert ne change pas fondamentalement lorsque l’on ajoute davantage de sites.

Chaque site participant a besoin du même petit contrat :

  • un identifiant logique stable ;
  • une origine publique de confiance ;
  • un accès à l’identité de compte partagée ;
  • une opération d’émission de transfert pour les sources authentifiées ;
  • une opération de consommation de transfert ;
  • un registre de sites de confiance ;
  • une session locale liée à l’hôte ;
  • une politique sûre de destination de retour ;
  • et un émetteur connu pour les flux Se connecter anonymes.

Le choix important pour la montée en charge consiste à éviter une logique d’authentification personnalisée pour chaque paire de sites. Un site déjà authentifié peut émettre le même type de transfert vers un autre site de confiance. Un site anonyme peut envoyer le navigateur vers l’émetteur connu.

Le système reste ainsi fondé sur un seul protocole plutôt que sur une matrice croissante de cas particuliers.

La portabilité de la connexion est distincte de la propagation de la déconnexion

Les sessions indépendantes liées à chaque hôte créent une autre distinction utile : rendre la connexion portable ne définit pas automatiquement une déconnexion globale.

Si l’utilisateur se déconnecte du site A, la session locale d’un autre site peut rester valide, sauf si le backend la révoque volontairement. Il s’agit d’une politique de gestion des sessions, et non d’un échec du transfert SSO.

Un système peut choisir entre une déconnexion locale, une déconnexion globale ou une gestion explicite des sessions. Garder cette décision séparée du transfert facilite le raisonnement sur les deux mécanismes.

Le modèle que j’utilise

L’architecture est devenue plus simple lorsque j’ai cessé de décrire le problème comme « le partage des cookies de connexion entre domaines ».

Il existe une identité globale, chaque domaine possède sa propre session de navigateur, et un transfert à usage unique et de courte durée permet à une origine de confiance authentifiée d’autoriser la création d’une autre session locale.

Pour une arrivée directe sur un site anonyme, une règle supplémentaire compte : le navigateur doit être envoyé vers un émetteur capable d’observer réellement la session existante de l’utilisateur. Si l’utilisateur s’est déjà connecté sur cet émetteur, l’aller-retour peut être presque invisible. Si l’émetteur n’a aucune session valide, il doit revenir à l’authentification normale au lieu de faire comme si la session d’un autre domaine sans lien était visible.

Ce modèle préserve les frontières de domaine du navigateur, évite les redirections d’authentification inutiles pour les visiteurs publics et offre malgré tout l’expérience recherchée : se connecter sur l’émetteur, ouvrir plus tard un autre site de confiance, cliquer sur Se connecter et revenir avec une nouvelle session locale sans saisir à nouveau le mot de passe.