Zurück zum Blog
7. Oktober 2026Sergei Solod12 Min. Lesezeit

Cross-Domain-SSO über unabhängige Root-Domains hinweg aufbauen

Eine praktische Architektur für einheitliche Authentifizierung über unabhängige Root-Domains hinweg mit Host-only-Sessions, einem bekannten Authentifizierungs-Issuer und kurzlebigen einmaligen Handoff-Tokens, ohne jeden Besucher durch einen Authentifizierungs-Redirect zu zwingen.

Domainübergreifendes SSOAuthentifizierungSession-SicherheitWebsicherheitWebarchitektur

Ich wollte, dass sich mehrere unabhängige Websites wie ein einziges Kontosystem verhalten, ohne die normale Browser-Isolation abzuschwächen. Ein Benutzer sollte sich auf einer Domain anmelden, später eine andere Domain direkt öffnen, auf Log In klicken und dort weitermachen können, ohne seine Zugangsdaten erneut einzugeben.

Die entscheidende Einschränkung war, dass es sich um unabhängige Root-Domains handelte. Ein von einer Root-Domain gesetztes Session-Cookie lässt sich nicht einfach auf eine nicht verwandte Root-Domain ausweiten. Das Design wurde deutlich leichter nachvollziehbar, nachdem ich zwei Konzepte getrennt hatte, die oft vermischt werden: globale Identität und lokale Browser-Session.

Das daraus entstandene Modell ist unkompliziert: Das Backend erkennt eine gemeinsame Kontoidentität, jede Domain besitzt ihre eigene an den Host gebundene Session, und ein kurzlebiger einmaliger Handoff erlaubt einem authentifizierten vertrauenswürdigen Origin, die Erstellung einer neuen Session auf einem anderen vertrauenswürdigen Origin zu autorisieren.

Eine globale Identität erfordert kein gemeinsames Cookie

Das Konto kann global sein, auch wenn die Browser-Sessions lokal sind.

Jede Website verwendet ein Host-only-Session-Cookie. Ein von einer Root-Domain ausgestelltes Cookie kann über das Attribut Domain nicht auf eine nicht verwandte Root-Domain erweitert werden. Dieses Attribut kann den Geltungsbereich eines Cookies nur innerhalb der Domain-Hierarchie erweitern, die der ausstellende Host verwenden darf.

Für eine Session, die an einen einzelnen Host gebunden bleiben soll, eignet sich ein Cookie mit dem Präfix __Host-. Konzeptionell:

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

Entscheidend sind die Grenzen:

  • Website A kann das Host-only-Session-Cookie von Website B nicht lesen.
  • Website B kann das Host-only-Session-Cookie von Website A nicht lesen.
  • Beide Sessions können trotzdem derselben Backend-Identität zugeordnet werden.

Cross-Domain-SSO sollte daher nicht versuchen, ein Cookie von einer Website auf eine andere zu verschieben. Stattdessen sollte ein temporärer Nachweis übertragen werden, dass eine vertrauenswürdige Quelle den Benutzer bereits authentifiziert hat.

Das Muster des einmaligen Handoffs

Die authentifizierte Quelle stellt eine kleine Handoff-Operation bereit. Der Browser ruft sie nur auf, wenn ein Authentifizierungsübergang zwischen Websites erforderlich ist.

Das Backend erzeugt ein kryptografisch zufälliges Ticket, gibt den Rohwert genau einmal an den Browser zurück und speichert nur einen Hash zusammen mit den minimalen Informationen, die zur Validierung der Übertragung erforderlich sind:

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

Das Ticket sollte kurzlebig und nur für ein einziges Ziel gültig sein. Die Anfrage zur Ausstellung auf der Quellseite sollte bereits authentifiziert und gegen CSRF geschützt sein. Das angeforderte Ziel und das Rückkehrziel sollten validiert werden, bevor etwas ausgestellt wird.

Anschließend führt der Browser einen Top-Level-POST zum Ziel aus. Das Ziel konsumiert das Ticket, ermittelt die gemeinsame Benutzeridentität, erstellt seine eigene lokale Session und leitet den Browser zum vorgesehenen Pfad weiter.

Konzeptionell:

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

if (!record) deny()

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

Das Ticket ist nicht die Ziel-Session. Es ist eine einmalige Autorisierung, diese Ziel-Session zu erstellen.

Atomisches Konsumieren schließt das Replay-Fenster

Eine schwächere Implementierung würde das Ticket laden, validieren, eine Session erstellen und das Ticket anschließend löschen. Dadurch entsteht ein Zeitfenster, in dem zwei Anfragen denselben noch gültigen Datensatz sehen können.

Die sicherere Variante kombiniert Validierung und Ungültigmachung in einer einzigen atomischen Consume-Operation. Wenn kein Datensatz passt, schlägt die Authentifizierung fehl. Wird ein Datensatz zurückgegeben, wurde dieses Ticket bereits aus der Menge der nutzbaren Tickets entfernt, bevor die Session erstellt wird.

Damit kann das Ziel außerdem mehrere Bedingungen an dieselbe Operation binden: erwartetes Ziel, erwarteter Quell-Origin, Token-Hash und Ablaufzeit.

Die Origin-Validierung sollte fail-closed arbeiten

Die Bindung an den Origin bringt eine weitere nützliche Eigenschaft: Ein Ticket, das über eine vertrauenswürdige Quelle ausgestellt wurde, sollte nicht von einem nicht verwandten Origin konsumiert werden können.

Die Origin-Prüfung sollte Teil der Consume-Bedingung selbst sein. Dadurch verbraucht eine Anfrage vom falschen Origin das gültige Ticket nicht, während die legitime Quelle den Handoff anschließend weiterhin abschließen kann.

Es gibt eine wichtige Einschränkung: Ein Origin-Header enthält nicht in jedem Browser-Kontext garantiert einen normalen Origin-String. Manche Anfragen können ohne nutzbaren Wert eintreffen, und opake Kontexte können Origin: null erzeugen.

Meine Regel ist, einen fehlenden oder opaken Origin als fehlgeschlagene Origin-Prüfung zu behandeln, sofern die Anwendung nicht über einen ausdrücklich vorgesehenen alternativen Validierungsmechanismus verfügt. Der literale Wert null sollte nicht allein aus Kompatibilitätsgründen zu einem vertrauenswürdigen Origin werden.

Das ist eine Fail-closed-Entscheidung. Wenn ein legitimer Ablauf einen Kontext ohne nutzbaren Origin unterstützen muss, sollte diese Ausnahme ausdrücklich entworfen werden, statt die allgemeine Prüfung stillschweigend abzuschwächen.

Das Handoff-Credential gehört nicht in die URL

Ich bevorzuge es, das temporäre Ticket per Top-Level-Formular-POST zu senden, statt es in einen Query-String zu schreiben.

Eine verallgemeinerte clientseitige Variante sieht so aus:

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();

Das Ziel akzeptiert das Ticket aus dem erwarteten POST-Body und lehnt gleichwertige Versuche ab, es über die URL bereitzustellen.

Dadurch wird das Credential nicht harmlos, aber es bleibt aus normalen Navigations-URLs heraus, und das Risiko einer unbeabsichtigten Offenlegung über URL-Verlauf, kopierte Links, Referrer und URL-orientiertes Logging wird reduziert.

Authentifizierte Cross-Site-Links können sich selbst aufwerten

Wenn die aktuelle Website bereits weiß, dass der Benutzer authentifiziert ist, kann ein gewöhnlicher Link zu einer anderen vertrauenswürdigen Website opportunistisch den Handoff durchführen.

Der Link sollte weiterhin ein echtes href enthalten. SSO ist eine Erweiterung der normalen Navigation, kein Ersatz dafür.

Ein vereinfachter Client-Ablauf sieht so aus:

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

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

event.preventDefault();

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

postTicketToTarget(ticket);

Die Produktionsversion sollte zusätzlich vor doppelten Übermittlungen schützen, das Ziel gegen ein vertrauenswürdiges Register validieren und auf normale Navigation zurückfallen, wenn der Handoff nicht erstellt werden kann.

Natives Browserverhalten sollte nativ bleiben. Mittelklicks, Klicks mit Modifikatortasten, Downloads und Links, die für einen anderen Browsing-Kontext bestimmt sind, sollten nicht stillschweigend in Authentifizierungsanfragen umgewandelt werden.

Der Log-In-Button auf der zweiten Website braucht einen bekannten Issuer

Der schwierigere Fall beginnt, wenn der Benutzer Website B direkt öffnet. Website B hat keine lokale Session und behandelt den Besucher daher korrekt als anonym, selbst wenn der Browser auf Website A noch eine gültige Session hat.

Website B kann das Host-only-Cookie von Website A nicht einsehen. Sie kann auch nicht zu einer beliebigen Website C weiterleiten und erwarten, dass diese die Sessions des restlichen Systems entdeckt. Jede nicht verwandte Root-Domain kann nur die Cookies sehen, die zu ihr gehören.

Das bedeutet, dass ein anonymer Log-In-Ablauf einen bekannten Issuer benötigt: entweder einen kanonischen Authentifizierungs-Origin oder eine gezielt ausgewählte vertrauenswürdige Website, von der erwartet wird, dass sie die wiederverwendbare Login-Session hält.

Für den einfachen Fall Website A → Website B ist die Abfolge:

  1. Der Benutzer meldet sich auf Website A an.
  2. Später öffnet der Benutzer Website B direkt.
  3. Website B hat keine Session und zeigt daher Log In an.
  4. Der Benutzer klickt auf Log In.
  5. Der Browser führt eine Top-Level-Navigation zu Website A aus, dem ausgewählten Issuer.
  6. Website A kann ihr eigenes vorhandenes Host-only-Session-Cookie lesen.
  7. Da diese Session noch gültig ist, stellt Website A sofort einen einmaligen Handoff für Website B aus.
  8. Der Browser sendet das Ticket per POST an Website B.
  9. Website B erstellt ihre eigene lokale Session und bringt den Benutzer zur angeforderten Seite zurück.

Aus Sicht des Benutzers ist das eine kurze Weiterleitung zu Website A und zurück. Es ist kein Passwort nötig, weil Website A bereits die Session besitzt, die sie beobachten darf.

Die Einschränkung ist ebenso wichtig: Wenn der Benutzer nur auf Website C angemeldet ist, hilft eine Weiterleitung von Website B zu Website A nicht, es sei denn, Website A ist ein echter zentraler Authentifizierungs-Broker mit eigenständigem Wissen über den Anmeldestatus. Eine gemeinsame Benutzerdatenbank macht Browser-Sessions nicht gemeinsam nutzbar.

Warum ein kanonischer Issuer das System vereinfacht

Mit wachsender Zahl unabhängiger Websites beseitigt ein kanonischer Issuer die Notwendigkeit, Sessions über beliebige Domains hinweg zu suchen.

Ohne ihn müsste eine anonyme Website B irgendwie entscheiden, welche andere Website aktuell ein nützliches Cookie haben könnte. Website A kann das Cookie von Website C nicht einsehen, Website C kann das Cookie von Website A nicht einsehen, und Website B kann keines von beiden einsehen.

Ein kanonischer Issuer gibt jedem anonymen Login-Versuch einen vorhersehbaren Pfad:

Website B
  → Authentifizierungs-Issuer
  → Issuer-Session vorhanden?
      ja   → einmaligen Handoff erstellen
      nein → normalen Login anzeigen
  → Website B konsumiert Handoff
  → Website B erstellt lokale Session

Der Issuer muss den Rest der Anwendung nicht bereitstellen. Seine relevante Aufgabe besteht darin, die wiederverwendbare Authentifizierungs-Session zu besitzen und eng begrenzte Handoffs an vertrauenswürdige Websites auszustellen.

In einem kleineren System kann eine bestehende Website diese Rolle übernehmen. Bei einer breiteren Architektur kann ein dedizierter Authentifizierungs-Origin das Vertrauensmodell leichter verständlich machen.

Nicht jeden Besucher über den Issuer umleiten

Der Issuer-Ablauf sollte nicht automatisch bei jedem anonymen Seitenaufruf ausgeführt werden.

Jeden Besucher nur zur Prüfung auf eine bestehende Session zu einer anderen Domain umzuleiten, erzeugt unnötige Navigation, zusätzliche Latenz, mehr Fehlermöglichkeiten, unruhigere Analytics und komplizierteres Caching-Verhalten.

Für öffentliche, SEO-sensitive Seiten passt das ebenfalls schlecht. Ein Crawler oder gewöhnlicher Besucher, der eine öffentliche URL anfordert, sollte diese öffentliche Seite direkt erhalten, statt zunächst zu einem Authentifizierungsumweg gezwungen zu werden.

Meine Regel lautet:

Den Issuer nicht beim Laden der Seite prüfen. Cross-Domain-Authentifizierung erst nach einer ausdrücklichen Log-In-Aktion oder einer absichtlichen authentifizierten Cross-Site-Navigation starten.

So bleibt öffentlicher Traffic einfach, während die Authentifizierung für einen Benutzer, der auf dem ausgewählten Issuer bereits eine gültige Session hat, weiterhin schnell ist.

Die Validierung des Rückkehrpfads verdient mehr Sorgfalt als eine Präfixprüfung

Der Handoff muss sich in der Regel merken, wo der Browser anschließend landen soll. Dieser Wert kann zu einem Open Redirect werden, wenn er als beliebige vom Client kontrollierte URL behandelt wird.

Wo möglich, ist es am sichersten, überhaupt keine vollständige Ziel-URL zu akzeptieren. Ein kurzer Bezeichner oder ein serverseitiges Mapping für bekannte Ziele reduziert den erforderlichen Aufwand für URL-Parsing und -Validierung.

Wenn ein relativer Pfad akzeptiert werden muss, sollte er sorgfältig validiert und auf die Ziel-Website beschränkt werden.

Ein bewusst vereinfachtes Beispiel — keine vollständige Routine zur Redirect-Validierung — ist:

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

Eine echte Implementierung muss außerdem Decoding-Verhalten, fehlerhafte Eingaben, Steuerzeichen, Normalisierung, Routing-Regeln der Anwendung sowie alle Transformationen durch Proxys oder Frameworks berücksichtigen, die stattfinden, bevor der Redirect erzeugt wird.

Die Sicherheitsinvariante ist einfacher als die Parserdetails: Der Handoff darf einen erlaubten Ort in der Zielanwendung auswählen, niemals ein beliebiges externes Ziel.

Die Fehlerfälle sind wichtiger als der Happy Path

Ein erfolgreicher Login beweist nur, dass der grundlegende Ablauf funktioniert. Die nützlicheren Tests prüfen die Vertrauensgrenzen.

Ich habe das Verhalten unter anderem für folgende Fälle überprüft:

  • eine nicht authentifizierte Quelle versucht, einen Handoff auszustellen;
  • fehlender CSRF-Schutz;
  • ein fehlender, opaker oder unerwarteter Origin;
  • ein nicht vertrauenswürdiges Ziel;
  • ein unsicheres Rückkehrziel;
  • ein Ticket, das dem falschen Ziel präsentiert wird;
  • ein Consume-Versuch vom falschen Origin;
  • Replay eines Tickets;
  • Ablauf eines Tickets;
  • ein Benutzer, der nicht mehr existiert;
  • ein Ticket, das statt im erwarteten POST-Body über die URL übermittelt wird.

Zwei Invarianten sind besonders nützlich für direkte Tests: Eine abgelehnte Anfrage darf nicht versehentlich ein Ticket zerstören, das für den legitimen Ablauf noch gültig ist, und ein erfolgreiches Konsumieren muss dieses Ticket sofort unbrauchbar machen.

Das Protokoll skaliert ohne paarweise Integrationen

Das Handoff-Design ändert sich nicht grundlegend, wenn weitere Websites hinzukommen.

Jede teilnehmende Website benötigt denselben kleinen Vertrag:

  • eine stabile logische Kennung;
  • einen vertrauenswürdigen öffentlichen Origin;
  • Zugriff auf die gemeinsame Kontoidentität;
  • eine Operation zur Handoff-Ausstellung für authentifizierte Quellen;
  • eine Handoff-Consume-Operation;
  • ein Register vertrauenswürdiger Websites;
  • eine an den Host gebundene lokale Session;
  • eine sichere Richtlinie für Rückkehrziele;
  • und einen bekannten Issuer für anonyme Log-In-Abläufe.

Die wichtige Skalierungsentscheidung besteht darin, benutzerdefinierte paarweise Authentifizierungslogik zu vermeiden. Eine bereits authentifizierte Website kann dieselbe Art von Handoff an eine andere vertrauenswürdige Website ausstellen. Eine anonyme Website kann den Browser zum bekannten Issuer schicken.

Damit basiert das System auf einem einzigen Protokoll statt auf einer wachsenden Matrix von Sonderfällen.

Portabler Login ist etwas anderes als die Weitergabe eines Logouts

Unabhängige hostgebundene Sessions machen eine weitere nützliche Unterscheidung sichtbar: Einen Login portabel zu machen, definiert nicht automatisch einen globalen Logout.

Wenn sich der Benutzer auf Website A abmeldet, kann die lokale Session einer anderen Website gültig bleiben, sofern das Backend sie nicht absichtlich widerruft. Das ist eine Richtlinie des Session-Managements, kein Fehler des SSO-Handoffs.

Ein System kann zwischen lokalem Logout, globalem Logout und explizitem Session-Management wählen. Diese Entscheidung vom Handoff getrennt zu halten, macht beide Mechanismen leichter nachvollziehbar.

Das Modell, das ich verwende

Die Architektur wurde einfacher, als ich aufhörte, das Problem als „Login-Cookies über Domains hinweg teilen“ zu beschreiben.

Es gibt eine globale Identität, jede Domain besitzt ihre eigene Browser-Session, und ein kurzlebiger einmaliger Handoff erlaubt einem authentifizierten vertrauenswürdigen Origin, die Erstellung einer weiteren lokalen Session zu autorisieren.

Für den direkten Einstieg in eine anonyme Website gilt eine zusätzliche Regel: Der Browser muss zu einem Issuer geschickt werden, der die bestehende Session des Benutzers tatsächlich sehen kann. Hat sich der Benutzer zuvor bei diesem Issuer angemeldet, kann der Hin- und Rückweg nahezu unsichtbar sein. Hat der Issuer keine gültige Session, muss er auf die normale Authentifizierung zurückfallen, statt so zu tun, als wäre die Session einer anderen, nicht verwandten Domain sichtbar.

Dieses Modell wahrt die Domain-Grenzen des Browsers, vermeidet unnötige Authentifizierungs-Redirects für öffentliche Besucher und bietet dennoch das gewünschte Erlebnis: beim Issuer anmelden, später eine andere vertrauenswürdige Website öffnen, auf Log In klicken und mit einer neuen lokalen Session zurückkehren, ohne das Passwort erneut einzugeben.