Ik wilde dat meerdere onafhankelijke websites zich als één accountsysteem gedroegen zonder de normale browserisolatie te verzwakken. Een gebruiker moest zich op het ene domein kunnen aanmelden, later rechtstreeks een ander domein kunnen openen, op Log In kunnen klikken en daar verder kunnen gaan zonder opnieuw inloggegevens in te voeren.
De belangrijke beperking was dat dit onafhankelijke rootdomeinen waren. Een sessiecookie die door het ene rootdomein is aangemaakt, kan niet simpelweg worden uitgebreid naar een niet-gerelateerd rootdomein. Het ontwerp werd veel eenvoudiger te doorgronden toen ik twee concepten van elkaar scheidde die vaak door elkaar worden gehaald: globale identiteit en lokale browsersessie.
Het resulterende model is eenvoudig: de backend herkent één gedeelde accountidentiteit, elk domein bezit zijn eigen hostgebonden sessie, en met een kortlevende eenmalige handoff kan een geauthenticeerde vertrouwde origin het aanmaken van een nieuwe sessie op een andere vertrouwde origin autoriseren.
Een globale identiteit vereist geen gedeelde cookie
Het account kan globaal zijn, ook als browsersessies lokaal zijn.
Elke site gebruikt een host-only-sessiecookie. Een cookie die door het ene rootdomein is uitgegeven, kan via het attribuut Domain niet worden uitgebreid naar een niet-gerelateerd rootdomein. Dat attribuut kan het bereik van een cookie alleen verruimen binnen de domeinhiërarchie die de uitgevende host mag gebruiken.
Voor een sessie die aan één host gebonden moet blijven, is een cookie met het voorvoegsel __Host- een passende keuze. Conceptueel:
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=LaxDe belangrijke eigenschappen zijn de grenzen:
- Site A kan de host-only-sessiecookie van Site B niet lezen.
- Site B kan de host-only-sessiecookie van Site A niet lezen.
- Beide sessies kunnen nog steeds naar dezelfde backendidentiteit verwijzen.
Cross-domain-SSO moet daarom niet proberen een cookie van de ene site naar de andere te verplaatsen. Het moet een tijdelijk bewijs overdragen dat een vertrouwde bron de gebruiker al heeft geauthenticeerd.
Het patroon van de eenmalige handoff
De geauthenticeerde bron stelt een kleine handoff-operatie beschikbaar. De browser roept die alleen aan wanneer een authenticatieovergang tussen sites nodig is.
De backend genereert een cryptografisch willekeurig ticket, geeft de ruwe waarde één keer terug aan de browser en slaat alleen een hash op, samen met de minimale informatie die nodig is om de overdracht te valideren:
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}Het ticket moet kortlevend zijn en slechts voor één doel geldig zijn. Het verzoek om het ticket aan de bronzijde uit te geven, moet al geauthenticeerd en tegen CSRF beschermd zijn. Het aangevraagde doel en de terugkeerbestemming moeten worden gevalideerd voordat er iets wordt uitgegeven.
Vervolgens voert de browser een top-level POST naar het doel uit. Het doel consumeert het ticket, bepaalt de gedeelde gebruikersidentiteit, maakt zijn eigen lokale sessie aan en stuurt de browser door naar het bedoelde pad.
Conceptueel:
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)Het ticket is niet de doelsessie. Het is een eenmalige autorisatie om die doelsessie aan te maken.
Atomisch consumeren sluit het replayvenster
Een zwakkere implementatie zou het ticket laden, valideren, een sessie aanmaken en het ticket daarna verwijderen. Daardoor ontstaat een venster waarin twee verzoeken hetzelfde nog geldige record kunnen zien.
Het veiligere ontwerp combineert validatie en ongeldigmaking in één atomische consume-operatie. Als geen enkel record overeenkomt, mislukt de authenticatie. Als er wel een record wordt teruggegeven, is dat ticket al uit de bruikbare set verwijderd voordat de sessie wordt aangemaakt.
Hiermee kan het doel ook meerdere voorwaarden aan dezelfde operatie koppelen: het verwachte doel, de verwachte bron-origin, de tokenhash en de vervaltijd.
Origin-validatie moet fail-closed werken
Binding aan de origin voegt nog een nuttige eigenschap toe: een ticket dat via één vertrouwde bron is uitgegeven, mag niet vanaf een niet-gerelateerde origin kunnen worden geconsumeerd.
De origin-controle moet deel uitmaken van de consume-voorwaarde zelf. Dat betekent dat een verzoek vanaf de verkeerde origin het geldige ticket niet consumeert, terwijl de legitieme bron de handoff daarna nog steeds kan voltooien.
Er is één belangrijke kanttekening: een Origin-header bevat niet in elke browsercontext gegarandeerd een normale origin-string. Sommige verzoeken kunnen zonder bruikbare waarde binnenkomen, en opake contexten kunnen Origin: null opleveren.
Mijn regel is om een ontbrekende of opake Origin als een mislukte origin-controle te behandelen, tenzij de applicatie een expliciet alternatief validatiemechanisme heeft. De letterlijke waarde null mag niet alleen om compatibiliteitsredenen een vertrouwde origin worden.
Dit is een fail-closed-keuze. Als een legitieme flow een context zonder bruikbare origin moet ondersteunen, moet die uitzondering expliciet worden ontworpen in plaats van de algemene controle stilzwijgend te verzwakken.
Houd de handoff-credential uit de URL
Ik geef er de voorkeur aan het tijdelijke ticket via een top-level formulier-POST te versturen in plaats van het in een querystring te zetten.
Een gegeneraliseerde client-side versie ziet er zo uit:
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();Het doel accepteert het ticket uit de verwachte POST-body en weigert gelijkwaardige pogingen om het via de URL aan te leveren.
Dit maakt de credential niet onschadelijk, maar houdt hem wel uit gewone navigatie-URL’s en vermindert onbedoelde blootstelling via URL-geschiedenis, gekopieerde links, referrers en URL-georiënteerde logging.
Geauthenticeerde cross-site-links kunnen zichzelf upgraden
Als de huidige site al weet dat de gebruiker geauthenticeerd is, kan een gewone link naar een andere vertrouwde site opportunistisch de handoff uitvoeren.
De link moet nog steeds een echte href bevatten. SSO is een uitbreiding van normale navigatie, geen vervanging ervan.
Een vereenvoudigde clientflow ziet er zo uit:
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);De productieversie moet ook bescherming bieden tegen dubbele inzendingen, het doel valideren aan de hand van een vertrouwd register en terugvallen op normale navigatie als de handoff niet kan worden aangemaakt.
Native browsergedrag moet native blijven. Middelklikken, klikken met modificatietoetsen, downloads en links die voor een andere browsercontext zijn bedoeld, mogen niet stilzwijgend worden omgezet in authenticatieverzoeken.
De Log In-knop op de tweede site heeft een bekende issuer nodig
Het moeilijkere geval begint wanneer de gebruiker Site B rechtstreeks opent. Site B heeft geen lokale sessie en behandelt de bezoeker daarom terecht als anoniem, ook als de browser op Site A nog een geldige sessie heeft.
Site B kan de host-only-cookie van Site A niet inspecteren. Site B kan ook niet naar een willekeurige Site C doorsturen en verwachten dat die site sessies van de rest van het systeem ontdekt. Elk niet-gerelateerd rootdomein kan alleen de cookies zien die erbij horen.
Dat betekent dat een anonieme Log In-flow een bekende issuer nodig heeft: ofwel een canonieke authenticatie-origin, ofwel een specifiek geselecteerde vertrouwde site waarvan wordt verwacht dat die de herbruikbare inlogsessie bewaart.
Voor het eenvoudige geval Site A → Site B is de volgorde:
- De gebruiker meldt zich aan op Site A.
- Later opent de gebruiker Site B rechtstreeks.
- Site B heeft geen sessie en toont daarom Log In.
- De gebruiker klikt op Log In.
- De browser voert een top-level navigatie uit naar Site A, de geselecteerde issuer.
- Site A kan zijn eigen bestaande host-only-sessiecookie lezen.
- Omdat die sessie nog geldig is, geeft Site A onmiddellijk een eenmalige handoff voor Site B uit.
- De browser verstuurt het ticket via POST naar Site B.
- Site B maakt zijn eigen lokale sessie aan en brengt de gebruiker terug naar de aangevraagde pagina.
Vanuit het perspectief van de gebruiker is dit een snelle redirect naar Site A en terug. Er is geen wachtwoord nodig, omdat Site A al de sessie heeft die deze site mag waarnemen.
De beperking is net zo belangrijk: als de gebruiker alleen op Site C is ingelogd, helpt het niet om Site B naar Site A door te sturen, tenzij Site A een echte centrale authenticatiebroker is met eigen, onafhankelijke kennis van de inlogstatus. Een gedeelde gebruikersdatabase maakt browsersessies niet gedeeld.
Waarom een canonieke issuer het systeem eenvoudiger maakt
Naarmate het aantal onafhankelijke sites groeit, maakt een canonieke issuer het zoeken naar sessies over willekeurige domeinen overbodig.
Zonder zo’n issuer zou een anonieme Site B op de een of andere manier moeten bepalen welke andere site op dat moment een bruikbare cookie heeft. Site A kan de cookie van Site C niet inspecteren, Site C kan de cookie van Site A niet inspecteren en Site B kan geen van beide inspecteren.
Een canonieke issuer geeft elke anonieme inlogpoging één voorspelbaar pad:
Site B
→ authenticatie-issuer
→ issuer-sessie aanwezig?
ja → eenmalige handoff aanmaken
nee → normale login tonen
→ Site B consumeert handoff
→ Site B maakt lokale sessie aanDe issuer hoeft de rest van de applicatie niet te bedienen. De relevante verantwoordelijkheid van de issuer is het bezitten van de herbruikbare authenticatiesessie en het uitgeven van strak afgebakende handoffs aan vertrouwde sites.
Voor een kleiner systeem kan een bestaande site die rol vervullen. Voor een bredere architectuur kan een aparte authenticatie-origin het vertrouwensmodel inzichtelijker maken.
Stuur niet elke bezoeker via de issuer
De issuer-flow moet niet automatisch bij elke anonieme paginaweergave worden uitgevoerd.
Elke bezoeker naar een ander domein doorsturen alleen om te controleren of er een bestaande sessie is, veroorzaakt onnodige navigatie, extra latency, meer foutscenario’s, rumoerigere analytics en ingewikkelder cachinggedrag.
Het past ook slecht bij openbare, SEO-gevoelige pagina’s. Een crawler of gewone bezoeker die een openbare URL opvraagt, moet die openbare pagina rechtstreeks ontvangen in plaats van eerst door een authenticatieomweg te worden gedwongen.
De regel die ik gebruik is:
Controleer de issuer niet tijdens het laden van de pagina. Start cross-domain-authenticatie alleen na een expliciete Log In-actie of een bewuste geauthenticeerde cross-site-navigatie.
Zo blijft openbaar verkeer eenvoudig, terwijl authenticatie toch snel is voor een gebruiker die al een geldige sessie op de geselecteerde issuer heeft.
Validatie van het terugkeerpad verdient meer aandacht dan een prefixcontrole
De handoff moet meestal onthouden waar de browser daarna moet uitkomen. Die waarde kan een open redirect worden als hij als een willekeurige door de client beheerste URL wordt behandeld.
Waar mogelijk is het veiligste ontwerp om helemaal geen volledige bestemmings-URL te accepteren. Een korte identifier of een server-side mapping voor bekende bestemmingen vermindert de hoeveelheid URL-parsing en -validatie die nodig is.
Als een relatief pad moet worden geaccepteerd, valideer het dan zorgvuldig en beperk het tot de doelsite.
Een bewust vereenvoudigd voorbeeld — geen volledige routine voor redirectvalidatie — is:
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;
}Een echte implementatie moet ook rekening houden met decodeergedrag, ongeldige invoer, controletekens, normalisatie, routeringsregels van de applicatie en eventuele transformaties door proxies of frameworks die plaatsvinden voordat de redirect wordt geproduceerd.
De beveiligingsinvariant is eenvoudiger dan de parserdetails: de handoff mag een toegestane locatie binnen de doelapplicatie selecteren, nooit een willekeurige externe bestemming.
De foutgevallen zijn belangrijker dan het happy path
Een geslaagde login bewijst alleen dat het basispad werkt. De nuttigere tests beproeven de vertrouwensgrenzen.
Ik heb het gedrag geverifieerd voor gevallen zoals:
- een niet-geauthenticeerde bron die probeert een handoff uit te geven;
- ontbrekende CSRF-bescherming;
- een ontbrekende, opake of onverwachte origin;
- een niet-vertrouwd doel;
- een onveilige terugkeerbestemming;
- een ticket dat aan het verkeerde doel wordt aangeboden;
- een consume-poging vanaf de verkeerde origin;
- replay van een ticket;
- verlopen van een ticket;
- een gebruiker die niet meer bestaat;
- een ticket dat via de URL wordt aangeleverd in plaats van via de verwachte POST-body.
Twee invarianten zijn bijzonder nuttig om rechtstreeks te testen: een geweigerd verzoek mag niet per ongeluk een ticket vernietigen dat nog geldig is voor de legitieme flow, en een succesvolle consume moet dat ticket onmiddellijk onbruikbaar maken.
Het protocol schaalt zonder paarsgewijze integraties
Het handoff-ontwerp verandert niet fundamenteel wanneer er meer sites worden toegevoegd.
Elke deelnemende site heeft hetzelfde kleine contract nodig:
- een stabiele logische identifier;
- een vertrouwde openbare origin;
- toegang tot de gedeelde accountidentiteit;
- een handoff-uitgifteoperatie voor geauthenticeerde bronnen;
- een handoff-consume-operatie;
- een register met vertrouwde sites;
- een hostgebonden lokale sessie;
- een veilig beleid voor terugkeerbestemmingen;
- en een bekende issuer voor anonieme Log In-flows.
De belangrijke schaalkeuze is het vermijden van aangepaste paarsgewijze authenticatielogica. Een al geauthenticeerde site kan hetzelfde soort handoff uitgeven aan een andere vertrouwde site. Een anonieme site kan de browser naar de bekende issuer sturen.
Daardoor blijft het systeem gebaseerd op één protocol in plaats van op een groeiende matrix van speciale gevallen.
Overdraagbaarheid van login staat los van logout-propagatie
Onafhankelijke hostgebonden sessies maken nog een nuttig onderscheid zichtbaar: login overdraagbaar maken definieert niet automatisch globale logout.
Als de gebruiker zich afmeldt bij Site A, kan de lokale sessie van een andere site geldig blijven tenzij de backend die bewust intrekt. Dat is een sessiebeheerbeleid, geen fout in de SSO-handoff.
Een systeem kan kiezen tussen lokale logout, globale logout of expliciet sessiebeheer. Door die beslissing los te houden van de handoff zijn beide mechanismen eenvoudiger te doorgronden.
Het model dat ik gebruik
De architectuur werd eenvoudiger toen ik stopte met het probleem te beschrijven als “inlogcookies delen tussen domeinen”.
Er is één globale identiteit, elk domein bezit zijn eigen browsersessie, en met een kortlevende eenmalige handoff kan een geauthenticeerde vertrouwde origin het aanmaken van een andere lokale sessie autoriseren.
Bij rechtstreekse toegang tot een anonieme site geldt nog één extra regel: de browser moet naar een issuer worden gestuurd die de bestaande sessie van de gebruiker daadwerkelijk kan waarnemen. Als de gebruiker zich eerder bij die issuer heeft aangemeld, kan de heen-en-terugreis vrijwel onzichtbaar zijn. Als de issuer geen geldige sessie heeft, moet hij terugvallen op normale authenticatie in plaats van te doen alsof de sessie van een ander, niet-gerelateerd domein zichtbaar is.
Dit model respecteert de domeingrenzen van de browser, voorkomt onnodige authenticatieredirects voor openbare bezoekers en biedt toch de gewenste ervaring: aanmelden bij de issuer, later een andere vertrouwde site openen, op Log In klikken en terugkeren met een nieuwe lokale sessie zonder het wachtwoord opnieuw in te voeren.