Respuesta breve. Validar una respuesta SAML tiene dos mitades, y la mayoría de los incidentes se deben a hacer solo la primera. Primero, verifica la firma XML con el certificado del proveedor de identidad tomado de sus metadatos, nunca del KeyInfo del mensaje, y exige exactamente una Reference que apunte al ID de la Assertion (o de la Response) que vas a usar. Segundo, lee cada valor del que dependes (el NameID, los atributos, Issuer, Audience, Recipient, las marcas de tiempo) del elemento que cubre esa firma, no buscándolo en el documento recibido: un documento XML puede contener, junto al elemento firmado, otro sin firmar que se le parece. Después, aplica las reglas de SAML: la audiencia es tu entity ID, el SubjectConfirmationData del método bearer indica la URL de tu servicio consumidor de aserciones y tu solicitud, no hay nada caducado y cada ID de aserción se acepta una sola vez.
Cómo señala una firma XML lo que firma
Una firma XML (XML Signature Syntax and Processing 1.1) no firma un documento como bytes. Firma un elemento SignedInfo, que enumera una o más Reference. Cada Reference tiene un URI que selecciona contenido, una lista de Transforms que se le aplican y un DigestValue del resultado. Esta es una firma SAML tal como la emite el proveedor de identidad de laboratorio que aparece más abajo, impresa con los valores base64 largos acortados:
<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>
Tres detalles deciden qué queda protegido:
URI="#_cf90…"es una referencia dentro del mismo documento: selecciona el elemento cuyo atributoIDtiene ese valor, junto con sus descendientes (XMLDSig §4.4.3.2). El lugar que ocupa ese elemento en el documento no forma parte de la firma.- La transformación enveloped-signature elimina el propio elemento
ds:Signatureantes de calcular el resumen, de modo que la firma puede estar dentro de lo que firma. - La canonicalización exclusiva (
xml-exc-c14n#, aquí sin comentarios) convierte el elemento seleccionado en una cadena de bytes canónica: el orden de los atributos, las declaraciones de espacios de nombres y los espacios en blanco dentro de las etiquetas se normalizan, y los comentarios se descartan. Por eso, documentos distintos pueden tener la misma forma canónica.
La validación consiste en la «validación de referencias» (recalcular cada resumen) seguida de la «validación de la firma» sobre el SignedInfo canonicalizado (XMLDSig §3.2). Responde a una sola pregunta: esta clave firmó estos bytes canónicos. No dice nada del resto del documento. La especificación lo convierte en el título de una sección, «Only What is Signed is Secure» («solo lo firmado es seguro», §8.1.1), y añade que las aplicaciones deberían operar sobre los datos «que se transformaron (incluida la canonicalización) y se firmaron» (§8.1.3).
Qué exigen las especificaciones de SAML
SAML restringe XMLDSig (SAML Core 2.0 §5.4): las firmas deben (MUST) ser envueltas (enveloped); deben (MUST) contener «una única <ds:Reference> que contenga una referencia dentro del mismo documento al valor del atributo ID del elemento raíz de la aserción o del mensaje de protocolo que se firma»; debería (SHOULD) usarse la canonicalización exclusiva, y no deberían (SHOULD NOT) aparecer transformaciones distintas de enveloped-signature y de la canonicalización exclusiva. Los verificadores que aceptan otras transformaciones «deben (MUST) asegurarse de que ningún contenido del mensaje SAML quede excluido de la firma».
El perfil Web Browser SSO, de inicio de sesión único en el navegador (SAML Profiles 2.0 §4.1.4), añade las comprobaciones que hacen que una firma válida signifique «este usuario, para este servicio, ahora»:
- Con el enlace HTTP-POST, «las aserciones incluidas deben (MUST) estar firmadas» (§4.1.4.5). Una
Responsefirmada que envuelve una aserción sin firmar no cumple este requisito. - El proveedor de servicios debe (MUST) verificar todas las firmas que haya, que el
RecipientdelSubjectConfirmationDatadel método bearer «coincide con la URL del servicio consumidor de aserciones», que suNotOnOrAfter«no ha pasado, con el margen de desfase de reloj permitido» y queInResponseToes igual al ID de suAuthnRequesto falta en una respuesta no solicitada (§4.1.4.3). - Debe (MUST) «asegurarse de que las aserciones bearer no se reenvíen, manteniendo el conjunto de valores de ID usados» durante todo el tiempo en que la aserción sería válida (§4.1.4.5).
- La condición
AudienceRestriction«se evalúa como Valid si y solo si la relying party (la parte que confía) de SAML es miembro de una o más de las audiencias especificadas» (SAML Core §2.5.1.4), y los atributosNotBeforeyNotOnOrAfterdeConditionsacotan la validez de la aserción (§2.5.1.2).
Un validador que solo lee datos firmados
El siguiente validador usa signxml 5.2.0 para la firma XML. La propia documentación de signxml llama a la regla «See what is signed» («mira qué está firmado») y la convierte en la API: verify() devuelve el elemento firmado como signed_xml, sin los comentarios que la firma no cubría, y location fija dónde debe estar ds:Signature. Todo lo que sigue a la comprobación de la firma lee los datos de 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}
Para comparar, así es el error. Llama a la misma comprobación de firma, descarta el resultado y después lee la primera Assertion que encuentra en el documento:
# 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}
Probado con respuestas manipuladas
Un proveedor de identidad de laboratorio genera en cada ejecución una clave P-256 y un certificado autofirmado, y emite respuestas firmadas; el proveedor de servicios fija ese certificado:
# 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)
El script de control envía diez respuestas a ambas funciones: una válida, una nueva que después se reenvía y ocho manipuladas o inaceptables:
# 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
Caso por caso:
- Una aserción sin firmar antes de la firmada. La firma se verificó igualmente, porque su referencia selecciona la aserción original por ID, esté donde esté. El antipatrón leyó la primera
Assertiondel documento y dio acceso a un usuario que el proveedor de identidad nunca avaló. El validador usósigned_xmly solo vio la original. Esta familia de errores se suele llamar envoltura de firmas (signature wrapping). - Un comentario dentro del
NameIDfirmado. El proveedor de identidad firmóadmin@example.com.evil.test, una cuenta que uno de sus usuarios podría tener legítimamente. Insertar<!---->después deadmin@example.comno cambia la forma canónica, porque la canonicalización exclusiva sin comentarios descarta los comentarios, así que la firma sigue siendo válida. En lxml,.textdevuelve solo el texto anterior al primer nodo hijo, de modo que el antipatrón leyóadmin@example.com. signxml devuelve el elemento firmado sin el comentario, y el validador une todos los nodos de texto: vio la cuenta real. Este mismo patrón, publicado en febrero de 2018 como CERT/CC VU#475445 tras una investigación de Duo Labs, afectó, entre otros, a python-saml y ruby-saml de OneLogin, saml2-js, OmniAuth-SAML y OpenSAML C++ de Shibboleth. - Una aserción para otro proveedor de servicios, firmada por el mismo proveedor de identidad, no superó la comprobación de audiencia. Las dos implementaciones la detectaron, porque el antipatrón, por casualidad, sí comprobaba la audiencia.
- La caducidad y la repetición solo las detectó el validador. Las dos son reglas de SAML, no de la firma: la firma de una aserción capturada sigue siendo válida para siempre.
- Las respuestas editadas, firmadas de nuevo y sin firma fallaron en la firma en las dos implementaciones: discrepancia del resumen, una clave que no es la fijada aunque su certificado venía incrustado en
KeyInfoy llevaba el mismo nombre de sujeto, y ninguna firma dondelocationexige una.
Detalles decisivos en despliegues reales
Firma de la respuesta o firma de la aserción. Los proveedores de identidad firman la aserción, la respuesta o ambas. Si solo está firmada la Response, la aserción queda cubierta como descendiente, y la regla pasa a ser: usa la aserción que es hija del elemento de respuesta firmado. El perfil POST sigue exigiendo aserciones firmadas, así que configura el proveedor de identidad para que las firme. Nunca aceptes «una firma en algún lugar del documento».
Aserciones cifradas. Una EncryptedAssertion debe descifrarse primero con tu clave; después, la Assertion descifrada debe llevar una firma válida del proveedor de identidad. El cifrado solo demuestra que el remitente conocía tu clave pública.
La confianza viene de los metadatos. Los certificados de firma del proveedor de identidad van en tu configuración, importados de sus metadatos y actualizados cuando los rota; durante un cambio de certificado es normal que haya certificados que se solapen. Un certificado dentro del mensaje no demuestra nada: cualquiera puede crear uno con cualquier nombre de sujeto, que es justo lo que hizo el caso de la otra clave.
Algoritmos. El texto de SAML Core, de 2005, menciona RSA con SHA-1; signxml 5.2.0 rechaza por defecto los métodos de firma y de resumen SHA-1, y tú también deberías hacerlo. Limita los métodos aceptados a los que usa tu proveedor de identidad, normalmente RSA-SHA256 o ECDSA-SHA256.
Análisis del XML. Antes que nada, analiza el documento con las entidades externas y las DTD desactivadas; el analizador de signxml establece resolve_entities=False. Rechaza los documentos que tengan más de un elemento con el mismo ID.
Tiempos. Permite un desfase pequeño (de unos segundos a un par de minutos) y guarda los ID de aserción usados hasta su NotOnOrAfter más ese desfase.
Cuando falla una respuesta legítima
El problema contrario —una respuesta real de tu proveedor de identidad que no supera la verificación— tiene una lista corta de causas habituales:
- El proveedor de identidad rota su certificado y tu configuración sigue teniendo el antiguo. Actualízala desde sus metadatos, que normalmente publican el nuevo certificado antes de que se use.
- Algo reformatea el XML. La canonicalización exclusiva normaliza el marcado, no el texto: la sangría o los saltos de línea añadidos entre elementos cambian los nodos de texto dentro del elemento firmado y rompen el resumen. Verifica los bytes exactamente como salen al decodificar el campo del formulario; nunca apliques formato legible (pretty-print), vuelvas a serializar con otra biblioteca ni «limpies» el XML antes de la verificación.
- Una decodificación incorrecta. Con el enlace HTTP-POST, el campo de formulario
SAMLResponsees el XML en base64, que puede venir con saltos de línea (SAML Bindings §3.5.4). Con HTTP-Redirect, el mensaje va comprimido con DEFLATE, se le quita la firma XML y una firma aparte sobre la cadena de consulta viaja en los parámetrosSigAlgySignature(§3.4.4.1); esa firma se verifica sobre los parámetros con codificación URL, no sobre el XML. Las respuestas que llevan aserciones firmadas no deberían usar el enlace Redirect en absoluto. - La firma está donde tu validador no la espera. Si el proveedor de identidad firma la
Responsey tu configuración exige la firma dentro de laAssertion(o al revés), la verificación falla. Decide cuál exiges y configura el proveedor de identidad para que coincida; no relajes la ubicación a «en cualquier parte». - Relojes. «Aún no válido» justo después del inicio de sesión significa que el reloj del proveedor de servicios va atrasado; amplía el margen de desfase solo después de corregir el reloj.
Bibliotecas
Usa una biblioteca SAML en lugar de una biblioteca genérica de firmas XML más tu propio código de integración, mantenla actualizada y busca en su documentación la función que devuelve el contenido firmado. Versiones vigentes el 20 de octubre de 2026:
| Lenguaje | Biblioteca SAML | Capa de firma XML |
|---|---|---|
| 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 |
El README de xml-crypto expone la misma regla que signxml: cuando checkSignature() tiene éxito, usa el XML canónico de getSignedReferences() y no tomes nodos del documento original; getReferences() y validateElementAgainstReferences() están obsoletas para ese fin. Si mantienes código que verifica la firma y después consulta el documento de entrada, esa es la línea que hay que cambiar.
Lista de comprobación
- Analiza el XML de forma segura: sin DTD, sin entidades externas, sin
IDduplicados. - Verifica la firma con el certificado fijado de los metadatos del proveedor de identidad; ignora
KeyInfoa efectos de confianza. - Exige exactamente una
Reference, que apunte al elemento que vas a usar, solo con las transformaciones enveloped-signature y de canonicalización exclusiva, y solo con algoritmos aceptados. - Solo del elemento firmado:
Issueres igual al entity ID del proveedor de identidad; la ventana temporal deConditionsyAudienceRestrictioncon tu entity ID; unSubjectConfirmationDatadel método bearer con tu URL de ACS comoRecipient, unNotOnOrAfteren el futuro y el ID de tu solicitud comoInResponseTo(ausente solo en las respuestas no solicitadas que hayas decidido aceptar). - Registra el ID de la aserción y recházalo si vuelve a llegar mientras la aserción siga siendo válida.
- Lee el
NameIDy los atributos del elemento firmado, uniendo todos los nodos de texto, e identifica a los usuarios por el parIssueryNameID. - Prueba con una aserción caducada y otra reenviada, y con un certificado que no sea el del proveedor de identidad: todos estos casos deben fallar. Prueba también una respuesta con envoltura de firmas y un
NameIDcon un comentario: solo deben dar acceso a la identidad completa que firmó el proveedor.
Fuentes
- XML Signature Syntax and Processing 1.1: §3.2 (validación básica), §4.4.3 (referencias), §6.1 (algoritmos), §8.1 (consideraciones de seguridad)
- SAML Core 2.0: §2.5.1 (condiciones), §5.4 (perfil de firma XML)
- SAML Profiles 2.0: §4.1.4 (reglas de procesamiento de Web Browser SSO)
- SAML Bindings 2.0: §3.4.4.1 (Redirect, DEFLATE), §3.5.4 (POST)
- Documentación de signxml: «See what is signed»
- README de xml-crypto:
getSignedReferences() - CERT/CC VU#475445: varias bibliotecas SAML pueden permitir eludir la autenticación por una canonicalización XML y un recorrido del DOM incorrectos (2018)