SAML is een fractal van slecht ontwerp: wat dat betekent voor je .NET-SSO
Trail of Bits noemt SAML een fractal van slecht ontwerp, en de HN-discussie ging los. Dit is waarom het klopt, waar je .NET-SP-code de gevaarlijke bochten neemt, en hoe je met OIDC verder komt zonder je enterprise-klanten kwijt te raken.
Jean-Pierre Broeders
Freelance .NET Developer
Deze week zette Trail of Bits een stuk bovenaan Hacker News met een titel die weinig ruimte laat voor twijfel: "SAML: a fractal of bad design". De strekking is dat SAML de SSO-industrie mogelijk heeft gemaakt en tegelijk vastzit aan keuzes die het protocol op elk niveau lastig en gevaarlijk houden. Ik heb genoeg SAML-integraties in .NET gebouwd om te weten dat dat geen flauwe provocatie is. Het klopt gewoon, en het klopt op precies de plekken waar je code de mist ingaat.
Dus laat ik het praktisch maken. Waarom is SAML zo moeilijk goed te doen, waar in een typische .NET service provider gaat het fout, en wat betekent het dat, in de woorden van het stuk, alle wegen naar OIDC leiden. Met code die je kunt lezen en checks die je vandaag kunt uitvoeren.
Waar het misgaat begint bij XML
SAML is opgebouwd op XML, en dat is niet neutraal. XML heeft namespaces, entities, DTD's, CDATA en processing instructions, en elk van die dingen is een extra manier waarop twee parsers dezelfde bytes anders kunnen lezen. Trail of Bits noemt vijf grondproblemen, en ze hangen allemaal aan die XML-basis. De canonicalisatie die nodig is om een handtekening te verifiëren is foutgevoelig. De handtekening zit ingebed in het document dat hij tekent, wat de byte-representatie meteen ambigu maakt. De spec is een kitchen sink waarvan bijna iedereen dezelfde kleine subset gebruikt. En het geheel gaat uit van netwerkaannames uit een tijd zonder mobiel, SPA's of IoT.
Die laatste twee zijn ergernissen. De eerste drie zijn beveiligingslekken die met naam en toenaam in de literatuur staan. XML signature wrapping. Billion laughs via entity expansion. XXE. Parser differential attacks. Kelby Ludwig liet in 2018 zien hoe een XML-comment een gebruikersnaam kon splitsen zodat de validator iets anders las dan de applicatie, en die klasse aanvallen is daarna niet opgedroogd. Tussen 2020 en 2025 kwamen er telkens nieuwe varianten bovendrijven.
Het punt is niet dat SAML-implementeerders lui zijn. Het punt is dat het protocol je dwingt om precies dat te doen waar computers slecht in zijn: twee keer exact dezelfde betekenis uit dezelfde tekst halen, in twee verschillende stukken code.
Signature wrapping, en waarom .NET je niet redt
De gemeenste aanval is signature wrapping, en die snap je pas echt als je ziet waar hij op leunt. Een SAML-response is een XML-document met daarin een assertion, en die assertion is ondertekend. De handtekening zit in het document zelf, als een <ds:Signature>-element dat naar een ander element verwijst via een Reference URI.
<samlp:Response>
<saml:Assertion ID="_a1b2c3">
<saml:Subject>
<saml:NameID>alice@klant.nl</saml:NameID>
</saml:Subject>
<ds:Signature>
<ds:SignedInfo>
<ds:Reference URI="#_a1b2c3">...</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>...</ds:SignatureValue>
</ds:Signature>
</saml:Assertion>
</samlp:Response>
De handtekening zegt: element met ID="_a1b2c3" is echt. Nu komt de val. Een aanvaller pakt een geldige response, laat de ondertekende assertion staan, en plakt er een tweede, niet ondertekende assertion bij met een andere NameID en een ander ID. Je code verifieert de handtekening, die klopt want het originele element is niet aangeraakt, en leest daarna de identiteit uit. Als het stuk code dat de identiteit leest een andere assertion oppakt dan het stuk dat de handtekening controleerde, log je iemand anders in.
Hier is de scherpe rand voor .NET. De klasse waar je op leunt, System.Security.Cryptography.Xml.SignedXml, controleert of een handtekening wiskundig klopt bij een element. Ze vertelt je niet welk element je applicatie vervolgens uitleest. Die twee stappen ontkoppeld hebben is de hele kwetsbaarheid. En SignedXml heeft een geschiedenis met dit soort gevallen; de aanbevolen aanpak van Microsoft is al jaren om na verificatie expliciet te controleren dat het ondertekende element ook echt het element is dat je gebruikt, in plaats van aan te nemen dat "de handtekening klopte" genoeg is.
// Fout: handtekening klopt, dus we vertrouwen het hele document.
var signedXml = new SignedXml(doc);
var sig = doc.GetElementsByTagName("Signature", XmlDsigNs)[0] as XmlElement;
signedXml.LoadXml(sig);
if (signedXml.CheckSignature(cert, verifySignatureOnly: true))
{
var nameId = doc.GetElementsByTagName("NameID")[0].InnerText; // welke?
SignIn(nameId);
}
Die GetElementsByTagName("NameID")[0] is het gat. Hij pakt de eerste NameID in het document, en niets garandeert dat die binnen de ondertekende assertion valt. Je wilt de referenties uit SignedInfo uitlezen, het element opzoeken waar die referentie exact naar wijst via zijn ID, en alleen daaruit lezen. Elke serieuze SAML-library voor .NET, zoals Sustainsys.Saml2 of ITfoxtec, doet dit inmiddels, en dat is precies de reden dat je dit soort dingen nooit zelf moet bouwen. Maar zelfs met een library moet je weten dat dit de gevoelige plek is, want een verkeerd geconfigureerde WantAssertionsSigned of een custom parsestap zet de deur zo weer open.
XXE en billion laughs: hoe je XML in .NET veilig laadt
Voordat een handtekening aan bod komt, moet je de response parsen, en daar zit de tweede laag ellende. Een aanvaller stuurt XML met een externe entity of een DTD die zichzelf exponentieel uitvouwt. Bij XXE laat je je server een bestand van schijf lezen of een interne URL ophalen. Bij billion laughs eet een paar regel XML al je geheugen op.
De verdediging in .NET is niet ingewikkeld, maar je moet hem bewust aanzetten. Nieuwere runtimes hebben veiligere defaults dan het .NET Framework van tien jaar terug, maar leun daar niet op. Zet het expliciet.
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // geen DTD, dus geen billion laughs
XmlResolver = null, // geen externe entities, dus geen XXE
MaxCharactersFromEntities = 0,
};
using var reader = XmlReader.Create(stream, settings);
var doc = new XmlDocument { XmlResolver = null };
doc.Load(reader);
Twee regels doen het zware werk. DtdProcessing.Prohibit gooit een exception zodra er een DTD in de response zit, wat de hele entity-expansie-familie afsnijdt. XmlResolver = null zorgt dat er nooit iets van buiten wordt opgehaald. Vergeet die XmlResolver niet ook op het XmlDocument zelf te zetten, want de reader en het document hebben elk hun eigen resolver, en één ervan open laten staan is genoeg.
Dit is defensie die werkt, maar merk op wat je aan het doen bent. Je bouwt een muur van mitigaties rond een dataformaat dat deze aanvallen überhaupt mogelijk maakt. Dat is het gevoel dat het Trail of Bits-stuk zo goed vangt: je bent constant bezig een protocol veilig te maken dat je actief tegenwerkt.
Wat OIDC concreet anders doet
OpenID Connect lost dit niet op met betere discipline. Het lost het op door de scherpe randen weg te ontwerpen. De token is een JWT, en een JWT is drie base64url-stukken gescheiden door punten: header, payload, handtekening. De handtekening staat naast de data, niet erin. Er is geen canonicalisatie nodig, want je tekent letterlijk de bytes die er staan. Geen enveloped signature, dus geen wrapping. Geen XML, dus geen XXE en geen billion laughs. De hele klasse aanvallen die SAML jaar na jaar teistert bestaat hier simpelweg niet.
En in .NET is het bijna saai om op te zetten, wat een compliment is.
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://login.klant.nl";
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = "code"; // authorization code flow
options.UsePkce = true; // PKCE aan
options.Scope.Add("openid");
options.Scope.Add("profile");
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
});
Wat hier gebeurt is de moeite waard om even bij stil te staan. Authority wijst naar de identity provider, en de handler haalt zelf de discovery-metadata en de signing keys op via het bekende /.well-known/openid-configuration-endpoint. Sleutelrotatie werkt daardoor vanzelf: als de IdP zijn keys verwisselt, ziet je app de nieuwe via de JWKS-endpoint zonder dat je iets deployt. Bij SAML upload je certificaten met de hand en krijg je een outage op de dag dat er eentje verloopt en niemand eraan dacht. PKCE en de authorization code flow doen de rest. De handler valideert de handtekening, de issuer, de audience en de expiry, en jij krijgt claims.
Vergelijk dat met de SAML-kant, waar je een SP moet registreren, metadata-XML moet uitwisselen, certificaten moet beheren, en per identity provider net weer een andere interpretatie van de spec tegenkomt. Ik heb dagen verloren aan een integratie waarbij de IdP een NameID-format stuurde dat net niet matchte met wat mijn SP verwachtte, puur omdat de spec beide toestaat.
De migratie die je niet in één sprint doet
Nu de nuance, want "gebruik gewoon OIDC" is makkelijk roepen. Als je een SaaS bouwt en verkoopt aan grote bedrijven, dan is SAML geen keuze die jij maakt. De IT-afdeling van je klant draait Okta, Entra of een oude ADFS, en hun security-team vinkt SAML af op een lijst. Je kunt niet tegen een enterprise-deal van zes cijfers zeggen dat hun IdP van gisteren is. Dat is de echte reden dat SAML blijft leven, en geen enkel blogstuk verandert dat op korte termijn.
Wat je wel kunt doen is de blast radius beperken. Behandel SAML als een externe integratie aan de rand van je systeem, niet als iets dat diep in je auth-laag zit. Laat een gehardende library of een dedicated broker de SAML-response afhandelen, en zet die zo snel mogelijk om naar je interne token, bij voorkeur een JWT. Je eigen services praten dan alleen OIDC en JWT, en de rommelige XML blijft in één component dat je apart kunt patchen en testen. Providers als Auth0, Entra External ID of Keycloak doen precies dit: zij slikken SAML in en geven jou OIDC terug.
En test die ene component alsof je hem haat. Gooi een response met een tweede, niet ondertekende assertion erin. Gooi een DTD erin. Verwissel de NameID na de handtekening. Als je broker of library daar niet op klapt met een nette afwijzing, heb je een probleem gevonden voordat een aanvaller dat deed.
Hoe ik het vandaag zou beslissen
Bouw je iets nieuws en heb je de vrijheid, kies OIDC en kijk niet om. De aanvalsklasse die SAML draagt is in OIDC ontworpen om niet te bestaan, en de .NET-ondersteuning is eersteklas en ingebouwd. Moet je SAML ondersteunen omdat je klanten het eisen, prima, maar bouw het nooit zelf en isoleer het aan de rand. Laat een onderhouden library of een externe broker de XML aanraken, verifieer expliciet dat je het ondertekende element uitleest en niet zomaar het eerste dat lijkt te passen, en zet je XML-parser dicht met DtdProcessing.Prohibit en een lege XmlResolver.
De titel van Trail of Bits is scherp, maar het onderliggende punt is nuchter. SAML werkt, miljarden logins draaien erop, en het gaat niet weg. Het is alleen een protocol dat je op je hoede houdt op plekken waar een beter ontwerp je met rust had gelaten. Dat verschil voel je pas echt als je een keer aan de OIDC-kant hebt gewerkt en merkt hoeveel zorgen je gewoon niet meer hebt.
Veelgestelde vragen over SAML en OIDC in .NET
Waarom is XML signature wrapping zo lastig te voorkomen in een .NET service provider?
Omdat verificatie en uitlezen twee losse stappen zijn: SignedXml bevestigt dat een handtekening bij een element klopt, maar jouw code kiest daarna zelf welk element ze uitleest. Als die twee elementen niet gegarandeerd hetzelfde zijn, kan een aanvaller een tweede, niet ondertekende assertion bijplakken en die laten uitlezen, dus je moet de referentie uit SignedInfo volgen en strikt alleen daaruit lezen.
Moet ik zelf een SAML-parser bouwen in .NET? Nee, nooit. De aanvalsklassen rond canonicalisatie, signature wrapping en XXE zijn precies de dingen die volwassen libraries zoals Sustainsys.Saml2 en ITfoxtec na jaren bugfixes goed doen, en die je zelf vrijwel zeker verkeerd doet. Gebruik een onderhouden library of een externe broker en houd je eigen code eruit.
Hoe zet ik XXE en billion laughs uit bij het parsen van SAML in .NET?
Zet DtdProcessing op Prohibit zodat DTD's en entity-expansie worden geweigerd, en zet XmlResolver op null op zowel de XmlReaderSettings als het XmlDocument zodat er nooit externe entities worden opgehaald. Leun niet op de runtime-defaults maar configureer dit expliciet, want één open resolver is genoeg om de deur open te zetten.
Als OIDC beter is, waarom gebruiken enterprises dan nog SAML? Omdat hun IdP's en security-processen erop gebouwd zijn en SAML op elke compliance-checklist staat. Voor een SaaS die aan grote bedrijven verkoopt is SAML-ondersteuning een verkoopvereiste, geen technische voorkeur, dus de praktische route is SAML aan de rand accepteren en intern zo snel mogelijk naar OIDC en JWT omzetten.
Regelt OIDC in .NET sleutelrotatie automatisch? Ja. De OpenIdConnect-handler haalt de signing keys op via de JWKS-endpoint uit de discovery-metadata van de Authority, dus als de identity provider zijn keys wisselt pikt je app de nieuwe vanzelf op zonder deploy. Bij SAML beheer je certificaten met de hand en krijg je een outage zodra er eentje ongemerkt verloopt.
Verder lezen
- API Security Best Practices: bescherm je REST APIs
- GitHub Actions beveiligen: secrets, OIDC en permissions goed regelen
- CORS-fouten en request validation: de blinde vlekken in API security
Conclusie: Behandel SAML als een gehard grensgeval, niet als je auth-laag. Isoleer de XML in een onderhouden library of broker, verifieer expliciet welk element je uitleest, en zet intern zo snel mogelijk over op OIDC, waar de gevaarlijkste aanvalsklasse simpelweg niet bestaat.
Bronnen: SAML: a fractal of bad design (Trail of Bits) · Hacker News-discussie.
