10.4 Review SAML Federation
10.4 - Review SAML Federation
SAML federation lets enterprises sign in through an Identity Provider (IdP). Review Service Provider (SP) endpoints, metadata exchange, and assertion processing code. Confirm signatures are verified with trusted keys, ACS URLs are bound, assertions are replay-protected, and metadata is authenticated before trust is granted.
What This Topic Is
This chapter is about implementation review, not generic vulnerability hunting. SAML security depends on XML signature validation, strict endpoint binding, and one-time use of assertions—not on trusting decoded XML fields after parsing.
The unsafe assumption is that a POST to the Assertion Consumer Service (ACS) URL contains a legitimate IdP response because it arrived over HTTPS. Attackers can forge assertions, replay captured responses, or swap metadata if signature verification and recipient checks are skipped.
This relates to CWE-347 and CWE-294 (Authentication Bypass by Capture-replay).
Vulnerability Characteristics (Where to Identify Them)
| Signal | Where to look |
|---|---|
| Feature type | Enterprise SSO, B2B federation, legacy Java/.NET portals, cloud app SAML connectors |
| Signature validation | wantAssertionsSigned false, signature optional, verify response but not assertion |
| Certificate trust | IdP cert embedded without expiry check, metadata fetched over HTTP, thumbprint string match only |
| ACS URL / Recipient | Missing Recipient/Destination validation, dynamic ACS from request param, wildcard ACS in metadata |
| Replay controls | No InResponseTo check, assertion ID not tracked, clock skew unbounded |
| Conditions | NotOnOrAfter ignored, AudienceRestriction missing or not matched to SP entity ID |
| XML processing | XXE-enabled parsers, external DTD allowed—see 4.08 Review XXE |
| Metadata exchange | Unsigned metadata trusted, SP uploads attacker IdP metadata in self-service config |
Abuse Scenarios
Use these when reviewing SAML SP endpoints, metadata upload flows, and assertion processing.
Scenario 1: Unsigned assertion acceptance
The SP parses SAML XML and extracts NameID without XML signature validation. An attacker POSTs a crafted SAMLResponse to the ACS URL and logs in as any user.
Scenario 2: Assertion replay
A captured legitimate SAMLResponse is replayed within NotOnOrAfter. The SP does not track assertion IDs or InResponseTo, so the same response grants access repeatedly or on another victim session.
Scenario 3: Wrong recipient / ACS URL
The SP accepts assertions whose Destination or Recipient does not match the registered ACS URL. Attacker replays assertions intended for a different SP instance or environment.
Scenario 4: Audience mismatch ignored
AudienceRestriction is missing or not compared to SP entity ID. Assertions minted for a staging SP work against production.
Scenario 5: Malicious IdP metadata import
Self-service tenant onboarding accepts arbitrary metadata URLs. Attacker supplies metadata pointing to attacker IdP; users authenticate against attacker-controlled keys.
Scenario 6: Open redirect via RelayState
After login, the SP redirects to unvalidated RelayState. Attacker phishes victims through a trusted domain to an external site or steals tokens from URL parameters.
Language-Specific Libraries and Dangerous Patterns
Python
# Dangerous: manual XML parse without signature
name_id = parse_nameid_from_xml(base64.b64decode(SAMLResponse))
# Safer: python3-saml with strict settings
from onelogin.saml2.auth import OneLogin_Saml2_Auth
auth = OneLogin_Saml2_Auth(request_data, old_settings=settings)
auth.process_response()
if not auth.is_authenticated() or auth.get_errors():
abort(403)
Settings must include strict: True, wantAssertionsSigned: True, and trusted IdP cert from reviewed metadata.
Java
// Dangerous: DOM parse NameID only
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stream);
// Safer: Spring Security SAML2 Service Provider
http.saml2Login(Customizer.withDefaults());
// RelyingPartyRegistration.fromMetadataLocation(...).entityId(...).assertionConsumerServiceLocation(...)
Also review: OpenSAML low-level usage without validation, Pac4j SAML2Client misconfiguration, Shibboleth SP MetadataProvider accepting unsigned metadata.
C
// Dangerous
var response = new Response(SAMLResponse); // validate: false
var nameId = response.GetNameID();
// Safer: ITfoxtec.Identity.Saml2
var saml2AuthnResponse = new Saml2AuthnResponse(config);
saml2AuthnResponse.ReadSamlResponse(Request, validate: true);
Also review: Sustainsys.Saml2, Azure AD SAML integration defaults.
JavaScript
// Dangerous: regex extract NameID
const nameID = xml.match(/<NameID[^>]*>([^<]+)<\/NameID>/)[1];
// Safer: @node-saml/node-saml or samlify with cert and audience checks
const { SAML } = require('@node-saml/node-saml');
const profile = await saml.validatePostResponseAsync(body);
Go
// Dangerous
nameID := xmlquery.FindOne(doc, "//NameID").InnerText()
// Safer: crewjam/saml
assertion, err := sp.ParseResponse(r, []string{pendingAuthnRequestID})
See OneLogin python3-saml, Spring Security SAML2, ITfoxtec.Identity.Saml2, and crewjam/saml.
Sample Vulnerable Code in Python
from flask import Flask, request, redirect, session
from onelogin.saml2.auth import OneLogin_Saml2_Auth
import base64
app = Flask(__name__)
@app.route("/saml/acs", methods=["POST"])
def saml_acs():
saml_response = request.form["SAMLResponse"]
xml = base64.b64decode(saml_response)
# Parser extracts NameID without verifying assertion signature
name_id = parse_nameid_from_xml(xml)
# Recipient, Audience, NotOnOrAfter, InResponseTo not enforced
session["user"] = name_id
relay = request.form.get("RelayState", "/")
return redirect(relay) # open redirect via unchecked RelayState
def prepare_saml_request(req):
return {
"http_host": req.host,
"script_name": req.path,
"post_data": req.form,
"get_data": req.args,
}
@app.route("/saml/metadata")
def sp_metadata():
# SP accepts any IdP metadata URL supplied by tenant admin without signature check
idp_metadata_url = request.args["metadata"]
load_idp_from_url(idp_metadata_url)
return "ok"
Step-by-Step Review Walkthrough
- Map SP and IdP roles. Locate metadata files, ACS endpoints, single logout URLs, and libraries (OneLogin python3-saml, Spring SAML, ITfoxtec, etc.).
- Verify assertion signatures. Require signed assertions (or signed outer response with signed assertion). Validate with IdP certificate from trusted metadata, including expiry and key rollover.
- Validate ACS binding. Confirm
DestinationandRecipientmatch the registered ACS URL exactly. Reject assertions POSTed to alternate paths or hosts. - Check replay defenses. Store used assertion IDs (
IDattribute) for at least the assertion validity window. ValidateInResponseToagainst the outstanding AuthnRequest ID when SP-initiated. - Inspect conditions. Enforce
NotBefore/NotOnOrAfterwith modest clock skew. RequireAudienceRestrictioncontaining the SP entity ID. - Review metadata trust. Metadata should load from configured URLs or signed bundles—not arbitrary user URLs without review. Plan certificate rollover using metadata refresh.
- Harden XML parsing. Disable DTDs and external entities on SAML parsers. Review RelayState allowlists to block open redirects after login.
Risk Impact Analysis
Authentication bypass. Accepting unsigned or wrongly signed assertions lets attackers craft arbitrary NameIDs and attribute statements.
Assertion replay. Captured SAML responses reused within validity windows grant access without fresh IdP authentication.
Wrong IdP trust. Untrusted metadata imports route logins to attacker-controlled IdPs that mint valid-looking assertions for their keys.
Account linking errors. Weak NameID policy (EmailAddress without confirmation) may merge attacker IdP identities with victim accounts.
XML-side attacks. Unsafe parsers may expose server files or SSRF via XXE before signature logic runs.
Vulnerable Examples in Other Languages
Java
@PostMapping("/saml/SSO")
public ResponseEntity<?> acs(@RequestParam String SAMLResponse) {
byte[] decoded = Base64.getDecoder().decode(SAMLResponse);
Element root = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(new ByteArrayInputStream(decoded))
.getDocumentElement();
// No XML signature validation
String nameId = root.getElementsByTagName("NameID").item(0).getTextContent();
securityContext.setUser(nameId);
return ResponseEntity.status(302).header("Location", "/").build();
}
C
[HttpPost("sso")]
public IActionResult Sso([FromForm] string SAMLResponse)
{
var response = new Response(SAMLResponse);
// Signature validation disabled in config
var nameId = response.GetNameID();
await SignInAsync(nameId);
return Redirect(Request.Form["RelayState"].ToString());
}
JavaScript
// Node SP using simplified parser
app.post("/saml/consume", (req, res) => {
const xml = Buffer.from(req.body.SAMLResponse, "base64").toString("utf8");
const nameID = xml.match(/<NameID[^>]*>([^<]+)<\/NameID>/)[1];
req.session.user = nameID;
res.redirect(req.body.RelayState || "/");
});
Go
func acs(w http.ResponseWriter, r *http.Request) {
samlResp := r.FormValue("SAMLResponse")
doc, _ := xmlquery.Parse(strings.NewReader(decode(samlResp)))
nameID := xmlquery.FindOne(doc, "//NameID").InnerText()
// Signature, Audience, Recipient not verified
setSession(w, nameID)
http.Redirect(w, r, r.FormValue("RelayState"), http.StatusFound)
}
Fix: Safer Patterns and Libraries to Use
Python
Use python3-saml with strict settings and explicit security flags.
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.settings import OneLogin_Saml2_Settings
settings = {
"strict": True,
"security": {
"wantAssertionsSigned": True,
"wantMessagesSigned": True,
"rejectDeprecatedAlgorithm": True,
"allowRepeatAttributeName": False,
},
"sp": {
"entityId": "https://app.example.com/saml/metadata",
"assertionConsumerService": {
"url": "https://app.example.com/saml/acs",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
},
},
"idp": {
"entityId": "https://idp.example.com/metadata",
"singleSignOnService": {"url": "https://idp.example.com/sso", "binding": "..."},
"x509cert": IDP_CERT_PEM,
},
}
@app.route("/saml/acs", methods=["POST"])
def saml_acs():
auth = OneLogin_Saml2_Auth(prepare_request(request), old_settings=settings)
auth.process_response()
errors = auth.get_errors()
if errors or not auth.is_authenticated():
abort(403)
if not auth.validate_timestamps():
abort(403)
assertion_id = auth.get_last_assertion_id()
if replay_cache.seen(assertion_id):
abort(403)
replay_cache.remember(assertion_id, ttl=300)
session["user"] = auth.get_nameid()
return redirect(safe_relay_state(request.form.get("RelayState")))
Important: Keep strict: True. Load IdP certificates from reviewed metadata; refresh before expiry. Allowlist RelayState targets.
Java
Use Spring Security SAML2 Service Provider with verified relying party registration.
@Bean
RelyingPartyRegistrationRepository registrations() {
Saml2MetadataResolver resolver = new Saml2MetadataResolver(
"https://idp.example.com/metadata/saml2");
RelyingPartyRegistration registration = RelyingPartyRegistrations
.fromMetadataLocation("https://idp.example.com/metadata/saml2")
.registrationId("corp-idp")
.entityId("https://app.example.com/saml/metadata")
.assertionConsumerServiceLocation("https://app.example.com/login/saml2/sso/corp-idp")
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
http.saml2Login(saml -> saml
.loginProcessingUrl("/login/saml2/sso/{registrationId}")
.successHandler(validatedRelayStateHandler()));
Important: Spring validates signatures and audience by default when correctly configured. Do not replace with manual DOM parsing.
C
Use ITfoxtec Identity SAML2 with signature validation enabled.
var config = new Saml2Configuration
{
Issuer = "https://app.example.com/saml/metadata",
AllowedAudienceUris = { "https://app.example.com/saml/metadata" },
CertificateValidationMode = X509CertificateValidationMode.ChainTrust,
SignatureAlgorithm = Saml2SecurityAlgorithms.RsaSha256Signature,
};
config.AllowedIssuer = "https://idp.example.com/metadata";
config.SignatureValidationCertificates.Add(idpCert);
var saml2AuthnResponse = new Saml2AuthnResponse(config);
saml2AuthnResponse.ReadSamlResponse(Request, validate: true);
if (saml2AuthnResponse.Status != Saml2StatusCodes.Success)
throw new AuthenticationException("SAML auth failed");
var claims = saml2AuthnResponse.CreateClaimsIdentity(config);
await SignInAsync(new ClaimsPrincipal(claims));
Important: Set validate: true on read paths. Store consumed assertion IDs in cache with TTL matching NotOnOrAfter.
Go
Use crewjam/saml with SP struct fields and built-in validation.
import "github.com/crewjam/saml"
sp := &saml.ServiceProvider{
EntityID: "https://app.example.com/saml/metadata",
Key: spKey,
Certificate: spCert,
IDPMetadata: idpMetadata,
AcsURL: mustParseURL("https://app.example.com/saml/acs"),
MetadataURL: mustParseURL("https://app.example.com/saml/metadata"),
}
func acs(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
assertion, err := sp.ParseResponse(r, []string{pendingRequestID})
if err != nil {
http.Error(w, "invalid SAML response", http.StatusForbidden)
return
}
if replayCache.Exists(assertion.ID) {
http.Error(w, "replay detected", http.StatusForbidden)
return
}
replayCache.Add(assertion.ID, assertion.NotOnOrAfter)
setSession(w, assertion.Subject.NameID.Value)
}
Important: ParseResponse validates signature, destination, and timing when IdP metadata is correct. Track SP-initiated request IDs and enforce them with InResponseTo.
Verify During Review
- Assertions (or outer responses) are XML signature validated with current IdP keys from trusted metadata.
- ACS URL, Destination, and Recipient match registered SP endpoints exactly.
- Assertion IDs are single-use; InResponseTo matches outstanding AuthnRequest when applicable.
- Audience equals SP entity ID; NotBefore/NotOnOrAfter enforced with bounded clock skew.
- IdP metadata and certificates come from trusted sources with rollover planned before expiry.
- SAML parsers disable XXE; RelayState is allowlisted.