Kurz gesagt. Die Validierung einer SAML-Response besteht aus zwei Hälften, und die meisten Vorfälle entstehen, weil nur die erste erledigt wird. Erstens: Prüfe die XML-Signatur mit dem Zertifikat des Identity Providers aus dessen Metadaten, nie mit dem aus KeyInfo in der Nachricht, und verlange genau eine Reference, die auf die ID der Assertion (oder der Response) zeigt, die du gleich verwenden willst. Zweitens: Lies jeden Wert, auf den du dich verlässt (die NameID, Attribute, Issuer, Audience, Recipient, Zeitangaben), aus dem Element, das diese Signatur abdeckt, statt das empfangene Dokument zu durchsuchen: Ein XML-Dokument kann neben dem signierten Element ein unsigniertes enthalten, das ihm zum Verwechseln ähnlich sieht. Wende dann die SAML-Regeln an: Die Audience ist deine Entity-ID, die Bearer-SubjectConfirmationData nennt deine Assertion-Consumer-URL und deinen Request, nichts ist abgelaufen, und jede Assertion-ID wird nur einmal akzeptiert.
Wie eine XML-Signatur auf das zeigt, was sie signiert
Eine XML-Signatur (XML Signature Syntax and Processing 1.1) signiert ein Dokument nicht als Bytefolge. Signiert wird ein Element SignedInfo, das ein oder mehrere Elemente Reference auflistet. Jede Reference hat eine URI, die Inhalt auswählt, eine Liste von Transforms, die darauf angewendet werden, und einen DigestValue des Ergebnisses. So sieht eine SAML-Signatur aus, wie sie der Test-Identity-Provider weiter unten ausstellt, hier mit gekürzten langen base64-Werten:
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_ef6e7d7e83384141a7a16e6fb9d52d61" Version="2.0" IssueInstant="2026-10-20T13:39:36Z" Destination="https://sp.example/saml/acs" InResponseTo="_req-7d1f">
<saml:Issuer>https://idp.example/saml</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion ID="_cf908bd7284743f8997ddd7943f6640c" Version="2.0" IssueInstant="2026-10-20T13:39:36Z">
<saml:Issuer>https://idp.example/saml</saml:Issuer>
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"/><ds:Reference URI="#_cf908bd7284743f8997ddd7943f6640c"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>genlRcOfIZpWA8BHZ0Eo42LeZcRMriskgTApPDxsPI0=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>cPF5v/9Ve6uUmgcn...</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIBPDCB4qADAgEC...</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">alice@example.com</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="2026-10-20T13:44:36Z" Recipient="https://sp.example/saml/acs" InResponseTo="_req-7d1f"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2026-10-20T13:39:36Z" NotOnOrAfter="2026-10-20T13:44:36Z">
<saml:AudienceRestriction><saml:Audience>https://sp.example/saml</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2026-10-20T13:39:36Z"><saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
</saml:AuthnContext></saml:AuthnStatement>
</saml:Assertion></samlp:Response>
Drei Details entscheiden, was geschützt ist:
URI="#_cf90…"ist eine Referenz innerhalb desselben Dokuments: Ausgewählt wird das Element, dessen AttributIDdiesen Wert hat, samt seinen Nachfahren (XMLDSig §4.4.3.2). Wo dieses Element im Dokument steht, ist nicht Teil der Signatur.- Die Transformation enveloped-signature entfernt vor der Digest-Berechnung das Element
ds:Signatureselbst, sodass die Signatur innerhalb dessen stehen kann, was sie signiert. - Exklusive Kanonisierung (
xml-exc-c14n#, hier ohne Kommentare) macht aus dem ausgewählten Element eine kanonische Bytefolge: Attributreihenfolge, Namespace-Deklarationen und Whitespace innerhalb von Tags werden normalisiert, Kommentare entfallen. Verschiedene Dokumente können deshalb dieselbe kanonische Form haben.
Die Validierung besteht aus der „Referenzvalidierung“ (jeden Digest neu berechnen), gefolgt von der „Signaturvalidierung“ über das kanonisierte SignedInfo (XMLDSig §3.2). Diese Validierung beantwortet genau eine Frage: Diese kanonischen Bytes wurden mit diesem Schlüssel signiert. Über den Rest des Dokuments sagt sie nichts. Die Spezifikation macht das sogar zur Abschnittsüberschrift, „Only What is Signed is Secure“ (§8.1.1), und ergänzt, dass Anwendungen mit den Daten arbeiten sollten, „die transformiert (einschließlich Kanonisierung) und signiert wurden“ (§8.1.3).
Was die SAML-Spezifikationen verlangen
SAML schränkt XMLDSig weiter ein (SAML Core 2.0 §5.4): Signaturen MÜSSEN enveloped sein; sie MÜSSEN „eine einzige <ds:Reference> enthalten, die eine Referenz innerhalb desselben Dokuments auf den Wert des ID-Attributs des Wurzelelements der signierten Assertion oder Protokollnachricht enthält“; exklusive Kanonisierung SOLLTE verwendet werden; und andere Transformationen als enveloped-signature und exklusive Kanonisierung SOLLTEN NICHT vorkommen. Verifizierer, die andere Transformationen akzeptieren, „MÜSSEN sicherstellen, dass kein Inhalt der SAML-Nachricht von der Signatur ausgenommen ist“.
Das Profil Web Browser SSO (SAML Profiles 2.0 §4.1.4) ergänzt die Prüfungen, durch die eine gültige Signatur erst „dieser Benutzer, für diesen Dienst, jetzt“ bedeutet:
- Beim HTTP-POST-Binding „MÜSSEN die enthaltenen Assertions signiert sein“ (§4.1.4.5). Eine signierte
Responseum eine unsignierte Assertion herum erfüllt das nicht. - Der Service Provider MUSS alle Signaturen prüfen und außerdem, dass
Recipientin der Bearer-SubjectConfirmationData„der URL des Assertion Consumer Service entspricht“, dass derenNotOnOrAfter„unter Berücksichtigung einer zulässigen Uhrenabweichung nicht überschritten ist“ und dassInResponseToder ID seinesAuthnRequestentspricht oder bei einer unaufgeforderten Response fehlt (§4.1.4.3). - Er MUSS „sicherstellen, dass Bearer-Assertions nicht erneut eingespielt werden, indem er die Menge der verwendeten ID-Werte vorhält“, und zwar so lange, wie die Assertion gültig wäre (§4.1.4.5).
- Die Bedingung
AudienceRestriction„ergibt genau dann Valid, wenn die SAML-Relying-Party zu mindestens einer der angegebenen Audiences gehört“ (SAML Core §2.5.1.4), undNotBeforeundNotOnOrAfterinConditionsbegrenzen die Gültigkeit der Assertion (§2.5.1.2).
Ein Validator, der nur signierte Daten liest
Der Validator unten nutzt für die XML-Signatur signxml 5.2.0. Die Dokumentation von signxml nennt die Regel „See what is signed“ und macht sie zur API: verify() gibt das signierte Element als signed_xml zurück, ohne die Kommentare, die die Signatur nicht abgedeckt hat, und location legt fest, wo ds:Signature stehen muss. Alles nach der Signaturprüfung liest aus result.signed_xml:
# Service provider side: verify the signature, then read every value from the signed Assertion only.
import datetime
from cryptography import x509
from signxml import SignatureConfiguration, XMLVerifier
SAMLP = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML = "urn:oasis:names:tc:SAML:2.0:assertion"
NS = {"samlp": SAMLP, "saml": SAML}
BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
class SamlError(Exception):
pass
def _time(value):
return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc)
def validate_response(xml: bytes, *, idp_cert: x509.Certificate, idp_entity_id: str, sp_entity_id: str,
acs_url: str, request_id: str, seen_assertion_ids: set, skew=60, now=None):
now = now or datetime.datetime.now(datetime.timezone.utc)
lag = datetime.timedelta(seconds=skew)
# 1. The signature: pinned IdP certificate from metadata (never the one in KeyInfo),
# exactly one Reference, ds:Signature where SAML puts it: inside the Assertion.
config = SignatureConfiguration(location=f"./{{{SAML}}}Assertion/", expect_references=1)
try:
result = XMLVerifier().verify(xml, x509_cert=idp_cert, expect_config=config)
except Exception as e: # signxml raises InvalidSignature, InvalidInput and others
raise SamlError(f"signature: {type(e).__name__}: {e}") from None
# 2. From here on, only the element the signature covers. Never the document passed in.
assertion = result.signed_xml
if assertion.tag != f"{{{SAML}}}Assertion":
raise SamlError(f"signed element is {assertion.tag}, not an Assertion")
aid = assertion.get("ID")
one = lambda path: assertion.find(path, NS)
if (one("saml:Issuer").text or "").strip() != idp_entity_id:
raise SamlError("issuer")
conditions = one("saml:Conditions")
if conditions is None:
raise SamlError("no Conditions")
if now + lag < _time(conditions.get("NotBefore")) or now - lag >= _time(conditions.get("NotOnOrAfter")):
raise SamlError("assertion is outside its validity window")
audiences = [a.text for a in conditions.findall("saml:AudienceRestriction/saml:Audience", NS)]
if sp_entity_id not in audiences:
raise SamlError(f"audience {audiences} is not this SP")
# 3. A bearer SubjectConfirmation for this ACS URL, this request, still valid.
data = one(f"saml:Subject/saml:SubjectConfirmation[@Method='{BEARER}']/saml:SubjectConfirmationData")
if data is None:
raise SamlError("no bearer SubjectConfirmationData")
if data.get("Recipient") != acs_url:
raise SamlError(f"recipient {data.get('Recipient')}")
if now - lag >= _time(data.get("NotOnOrAfter")):
raise SamlError("subject confirmation expired")
if data.get("InResponseTo") != request_id:
raise SamlError("InResponseTo does not match the AuthnRequest")
# 4. One use per assertion ID, for as long as the assertion could be accepted.
if aid in seen_assertion_ids:
raise SamlError(f"assertion {aid} already used")
seen_assertion_ids.add(aid)
name_id = one("saml:Subject/saml:NameID")
return {"name_id": "".join(name_id.itertext()), "assertion_id": aid}
Zum Vergleich sieht der Fehler so aus. Dieser Code ruft dieselbe Signaturprüfung auf, verwirft das Ergebnis und liest dann die erste Assertion, die er im Dokument findet:
# Anti-pattern, for comparison only: the signature is checked, but the values come from the
# document as received instead of from the element the signature covers.
from lxml import etree
from signxml import SignatureConfiguration, XMLVerifier
NS = {"samlp": "urn:oasis:names:tc:SAML:2.0:protocol", "saml": "urn:oasis:names:tc:SAML:2.0:assertion"}
def naive_validate(xml: bytes, *, idp_cert, sp_entity_id: str, acs_url: str, request_id: str):
config = SignatureConfiguration(location="./{urn:oasis:names:tc:SAML:2.0:assertion}Assertion/", expect_references=1)
XMLVerifier().verify(xml, x509_cert=idp_cert, expect_config=config) # result ignored
doc = etree.fromstring(xml)
assertion = doc.find("saml:Assertion", NS) # the first Assertion in the document
assert sp_entity_id in [a.text for a in assertion.findall(".//saml:Audience", NS)]
data = assertion.find(".//saml:SubjectConfirmationData", NS)
assert data.get("Recipient") == acs_url and data.get("InResponseTo") == request_id
return {"name_id": assertion.find("saml:Subject/saml:NameID", NS).text}
Getestet mit manipulierten Responses
Ein Test-Identity-Provider erzeugt bei jedem Lauf einen P-256-Schlüssel und ein selbstsigniertes Zertifikat und stellt signierte Responses aus; der Service Provider pinnt dieses Zertifikat:
# Lab identity provider: a key and self-signed certificate generated per run, and signed SAML Responses.
import datetime
import uuid
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from lxml import etree
from signxml import XMLSigner
from signxml.algorithms import CanonicalizationMethod, SignatureMethod
SAMLP = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML = "urn:oasis:names:tc:SAML:2.0:assertion"
def new_idp(name):
key = ec.generate_private_key(ec.SECP256R1())
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject).issuer_name(subject).public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5)).not_valid_after(now + datetime.timedelta(days=30))
.sign(key, hashes.SHA256())
)
return key, cert
def iso(t):
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
def response(key, cert, *, name_id, audience, recipient, in_response_to, issuer="https://idp.example/saml",
valid_for=300, now=None):
"""A Response with one signed Assertion: enveloped signature, exclusive C14N, Reference URI=#ID."""
now = now or datetime.datetime.now(datetime.timezone.utc)
until = now + datetime.timedelta(seconds=valid_for)
aid = f"_{uuid.uuid4().hex}"
xml = f"""<samlp:Response xmlns:samlp="{SAMLP}" xmlns:saml="{SAML}" ID="_{uuid.uuid4().hex}" Version="2.0"
IssueInstant="{iso(now)}" Destination="{recipient}" InResponseTo="{in_response_to}">
<saml:Issuer>{issuer}</saml:Issuer>
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion ID="{aid}" Version="2.0" IssueInstant="{iso(now)}">
<saml:Issuer>{issuer}</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">{name_id}</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData NotOnOrAfter="{iso(until)}" Recipient="{recipient}" InResponseTo="{in_response_to}"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="{iso(now)}" NotOnOrAfter="{iso(until)}">
<saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="{iso(now)}"><saml:AuthnContext>
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
</saml:AuthnContext></saml:AuthnStatement>
</saml:Assertion>
</samlp:Response>"""
root = etree.fromstring(xml.encode())
assertion = root.find(f"{{{SAML}}}Assertion")
signed = XMLSigner(
signature_algorithm=SignatureMethod.ECDSA_SHA256,
c14n_algorithm=CanonicalizationMethod.EXCLUSIVE_XML_CANONICALIZATION_1_0,
).sign(assertion, key=key, cert=[cert], reference_uri=aid)
# SAML requires ds:Signature right after saml:Issuer inside the signed element.
sig = signed.find("{http://www.w3.org/2000/09/xmldsig#}Signature")
signed.remove(sig)
signed.insert(1, sig)
root.replace(assertion, signed)
return etree.tostring(root)
Das Testskript schickt beiden Funktionen zehn Responses: eine gültige, eine frische, die danach erneut eingespielt wird, und acht manipulierte oder nicht akzeptable:
# Lab driver: valid and manipulated SAML Responses against the validator (sp.py) and the anti-pattern (naive.py).
import copy
import datetime
import re
from lxml import etree
from idp import SAML, new_idp, response
from naive import naive_validate
from sp import SamlError, validate_response
key, cert = new_idp("idp.example signing")
other_key, other_cert = new_idp("idp.example signing") # same name, different key: not the IdP
SP = {"sp_entity_id": "https://sp.example/saml", "acs_url": "https://sp.example/saml/acs", "request_id": "_req-7d1f"}
seen = set()
def issue(name_id="alice@example.com", **kw):
args = {"audience": SP["sp_entity_id"], "recipient": SP["acs_url"], "in_response_to": SP["request_id"], **kw}
return response(args.pop("key", key), args.pop("cert", cert), name_id=name_id, **args)
def check(label, xml, **kw):
try:
ours = validate_response(xml, idp_cert=cert, idp_entity_id="https://idp.example/saml",
seen_assertion_ids=seen, **SP, **kw)
ours = f"login as {ours['name_id']}"
except SamlError as e:
ours = f"rejected: {e}"
try:
naive = f"login as {naive_validate(xml, idp_cert=cert, **SP)['name_id']}"
except Exception as e:
naive = f"rejected: {type(e).__name__}"
print(f"{label}\n validator: {ours}\n anti-pattern: {naive}")
check("valid response", issue())
# Signature wrapping: an unsigned Assertion inserted before the signed one. The signature still
# verifies, because its Reference points by ID at the original Assertion.
doc = etree.fromstring(issue())
signed = doc.find(f"{{{SAML}}}Assertion")
forged = copy.deepcopy(signed)
forged.remove(forged.find("{http://www.w3.org/2000/09/xmldsig#}Signature"))
forged.set("ID", "_forged")
forged.find(f"{{{SAML}}}Subject/{{{SAML}}}NameID").text = "admin@example.com"
doc.insert(list(doc).index(signed), forged)
check("unsigned Assertion before the signed one", etree.tostring(doc))
# A comment inside the signed NameID: exclusive C14N without comments ignores it, so the
# signature stays valid; .text on the received document stops at the comment.
xml = issue("admin@example.com.evil.test")
xml = xml.replace(b"admin@example.com.evil.test<", b"admin@example.com<!---->.evil.test<")
check("comment inside the signed NameID", xml)
check("assertion for another SP", issue(audience="https://other-sp.example/saml"))
check("expired 10 minutes ago", issue(now=datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=15)))
replay = issue()
check("first use", replay)
check("same assertion again", replay)
tampered = issue().replace(b"alice@example.com", b"admin@example.com")
check("NameID edited after signing", tampered)
check("signed with another key + its cert", issue(key=other_key, cert=other_cert))
stripped = re.sub(rb"<ds:Signature.*?</ds:Signature>", b"", issue(), flags=re.S)
check("signature removed", stripped)
valid response
validator: login as alice@example.com
anti-pattern: login as alice@example.com
unsigned Assertion before the signed one
validator: login as alice@example.com
anti-pattern: login as admin@example.com
comment inside the signed NameID
validator: login as admin@example.com.evil.test
anti-pattern: login as admin@example.com
assertion for another SP
validator: rejected: audience ['https://other-sp.example/saml'] is not this SP
anti-pattern: rejected: AssertionError
expired 10 minutes ago
validator: rejected: assertion is outside its validity window
anti-pattern: login as alice@example.com
first use
validator: login as alice@example.com
anti-pattern: login as alice@example.com
same assertion again
validator: rejected: assertion _2291663e82ca413bb791278fa3a9a3f8 already used
anti-pattern: login as alice@example.com
NameID edited after signing
validator: rejected: signature: InvalidDigest: Digest mismatch for reference 0 (#_37ad00c1109f47e3b3b1b650a17aba70)
anti-pattern: rejected: InvalidDigest
signed with another key + its cert
validator: rejected: signature: InvalidSignature: Signature verification failed:
anti-pattern: rejected: InvalidSignature
signature removed
validator: rejected: signature: InvalidInput: Expected to find XML element Signature in {urn:oasis:names:tc:SAML:2.0:protocol}Response using XPath ./{urn:oasis:names:tc:SAML:2.0:assertion}Assertion/ds:Signature
anti-pattern: rejected: InvalidInput
Die Fälle im Einzelnen:
- Eine unsignierte Assertion vor der signierten. Die Signatur bestand die Prüfung trotzdem, weil ihre Referenz die ursprüngliche Assertion über die ID auswählt, egal wo sie steht. Das Anti-Pattern las die erste
Assertionim Dokument und meldete einen Benutzer an, für den der Identity Provider nie gebürgt hat. Der Validator nutztesigned_xmlund sah nur das Original. Diese Familie von Fehlern heißt meist Signature Wrapping. - Ein Kommentar in der signierten
NameID. Der Identity Provider signierteadmin@example.com.evil.test, ein Konto, das einer seiner Benutzer ganz legitim haben kann. Ein eingefügtes<!---->nachadmin@example.comändert die kanonische Form nicht, weil exklusive Kanonisierung in der Variante ohne Kommentare alle Kommentare entfernt; die Signatur bleibt also gültig. In lxml liefert.textnur den Text vor dem ersten Kindknoten, deshalb las das Anti-Patternadmin@example.com. signxml gibt das signierte Element ohne den Kommentar zurück, und der Validator fügt alle Textknoten zusammen; er sah das echte Konto. Genau dieses Muster, im Februar 2018 nach Untersuchungen von Duo Labs als CERT/CC VU#475445 veröffentlicht, betraf unter anderem python-saml und ruby-saml von OneLogin, saml2-js, OmniAuth-SAML und OpenSAML C++ von Shibboleth. - Eine Assertion für einen anderen Service Provider, signiert vom selben Identity Provider, scheiterte an der Audience-Prüfung. Beide Implementierungen fingen sie ab, weil das Anti-Pattern zufällig die Audience prüfte.
- Ablauf und Replay fing nur der Validator ab. Beides sind SAML-Regeln, keine Signaturregeln: Die Signatur einer abgefangenen Assertion bleibt für immer gültig.
- Bearbeitete, neu signierte und unsignierte Responses scheiterten in beiden an der Signatur: ein abweichender Digest, ein Schlüssel, der nicht der gepinnte ist, obwohl sein Zertifikat in
KeyInfoeingebettet war und denselben Subject-Namen trug, und keine Signatur dort, wolocationeine verlangt.
Details, die im echten Einsatz entscheiden
Signatur der Response oder der Assertion. Identity Provider signieren die Assertion, die Response oder beides. Ist nur die Response signiert, ist die Assertion als Nachfahre mit abgedeckt, und die Regel lautet dann: Verwende die Assertion, die ein Kind des signierten Response-Elements ist. Das POST-Profil verlangt trotzdem signierte Assertions, also konfiguriere den Identity Provider so, dass er sie signiert. Akzeptiere nie „eine Signatur irgendwo im Dokument“.
Verschlüsselte Assertions. Eine EncryptedAssertion muss zuerst mit deinem Schlüssel entschlüsselt werden; die entschlüsselte Assertion muss dann eine gültige Signatur des Identity Providers tragen. Die Verschlüsselung beweist nur, dass der Absender deinen öffentlichen Schlüssel kannte.
Vertrauen kommt aus den Metadaten. Die Signaturzertifikate des Identity Providers gehören in deine Konfiguration, importiert aus seinen Metadaten und aktualisiert, wenn er sie rotiert; überlappende Zertifikate während eines Wechsels sind normal. Ein Zertifikat in der Nachricht beweist nichts: Jeder kann eines mit beliebigem Subject-Namen erzeugen, und genau das tat der Fall mit dem anderen Schlüssel.
Algorithmen. Der Text von SAML Core aus dem Jahr 2005 nennt RSA mit SHA-1; signxml 5.2.0 lehnt SHA-1 als Signatur- und Digest-Methode standardmäßig ab, und das solltest du auch tun. Beschränke die akzeptierten Methoden auf das, was dein Identity Provider verwendet, typischerweise RSA-SHA256 oder ECDSA-SHA256.
XML-Parsing. Parse vor allem anderen mit deaktivierten externen Entitäten und DTDs; der Parser von signxml setzt resolve_entities=False. Lehne Dokumente ab, in denen mehr als ein Element dieselbe ID trägt.
Zeiten. Erlaube eine kleine Uhrenabweichung (Sekunden bis wenige Minuten) und speichere verwendete Assertion-IDs bis zu ihrem NotOnOrAfter plus dieser Abweichung.
Wenn eine legitime Response scheitert
Für das umgekehrte Problem, eine echte Response deines Identity Providers, die die Prüfung nicht besteht, gibt es eine kurze Liste üblicher Ursachen:
- Der Identity Provider hat sein Zertifikat rotiert, und deine Konfiguration enthält noch das alte. Aktualisiere es aus seinen Metadaten, die das neue Zertifikat normalerweise schon auflisten, bevor es verwendet wird.
- Etwas hat das XML umformatiert. Exklusive Kanonisierung normalisiert Markup, keinen Text: Hinzugefügte Einrückungen oder Zeilenumbrüche zwischen Elementen ändern Textknoten im signierten Element und machen den Digest ungültig. Prüfe die Bytes genau so, wie sie aus dem Formularfeld dekodiert wurden; vor der Prüfung gilt: kein Pretty-Printing, kein erneutes Serialisieren mit einer anderen Bibliothek, kein „Aufräumen“.
- Die falsche Dekodierung. Beim HTTP-POST-Binding enthält das Formularfeld
SAMLResponsedas XML als base64; es kann Zeilenumbrüche enthalten (SAML Bindings §3.5.4). Bei HTTP-Redirect wird die Nachricht DEFLATE-komprimiert und ihre XML-Signatur entfernt, und eine separate Signatur über den Query-String wird in den ParameternSigAlgundSignatureübertragen (§3.4.4.1); diese Signatur wird über die URL-kodierten Parameter geprüft, nicht über das XML. Responses mit signierten Assertions sollten das Redirect-Binding gar nicht verwenden. - Die Signatur steht dort, wo dein Validator sie nicht erwartet. Signiert der Identity Provider die
Response, während deine Konfiguration die Signatur in derAssertionverlangt (oder umgekehrt), schlägt die Prüfung fehl. Entscheide, welche du verlangst, und konfiguriere den Identity Provider passend; lockere die Vorgabe für die Position nicht auf „irgendwo“. - Uhren. „Noch nicht gültig“ direkt nach der Anmeldung bedeutet, dass die Uhr des Service Providers nachgeht; vergrößere die erlaubte Abweichung erst, nachdem du die Uhr korrigiert hast.
Bibliotheken
Verwende eine SAML-Bibliothek statt einer allgemeinen Bibliothek für XML-Signaturen plus eigenem Glue-Code, halte sie aktuell und suche in ihrer Dokumentation die Funktion, die den signierten Inhalt zurückgibt. Aktuelle Versionen am 20. Oktober 2026:
| Sprache | SAML-Bibliothek | Schicht für XML-Signaturen |
|---|---|---|
| Python | python3-saml 1.16.0, pysaml2 7.5.5 | xmlsec 1.3.17, signxml 5.2.0 |
| Node.js | @node-saml/node-saml 5.1.0, samlify 2.15.0 | xml-crypto 6.4.0 |
| Go | crewjam/saml v0.5.1 | russellhaering/goxmldsig v1.6.1 |
Das README von xml-crypto formuliert dieselbe Regel wie signxml: Verwende nach erfolgreichem checkSignature() das kanonische XML aus getSignedReferences() und nimm keine Knoten aus dem ursprünglichen Dokument; getReferences() und validateElementAgainstReferences() sind für diesen Zweck veraltet. Wenn du Code pflegst, der erst prüft und dann das Eingabedokument abfragt, ist das die Zeile, die du ändern musst.
Checkliste
- Parse sicher: keine DTDs, keine externen Entitäten, keine doppelten Werte für
ID. - Prüfe die Signatur mit dem gepinnten Zertifikat aus den Metadaten des Identity Providers; ignoriere
KeyInfo, wenn es um Vertrauen geht. - Verlange genau eine
Reference, die auf das Element zeigt, das du verwenden wirst, nur mit den Transformationen enveloped-signature und exklusive Kanonisierung und nur mit akzeptierten Algorithmen. - Nur aus dem signierten Element:
Issuerentspricht der Entity-ID des Identity Providers; Zeitfenster ausConditionsundAudienceRestrictionmit deiner Entity-ID; eine Bearer-SubjectConfirmationDatamit deiner ACS-URL alsRecipient, einemNotOnOrAfterin der Zukunft und deiner Request-ID alsInResponseTo(fehlt nur bei unaufgeforderten Responses, die du bewusst akzeptierst). - Speichere die Assertion-ID und lehne sie ab, wenn sie erneut auftaucht, solange die Assertion noch gültig ist.
- Lies
NameIDund Attribute aus dem signierten Element, füge dabei alle Textknoten zusammen, und ordne Benutzer überIssuerplusNameIDzu. - Teste mit einer abgelaufenen und einer erneut eingespielten Assertion sowie einem Zertifikat, das nicht dem Identity Provider gehört: Alle müssen scheitern. Teste außerdem eine per Signature Wrapping manipulierte Response und eine
NameIDmit Kommentar: Bei beiden muss ausschließlich die Identität angemeldet werden, die der Identity Provider signiert hat, und zwar vollständig.
Quellen
- XML Signature Syntax and Processing 1.1: §3.2 (Kernvalidierung), §4.4.3 (Referenzen), §6.1 (Algorithmen), §8.1 (Sicherheitsaspekte)
- SAML Core 2.0: §2.5.1 (Bedingungen), §5.4 (Profil für XML-Signaturen)
- SAML Profiles 2.0: §4.1.4 (Verarbeitungsregeln für Web Browser SSO)
- SAML Bindings 2.0: §3.4.4.1 (Redirect, DEFLATE), §3.5.4 (POST)
- Dokumentation von signxml: „See what is signed“
- README von xml-crypto:
getSignedReferences() - CERT/CC VU#475445: Mehrere SAML-Bibliotheken ermöglichen womöglich eine Umgehung der Authentifizierung durch fehlerhafte XML-Kanonisierung und DOM-Traversierung (2018)