L26 · SAML and Enterprise SSO
Module 4: Identity and Access · ~20 min · Lesson 26 of 71
🎯 What you'll learn
- What the identity provider (IdP) and service provider (SP) do, and how an assertion travels between them through your browser
- What's inside a SAML assertion, and the checklist an SP must run before trusting one
- How XML signature wrapping tricks an SP into reading something the IdP never signed
- What Golden SAML is, and why stealing one signing key beats stealing a thousand passwords
🔥 Why this matters
In 2020, the attackers behind the SolarWinds compromise got admin access to some victims' identity servers (Microsoft AD FS) and copied the key those servers sign logins with. With it they could mint login messages for cloud services as any user, with no password and no MFA prompt. CISA described this in advisory AA21-008A (January 2021). The technique is called Golden SAML: whoever holds the IdP's signing key is the IdP. If your organization signs people in to AWS or GitHub through corporate SSO, they trust a signature like that every morning.
1. Three parties and a signed note
You open an internal reporting app. It bounces you to the corporate login page, you approve an MFA prompt, and you land back in the app, logged in. The app never saw your password. It got a signed note saying "this is alice@example.com, she logged in at 14:02".
That's SAML (Security Assertion Markup Language), an OASIS standard; version 2.0 (2005) is the one in use. The parties:
- The identity provider (IdP) authenticates people and signs statements about them: Okta, Microsoft Entra ID, AD FS, Ping and others.
- The service provider (SP) is the app that trusts those statements: AWS sign-in, GitHub, a SaaS tool.
- The principal is the user, and their browser carries messages between the two.
The signed note is an assertion: an XML document in which the IdP states facts about a subject (who, how they logged in, which attributes). Think of a sealed letter of introduction. Technically, it carries an XML digital signature (XML-DSig) made with the IdP's private key, which the SP verifies with a public key it received in advance (signatures L17, certificates L11).
The SP-initiated flow (HTTP-POST binding)
A binding is how SAML messages ride on HTTP. The most common login flow starts at the SP and uses the HTTP-POST binding for the response:
Browser SP (app) IdP (corp login)
│ 1. GET /reports │ │
│─────────────────────▶│ │
│ 2. 302 to IdP with │ │
│ ?SAMLRequest=... │ │
│◀─────────────────────│ │
│ 3. GET /sso?SAMLRequest=... (AuthnRequest) │
│───────────────────────────────────────────────▶│
│ 4. password + MFA (L21), or existing session │
│◀──────────────────────────────────────────────▶│
│ 5. HTML form that auto-submits: │
│ SAMLResponse=<base64 signed XML> │
│◀───────────────────────────────────────────────│
│ 6. POST /saml/acs SAMLResponse=... │
│─────────────────────▶│ │
│ │ 7. verify signature │
│ │ + conditions │
│ 8. 302 /reports + │ (no call to IdP) │
│ session cookie │ │
│◀─────────────────────│ │
Notice step 7: the SP and IdP never talk at login time. Trust was set up beforehand by exchanging metadata: XML files with each side's entity ID (a unique name, often a URL), the SP's Assertion Consumer Service (ACS) URL where responses are POSTed, and the IdP's signing certificate. Unlike the OIDC code flow (L24), there's no back-channel call: the SP decides from the XML your browser hands it. After step 8 the SP runs its own session (L22).
In the IdP-initiated flow (click an app tile in a portal), the IdP POSTs an unrequested
response; it's weaker, with no InResponseTo tying it to a request the SP made.
2. Anatomy of an assertion
Here's a trimmed, made-up assertion for AWS console sign-in (real ones run to several KB):
<saml:Assertion ID="_a1b2c3" IssueInstant="2026-11-06T14:02:11Z">
<saml:Issuer>https://idp.example.com/saml</saml:Issuer> ← who
<ds:Signature> ← XML-DSig block
<ds:Reference URI="#_a1b2c3"/> ← signs the element whose
... ID is _a1b2c3
</ds:Signature>
<saml:Subject>
<saml:NameID>alice@example.com</saml:NameID> ← about whom
<saml:SubjectConfirmationData InResponseTo="_q91c2"
Recipient="https://signin.aws.amazon.com/saml"
NotOnOrAfter="2026-11-06T14:07:11Z"/> ← use within 5 min
</saml:Subject>
<saml:Conditions NotBefore="2026-11-06T14:01:41Z"
NotOnOrAfter="2026-11-06T14:07:11Z">
<saml:AudienceRestriction>
<saml:Audience>urn:amazon:webservices</saml:Audience>
</saml:AudienceRestriction> ← for which SP
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2026-11-06T14:02:09Z"/>
← how/when login
<saml:AttributeStatement> ← extra facts:
<saml:Attribute Name="https://aws.amazon.com/SAML/Attribu
tes/Role">...role ARN, provider ARN...</saml:Attribute>
</saml:AttributeStatement> groups, roles
</saml:Assertion>
(The attribute name is wrapped to fit.) The assertion sits inside a <samlp:Response> with its
own Issuer, Destination and Status; the response, the assertion, or both can be signed.
3. What the SP must check
Every field above exists so the SP can refuse something. A correct SP checks all of these and fails closed (L04) on any miss:
| Check | Stops |
|---|---|
| Signature verifies with the IdP cert from metadata, never a cert embedded in the message | Anyone signing with their own key |
| The signature covers the assertion the code then reads | Signature wrapping (section 4) |
Issuer is the expected IdP |
Assertions from another IdP you also trust |
Audience is your entity ID; Recipient/Destination is your ACS URL |
Replaying an assertion meant for another app |
NotBefore / NotOnOrAfter with a small clock skew (a minute or two) |
Reusing old assertions |
InResponseTo matches a request this SP sent |
Unsolicited or injected responses |
Assertion ID not seen before (cache until expiry) |
Replay within the validity window |
| Exactly one assertion; schema-valid XML; no DTDs | Parser tricks, XML external entities (XXE) |
The most important rule: don't write this yourself. Use a maintained library and keep it patched; the next sections show how even good libraries got it wrong.
4. XML signature wrapping, in brief
JWTs (L25) sign an exact byte string. XML-DSig instead points at a part of the document by ID
(Reference URI="#_a1b2c3") and checks only that part. That leaves a gap between "which element
was verified" and "which element the app reads".
XML signature wrapping (XSW) exploits that gap. The attacker takes a genuine signed assertion (their own, as a low-privilege user), moves it where the verifier still finds it, and puts a forged, unsigned assertion where the app looks:
BEFORE: what the IdP signed AFTER: what the attacker sends
Response Response
└─ Assertion ID=_a1 ◀───┐ ├─ Assertion ID=_evil ◀── app
├─ NameID: mallory │ │ └─ NameID: admin reads
└─ Signature │ │ (unsigned) this
└─ Ref "#_a1" ────┘ └─ Extensions
└─ Assertion ID=_a1 ◀─┐
├─ NameID: mallory │
└─ Signature │
└─ Ref "#_a1" ──┘
verifier: ✓ valid
The verifier says "valid" (true: _a1 is untouched), then the app reads the first assertion and
logs the attacker in as admin. A 2012 study (Somorovsky et al., USENIX Security) found this flaw
in 11 of 14 SAML frameworks. The first deep dive covers variants and fixes.
5. Golden SAML, in brief
XSW is a bug in the SP. Golden SAML needs no bug: an attacker who steals the IdP's token-signing private key can sign any assertion, for any user and role, at any SP that trusts that IdP. CyberArk named it in 2017, after Kerberos "Golden Tickets". Password and MFA resets don't help, because a forged assertion skips the login. The second deep dive covers detection.
6. SAML or OIDC?
| SAML 2.0 | OIDC (L24) | |
|---|---|---|
| Format | XML assertion, XML-DSig | JSON ID token (a JWT, L25) |
| Transport | Browser POST of the whole assertion | Code via browser, token via back channel |
| Good for | Workforce SSO into SaaS and AWS console | New apps, mobile, SPAs, APIs |
| API calls | Not designed for it | Pairs with OAuth access tokens (L23) |
| Parsing risk | High: XML canonicalization, wrapping | Lower, but JWT pitfalls (L25) |
Rule of thumb: OIDC for anything new; SAML where a product only speaks SAML, through a battle-tested library.
🧪 Worked example: SAML into AWS, end to end
AWS IAM Identity Center (L13) is often connected to a corporate IdP as an external SAML 2.0
identity provider; there, Identity Center is the SP. It can also act as the IdP for
"customer managed" SAML apps. Separately, IAM SAML federation lets an IdP sign users straight
into a role with AssumeRoleWithSAML.
For IAM federation, the role's trust policy names the SAML provider and pins the audience:
{
"Effect": "Allow",
"Principal": {
"Federated":
"arn:aws:iam::111122223333:saml-provider/CorpIdP"
},
"Action": ["sts:AssumeRoleWithSAML", "sts:TagSession"],
"Condition": {
"StringEquals": {
"SAML:aud": "https://signin.aws.amazon.com/saml"
}
}
}
SAML:aud comes from the assertion's Recipient, so assertions addressed elsewhere are
rejected. The saml-provider holds the IdP's metadata and signing certificate. AWS checks the
signature, the time window and the Role attribute (this role ARN plus that provider ARN), then
issues temporary credentials (one hour by default).
Each sign-in leaves a CloudTrail record like this (trimmed, made-up values):
{
"eventSource": "sts.amazonaws.com",
"eventName": "AssumeRoleWithSAML",
"userIdentity": { "type": "SAMLUser",
"userName": "alice@example.com" },
"requestParameters": {
"sAMLAssertionID": "_a1b2c3",
"roleSessionName": "alice@example.com",
"roleArn": "arn:aws:iam::111122223333:role/ReadOnly"
},
"responseElements": {
"issuer": "https://idp.example.com/saml",
"subject": "alice@example.com"
}
}
Keep that sAMLAssertionID: the Golden SAML deep dive matches it against the IdP's sign-in log.
GitHub Enterprise Cloud with SAML SSO is the same pattern: GitHub is the SP, and classic personal
access tokens and SSH keys must be authorized for that SSO before they reach the org's repos.
🛠 Try it (optional, ~5 min, local only)
Build a made-up SAML response, encode it as a browser carries it, then decode it and pull out the fields an SP checks. Only step 4 touches the network (a public, read-only file).
1. Write the sample (fake values; the signature is a placeholder, so it won't verify):
mkdir -p ~/saml-lab && cd ~/saml-lab
cat > response.xml <<'EOF'
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
ID="_r7f3a" InResponseTo="_q91c2" Version="2.0"
IssueInstant="2026-11-06T14:02:11Z"
Destination="https://signin.aws.amazon.com/saml">
<saml:Issuer>https://idp.example.com/saml</saml:Issuer>
<saml:Assertion ID="_a1b2c3" Version="2.0"
IssueInstant="2026-11-06T14:02:11Z">
<saml:Issuer>https://idp.example.com/saml</saml:Issuer>
<ds:Signature><ds:SignedInfo>
<ds:Reference URI="#_a1b2c3">
<ds:DigestValue>FAKEdigestFAKEdigestFAKE=</ds:DigestValue>
</ds:Reference></ds:SignedInfo>
<ds:SignatureValue>FAKEsignatureFAKE==</ds:SignatureValue>
</ds:Signature>
<saml:Subject>
<saml:NameID>alice@example.com</saml:NameID>
<saml:SubjectConfirmation
Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData InResponseTo="_q91c2"
NotOnOrAfter="2026-11-06T14:07:11Z"
Recipient="https://signin.aws.amazon.com/saml"/>
</saml:SubjectConfirmation></saml:Subject>
<saml:Conditions NotBefore="2026-11-06T14:01:41Z"
NotOnOrAfter="2026-11-06T14:07:11Z">
<saml:AudienceRestriction>
<saml:Audience>urn:amazon:webservices</saml:Audience>
</saml:AudienceRestriction></saml:Conditions>
<saml:AttributeStatement>
<saml:Attribute
Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName">
<saml:AttributeValue>alice@example.com</saml:AttributeValue>
</saml:Attribute></saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
EOF
2. Encode it as the browser would (the SAMLResponse form field is base64 of the XML):
base64 < response.xml | tr -d '\n' > response.b64
head -c 60 response.b64; echo
PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6
Remember the prefix PHNhbWxw: it's <samlp in base64. If you see it in a HAR file, a proxy
log or a Kong request log, you're looking at a whole login credential. Treat it as a secret.
3. Decode and pull out what an SP checks. xmllint ships with macOS (/usr/bin/xmllint);
local-name() sidesteps XML namespaces:
q() { base64 -d < response.b64 | xmllint --xpath "$1" -; }
q 'string(//*[local-name()="Assertion"]/*[local-name()="Issuer"])'
q 'string(//*[local-name()="Audience"])'
q 'string(//*[local-name()="Conditions"]/@NotOnOrAfter)'
q 'string(//*[local-name()="Reference"]/@URI)'
q 'string(//*[local-name()="Assertion"]/@ID)'
q 'count(//*[local-name()="Assertion"])'
https://idp.example.com/saml
urn:amazon:webservices
2026-11-06T14:07:11Z
#_a1b2c3
_a1b2c3
1
The Reference URI matches the assertion ID, and there's exactly one assertion: the shape of
an honest response. To see it all, run base64 -d < response.b64 | xmllint --format -.
4. Compare with AWS's public SP metadata (a read-only static file):
M=https://signin.aws.amazon.com/static/saml-metadata.xml
curl -s "$M" | xmllint --xpath \
'string(//*[local-name()="EntityDescriptor"]/@entityID)' -
urn:amazon:webservices
That's AWS's entity ID, the value IdPs put in Audience for AWS. Behind a corporate proxy this
download may be blocked; steps 1-3 work offline.
Your own logins (described, not run here): the free SAML-tracer browser extension shows each SAML message your browser carries, decoded. Captured responses are live credentials for a few minutes, so never paste them into tickets or chats.
🧠 Key takeaways
- In SAML, the IdP signs an XML assertion and your browser POSTs it to the SP's ACS URL; the SP decides from that XML alone.
- The SP must check signature (with the metadata cert), issuer, audience, recipient, time window,
InResponseToand replay, and fail closed on any miss. - XML-DSig signs a part of the document; code must read exactly the verified part, or it's open to signature wrapping.
- Whoever holds the IdP's token-signing key can be anyone (Golden SAML); guard it like a root CA key.
- Prefer OIDC for new builds; use SAML through a maintained, patched library.
📖 Words to know
| Term | Meaning |
|---|---|
| Identity provider (IdP) | The system that authenticates users and signs assertions about them |
| Service provider (SP) | The app that trusts the IdP's assertions and logs users in |
| Assertion | A signed XML statement about a subject: identity, login details, attributes |
| Binding | How SAML messages travel over HTTP (redirect, POST, artifact) |
| ACS URL | The SP endpoint that receives the POSTed SAML response |
| Entity ID | A unique name for an IdP or SP, checked as Issuer and Audience |
| Metadata | XML where IdP and SP publish entity IDs, endpoints and certificates |
| XML signature wrapping (XSW) | Moving a signed element so the verifier checks it while the app reads a forged one |
| Golden SAML | Forging assertions with a stolen IdP token-signing key |
⏭ Next time
L27: access control models. Once SAML or OIDC has told you who someone is, RBAC, ABAC and
policy engines decide what they may do, ideally in one place instead of scattered if checks.
XML signature wrapping in one picture
Why XML signatures are hard to get right
A JWT signature covers one exact string of bytes. If one byte changes, verification fails, and the payload you read is the payload that was verified. An XML signature works differently, in three steps that each leave room for mistakes:
- Find the signed element: follow
Reference URI="#_a1"to the element whoseIDis_a1. - Canonicalize it: rewrite it into a standard form (attribute order, whitespace, namespaces) so that harmless formatting changes don't break the signature. The common algorithm, Exclusive XML Canonicalization, removes comments.
- Digest and verify: hash the canonical bytes, compare with
DigestValue, and checkSignatureValueoverSignedInfowith the IdP's public key.
Then, separately, the application's code navigates the document again to read the NameID and
attributes. Most of the famous SAML signature bugs live in the gap between those two
navigations.
┌──────────── one XML document ────────────┐
│ │
verifier │ finds by ID ──▶ element A ──▶ ✓ valid │
│ │
app code │ finds by path ─▶ element B ──▶ NameID │
│ │
└── safe only if A and B are the same node ┘
Four real ways A and B came apart
| Year | What happened | Example |
|---|---|---|
| 2012 | Wrapping: signed assertion moved, forged one put where the app looks (variants often tested as XSW1-8) | 11 of 14 frameworks, Somorovsky et al. |
| 2018 | Comment truncation: alice@example.com<!---->.evil.com verifies (comments are dropped before hashing) but the library reads only the text before the comment |
Disclosed by Duo Labs: python-saml CVE-2017-11427, ruby-saml CVE-2017-11428, OpenSAML C++ CVE-2018-0489, and others |
| 2024 | ruby-saml checked the wrong element with an XPath selector; anyone holding any document signed by the IdP could forge a login (CVSS 10) | CVE-2024-45409, fixed in 1.17.0 / 1.12.3; GitLab affected |
| 2025 | Parser differential: ruby-saml used two XML parsers (REXML and Nokogiri) that built different trees from the same input, so one verified and the other was read | CVE-2025-25291 / -25292, fixed in 1.18.0 / 1.12.4; found by GitHub Security Lab |
In the 2018 case, an attacker who could register alice@example.com.evil.com at the IdP got a
real assertion, inserted a comment, and the SP logged them in as alice@example.com. The signature
was valid the whole time. That's integrity failing without any byte of the signed data changing:
the bug was in what the SP read, not in what was signed.
🧪 Worked example: vulnerable vs fixed (Java-like pseudocode)
// VULNERABLE: verify one thing, read another
Document doc = parse(samlResponse);
if (!xmlSig.validate(doc, idpCert)) throw new AuthError();
Element a = (Element) doc
.getElementsByTagNameNS(SAML_NS, "Assertion").item(0);
String user = nameIdOf(a); // first Assertion in the doc,
// signed or not
// FIXED: read only what the verifier returned
Document doc = parseStrict(samlResponse); // no DTDs, schema
if (countAssertions(doc) != 1) throw new AuthError();
Element signed = xmlSig.validateAndGetSignedElement(
doc, idpCertFromMetadata); // never KeyInfo cert
if (!"Assertion".equals(signed.getLocalName()))
throw new AuthError();
String user = nameIdOf(signed); // same node that was hashed
The method names are illustrative. Real libraries (OpenSAML, python3-saml, ruby-saml, node-saml) do this internally, which is exactly why you call their high-level "validate response" API and never re-parse the XML yourself afterwards.
A defender's checklist for a SAML integration
- Use a maintained library and subscribe to its advisories; SAML bugs are often CVSS 9+.
- Read user data only from the object the library returns after validation.
- Reject responses with more than one assertion, any DTD, or unexpected structure.
- Pin the IdP certificate from metadata; ignore any certificate inside the message.
- Require signed assertions (not just signed responses) if the library supports it.
- In tests, feed your ACS endpoint a wrapped response and a comment-injected
NameIDon your own dev instance; both must be rejected.
🧠 Extra takeaways
- XML-DSig verifies an element found by ID; app code often finds elements by path. Make them the same node.
- Comment truncation, wrapping and parser differentials are all the same bug: verify A, read B.
- Never hand-roll SAML validation, and never re-parse after the library validates.
| Term | Meaning |
|---|---|
| Canonicalization (C14N) | Rewriting XML into a standard form before hashing so formatting changes don't break signatures |
| Parser differential | Two parsers building different trees from the same input |
| XSW1-XSW8 | Eight standard wrapping layouts that SAML testing tools try, derived from the 2012 "On Breaking SAML" study |
Golden SAML: forging the keys to the kingdom
The attack in five steps
1. Get admin on the IdP server (phish an admin,
exploit, or pivot from inside the network)
│
▼
2. Export the token-signing private key
(e.g. from AD FS config/DKM, or a key file)
│
▼
3. Offline, on the attacker's laptop: build an
assertion for any user, any role, MFA claimed
│
▼
4. POST it to any SP that trusts this IdP
(AWS, GitHub, Microsoft 365, SaaS)
│
▼
5. SP verifies: signature ✓ issuer ✓ audience ✓
→ logged in. The IdP never saw a login.
Step 5 is the whole problem. Every check from the main lesson's table passes, because the forged
assertion is cryptographically perfect. The assertion can even claim the user passed MFA, since the
AuthnContext that says so is just a field the attacker fills in. CyberArk published the technique
and a proof-of-concept tool in 2017, and in the SolarWinds campaign of 2020 it was used against
real AD FS deployments.
A variant, which CISA's advisory also describes, skips the key theft: an attacker with IdP or cloud-tenant admin rights adds a new trusted IdP or federation configuration that they control. In 2024, researchers at Semperis described a cloud-side relative, "Silver SAML": if an app's SAML signing certificate in Microsoft Entra ID was generated outside Entra and imported, whoever steals that key file can forge responses for that app. Entra's own generated keys can't be exported.
What a signature proves, and what it doesn't
As in L17, a valid signature proves the assertion was made by a holder of the private key and hasn't been changed since. It doesn't prove the holder is the real IdP, or that a human logged in. That's an integrity and authenticity guarantee, not secrecy: anyone who intercepts an assertion can read it, and the key holder can write whatever it likes.
Detection: look for logins the IdP never issued
CISA's AA21-008A lists several methods. The first is the one you can adapt anywhere: correlate SP logins with IdP events. A forged assertion produces an SP-side login with no matching IdP-side issuance. For AD FS, CISA names the Windows event IDs to correlate (4769 on domain controllers, 1200 and 1202 on AD FS).
IdP sign-in log CloudTrail (AWS = SP)
─────────────── ─────────────────────
14:02 alice issued _a1b2c3 ──▶ 14:02 AssumeRoleWithSAML
assertion _a1b2c3 ✓ pair
(nothing) 02:14 AssumeRoleWithSAML
assertion _f00d99
role Admin ✗ orphan
With AWS as the SP, that means joining CloudTrail AssumeRoleWithSAML events (and Identity Center or
GitHub audit logs) to the IdP's sign-in log by assertion ID, user and time, and alerting on
orphans. The other CISA methods: watch for export of the signing certificate on the IdP host, and
for changes to federation trust settings (for AD FS, event 307, "The Federation Service
configuration was changed").
Defenses
- Treat the IdP as tier 0, like domain controllers and your root CA: few admins, phishing- resistant MFA for them (L21), separate admin workstations, no internet browsing from the server.
- Keep the signing key in an HSM or a managed cloud IdP where it can't be exported. A key that can't leave the hardware can't be used from an attacker's laptop.
- Alert on federation changes: a new IdP, a new signing certificate, a new trusted domain.
- Limit blast radius: IdP-issued roles shouldn't be standing admin. Use short sessions, and just-in-time elevation for privileged AWS permission sets.
- If compromise is suspected, rotate the token-signing key and update every SP's trust, because password resets and MFA resets don't invalidate a stolen signing key.
🧪 Worked example: the 02:14 orphan
Your detection fires: CloudTrail shows AssumeRoleWithSAML into PlatformAdmin at 02:14 with
roleSessionName bob@example.com, from an unfamiliar IP. The IdP's log has no sign-in for Bob
since 18:30 the day before, and Bob's laptop was off. Bob's password is irrelevant here. The
response: revoke the role's active sessions (the Revoke sessions button on the role in IAM), rotate
the IdP signing key and re-upload the new metadata to the AWS saml-provider, then investigate
who had admin access to the IdP and how the key left it.
🧠 Extra takeaways
- Golden SAML forges valid assertions with a stolen signing key; every SP check passes.
- Detect it by finding SP logins with no matching IdP issuance, plus key-export and federation-change events.
- Defend it with an HSM or managed key, tier-0 handling of the IdP, and a rotation plan.
| Term | Meaning |
|---|---|
| Token-signing key | The IdP private key whose signatures every SP trusts |
| Tier 0 | Systems whose compromise means compromise of everything (IdP, domain controllers, root CA) |
| HSM | Hardware security module: a device that uses keys without letting them be exported |
| Orphan login | An SP-side login with no matching IdP-side event |
The quiz for this lesson and the Tests section need JavaScript.