Identity Flow Visualizer
Walk a federated login one message at a time. Both flows, and the signature-wrapping attack their checks defeat, are below in full.
Rendered when the site was built. No JavaScript required. With JavaScript on, this is replaced by an interactive stepper over the same flows.
SAML 2.0: SP-initiated Web Browser SSO
Participants: Browser · Service Provider · Identity Provider.
| Step | From | To | Binding | What happens |
|---|---|---|---|---|
| 1 | Browser | Service Provider | HTTP GET | Browser requests a protected resource |
| 2 | Service Provider | Browser | HTTP-Redirect | SP returns an AuthnRequest, redirecting to the IdP |
| 3 | Browser | Identity Provider | HTTP GET | Browser follows the redirect to the IdP SSO endpoint |
| 4 | Identity Provider | Identity Provider | user action | The IdP authenticates the user |
| 5 | Identity Provider | Browser | HTTP-POST | IdP returns a signed Response with an Assertion |
| 6 | Browser | Service Provider | HTTP-POST | Browser POSTs the SAMLResponse to the SP's ACS |
| 7 | Service Provider | Service Provider | validation | SP validates the Assertion and establishes a session |
-
1. Browser requests a protected resource
- From
- Browser
- To
- Service Provider
- Binding
- HTTP GET
GET https://sp.example.com/protected (no session cookie yet)
Why it matters: Unauthenticated, so the SP starts SSO instead of serving the resource.
-
2. SP returns an AuthnRequest, redirecting to the IdP
- From
- Service Provider
- To
- Browser
- Binding
- HTTP-Redirect
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_a1b2c3d4" Version="2.0" IssueInstant="2026-07-23T12:00:00Z" Destination="https://idp.example.com/sso" AssertionConsumerServiceURL="https://sp.example.com/acs" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"> <saml:Issuer>https://sp.example.com/metadata</saml:Issuer> </samlp:AuthnRequest>Why it matters: The <AuthnRequest> is sent through the browser (HTTP-Redirect binding); RelayState carries the original URL to return to.
-
3. Browser follows the redirect to the IdP SSO endpoint
- From
- Browser
- To
- Identity Provider
- Binding
- HTTP GET
GET https://idp.example.com/sso?SAMLRequest=<deflated+base64>&RelayState=/protected
Why it matters: The IdP, not the SP, authenticates the user; the SP never sees the password.
-
4. The IdP authenticates the user
- From
- Identity Provider
- To
- Identity Provider
- Binding
- user action
(the user signs in at the IdP, out of band)
Why it matters: Authentication happens entirely at the IdP; MFA/step-up policy lives here.
-
5. IdP returns a signed Response with an Assertion
- From
- Identity Provider
- To
- Browser
- Binding
- HTTP-POST
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_r9x8" InResponseTo="_a1b2c3d4" IssueInstant="2026-07-23T12:00:30Z" Destination="https://sp.example.com/acs"> <saml:Issuer>https://idp.example.com/metadata</saml:Issuer> <ds:Signature>…IdP signature over the Assertion…</ds:Signature> <saml:Assertion ID="_asrt1" IssueInstant="2026-07-23T12:00:30Z"> <saml:Subject> <saml:NameID Format="…emailAddress">[email protected]</saml:NameID> <saml:SubjectConfirmation Method="…bearer"> <saml:SubjectConfirmationData Recipient="https://sp.example.com/acs" NotOnOrAfter="2026-07-23T12:05:00Z" InResponseTo="_a1b2c3d4"/> </saml:SubjectConfirmation> </saml:Subject> <saml:Conditions NotBefore="2026-07-23T12:00:00Z" NotOnOrAfter="2026-07-23T12:05:00Z"> <saml:AudienceRestriction> <saml:Audience>https://sp.example.com/metadata</saml:Audience> </saml:AudienceRestriction> </saml:Conditions> <saml:AuthnStatement AuthnInstant="2026-07-23T12:00:30Z"/> </saml:Assertion> </samlp:Response>Why it matters: The IdP signs the Response/Assertion. Delivered as an auto-submitting HTML form the browser POSTs to the SP's ACS.
-
6. Browser POSTs the SAMLResponse to the SP's ACS
- From
- Browser
- To
- Service Provider
- Binding
- HTTP-POST
POST https://sp.example.com/acs SAMLResponse=<base64>&RelayState=/protected
Why it matters: The response travels through the browser (front-channel), which is why the signature + Conditions checks below matter.
-
7. SP validates the Assertion and establishes a session
- From
- Service Provider
- To
- Service Provider
- Binding
- validation
checks: XML signature · NotBefore/NotOnOrAfter · AudienceRestriction · Recipient · InResponseTo result: session established for [email protected]
Why it matters: Signature = authenticity/integrity; NotOnOrAfter = replay window; AudienceRestriction = this token is for THIS SP; InResponseTo = binds the response to our request (CSRF).
OpenID Connect: Authorization Code + PKCE
Participants: Browser · Relying Party · OpenID Provider.
| Step | From | To | Binding | What happens |
|---|---|---|---|---|
| 1 | Relying Party | Relying Party | client-side | RP generates state, nonce, and a PKCE verifier |
| 2 | Browser | OpenID Provider | HTTP-Redirect | Browser is redirected to the OP /authorize endpoint |
| 3 | OpenID Provider | OpenID Provider | user action | The OP authenticates the user and gets consent |
| 4 | OpenID Provider | Browser | HTTP-Redirect | OP redirects back with an authorization code |
| 5 | Relying Party | OpenID Provider | back-channel | RP exchanges the code at the OP /token endpoint |
| 6 | OpenID Provider | Relying Party | back-channel | OP returns the tokens (id_token is a JWT) |
| 7 | Relying Party | Relying Party | validation | RP validates the id_token and establishes a session |
-
1. RP generates state, nonce, and a PKCE verifier
- From
- Relying Party
- To
- Relying Party
- Binding
- client-side
state = af0ifjsldkj nonce = n-0S6_WzA2Mj code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk code_challenge = base64url(SHA-256(verifier)) = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMWhy it matters: PKCE binds the later token request to THIS browser session, so an intercepted code is useless without the verifier.
-
2. Browser is redirected to the OP /authorize endpoint
- From
- Browser
- To
- OpenID Provider
- Binding
- HTTP-Redirect
GET https://op.example.com/authorize? response_type=code& client_id=s6BhdRkqt3& redirect_uri=https://rp.example.com/cb& scope=openid%20profile& state=af0ifjsldkj& nonce=n-0S6_WzA2Mj& code_challenge_method=S256& code_challenge=<see step 1>
Why it matters: state defends against CSRF on the redirect back; nonce will defend against id_token replay/injection.
-
3. The OP authenticates the user and gets consent
- From
- OpenID Provider
- To
- OpenID Provider
- Binding
- user action
(the user signs in + consents at the OP, out of band)
Why it matters: As in SAML, only the OP sees the credentials; the RP never does.
-
4. OP redirects back with an authorization code
- From
- OpenID Provider
- To
- Browser
- Binding
- HTTP-Redirect
302 https://rp.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
Why it matters: The RP MUST verify `state` equals the value it sent before using the code.
-
5. RP exchanges the code at the OP /token endpoint
- From
- Relying Party
- To
- OpenID Provider
- Binding
- back-channel
POST https://op.example.com/token grant_type=authorization_code& code=SplxlOBeZQQYbYS6WxSbIA& redirect_uri=https://rp.example.com/cb& client_id=s6BhdRkqt3& code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Why it matters: Back-channel (server-to-server). The OP recomputes SHA-256(code_verifier) and checks it equals the code_challenge from step 2.
-
6. OP returns the tokens (id_token is a JWT)
- From
- OpenID Provider
- To
- Relying Party
- Binding
- back-channel
{ "token_type":"Bearer", "expires_in":3600, "access_token":"…opaque…", "id_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL29wLmV4YW1wbGUuY29tIiwic3ViIjoiMjQ4Mjg5NzYxMDAxIiwiYXVkIjoiczZCaGRSa3F0MyIsImV4cCI6MTc4NDgxMTYzMCwiaWF0IjoxNzg0ODA4MDMwLCJub25jZSI6Im4tMFM2X1d6QTJNaiJ9.VEShuk8FS57R9KJx423ka6egOxSeHDECdeSSgdnaOAw" } decoded id_token: header = { "alg": "HS256", "typ": "JWT" } payload = { "aud": "s6BhdRkqt3", "exp": 1784811630, "iat": 1784808030, "iss": "https://op.example.com", "nonce": "n-0S6_WzA2Mj", "sub": "248289761001" } signature = real HS256 HMAC over header.payload, keyed with the demo client_secret "demo-client-secret"Why it matters: The id_token is the OIDC addition over plain OAuth2: it is an assertion of *authentication*, verified below.
-
7. RP validates the id_token and establishes a session
- From
- Relying Party
- To
- Relying Party
- Binding
- validation
checks: signature (recompute the HS256 HMAC with the client_secret) · iss=https://op.example.com · aud=s6BhdRkqt3 · exp not passed · nonce=n-0S6_WzA2Mj (matches step 1) result: session established for sub 248289761001
Why it matters: aud must equal our client_id (token wasn't minted for someone else); nonce must equal what we sent (not a replayed/injected token).
XML Signature Wrapping: the attack these checks defeat
A synthetic, seeded demonstration. Every identifier below is invented, no real assertion or key is involved, and nothing here is a working exploit: it is the shape of the attack that makes step 7's signature check insufficient on its own. The wrapped response carries a signature that genuinely verifies.
XSW: original assertion hidden in ds:Signature/ds:Object
-
1. The Identity Provider signs one assertion
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
Why it matters: This is a real, honest login: the attacker signs in as themselves and keeps the response. Both sides hold the same synthetic document, and the signature's Reference URI names exactly one element.
-
2. The attacker moves the signed assertion out of the way
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <ds:Object> <saml:Assertion ID="_asrt1" NameID="[email protected]">Why it matters: Nothing has been altered. The signed assertion is still present and still byte-for-byte what the Identity Provider produced; it has only been moved into the signature's own <ds:Object> container. A signature over an element says what the element is, never where in the document it sits.
-
3. A forged assertion takes its place
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
<samlp:Response ID="_r9x8"> <saml:Issuer> <saml:Assertion ID="_evil1" NameID="[email protected]"> <ds:Signature Reference URI="#_asrt1"> <ds:Object> <saml:Assertion ID="_asrt1" NameID="[email protected]">
Why it matters: The forgery carries no signature at all, and it does not need one. It only needs to be the element the Service Provider picks up.
-
4. The Service Provider verifies the signature
Reference URI="#_asrt1" resolves. Digest matches. SIGNATURE VALID
Reference URI="#_asrt1" resolves. Digest matches. SIGNATURE VALID
Why it matters: The verifier follows the reference, finds the original element exactly as it was signed, and reports a valid signature. It is not wrong: that signature really is valid. It simply answers a narrower question than the Service Provider is asking.
-
5. The Service Provider reads the assertion
first <saml:Assertion> found: NameID = [email protected] session established for [email protected]
first <saml:Assertion> found: NameID = [email protected] session established for [email protected]
Why it matters: Two lookups, two answers. The verifier resolved the assertion by reference; the application found it by name. Where those disagree, the attacker chooses who you are logged in as.
-
6. The checks that defeat it
[PASS] The XML signature verifies Reference URI="#_asrt1" resolves to a <saml:Assertion> that has not been altered, so the digest matches and the signature is valid. [PASS] The assertion read is the assertion signed The first <saml:Assertion> found is ID="_asrt1", the one the signature covers. [PASS] The response carries exactly one assertion 1 <saml:Assertion> elements. The SAML schema allows a response to carry more than one, which is what leaves room to hide the original. [PASS] Every assertion is a direct child of the response No assertion is buried inside another element.[PASS] The XML signature verifies Reference URI="#_asrt1" resolves to a <saml:Assertion> that has not been altered, so the digest matches and the signature is valid. [FAIL] The assertion read is the assertion signed The first <saml:Assertion> found is ID="_evil1" (NameID [email protected]); the signature covers ID="_asrt1". The Service Provider would trust an element nobody signed. [FAIL] The response carries exactly one assertion 2 <saml:Assertion> elements. The SAML schema allows a response to carry more than one, which is what leaves room to hide the original. [FAIL] Every assertion is a direct child of the response ID="_asrt1" is nested under samlp:Response > ds:Signature > ds:Object. An assertion the signature covers should never need a hiding place.Why it matters: Resolve the assertion through the signature's own reference rather than by tag name, reject a response carrying more than one assertion, and read the same document the verifier read. A valid signature is a fact about an element, not a licence to trust whatever the parser hands back.
XSW: original assertion hidden in the forged saml:Advice
-
1. The Identity Provider signs one assertion
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
Why it matters: This is a real, honest login: the attacker signs in as themselves and keeps the response. Both sides hold the same synthetic document, and the signature's Reference URI names exactly one element.
-
2. The attacker moves the signed assertion out of the way
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Advice> <saml:Assertion ID="_asrt1" NameID="[email protected]">Why it matters: Nothing has been altered. The signed assertion is still present and still byte-for-byte what the Identity Provider produced; it has only been moved into a <saml:Advice> wrapper, which the next step hangs off the forged assertion. A signature over an element says what the element is, never where in the document it sits.
-
3. A forged assertion takes its place
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="[email protected]">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_evil1" NameID="[email protected]"> <saml:Advice> <saml:Assertion ID="_asrt1" NameID="[email protected]">
Why it matters: The forgery carries no signature at all, and it does not need one. It only needs to be the element the Service Provider picks up.
-
4. The Service Provider verifies the signature
Reference URI="#_asrt1" resolves. Digest matches. SIGNATURE VALID
Reference URI="#_asrt1" resolves. Digest matches. SIGNATURE VALID
Why it matters: The verifier follows the reference, finds the original element exactly as it was signed, and reports a valid signature. It is not wrong: that signature really is valid. It simply answers a narrower question than the Service Provider is asking.
-
5. The Service Provider reads the assertion
first <saml:Assertion> found: NameID = [email protected] session established for [email protected]
first <saml:Assertion> found: NameID = [email protected] session established for [email protected]
Why it matters: Two lookups, two answers. The verifier resolved the assertion by reference; the application found it by name. Where those disagree, the attacker chooses who you are logged in as.
-
6. The checks that defeat it
[PASS] The XML signature verifies Reference URI="#_asrt1" resolves to a <saml:Assertion> that has not been altered, so the digest matches and the signature is valid. [PASS] The assertion read is the assertion signed The first <saml:Assertion> found is ID="_asrt1", the one the signature covers. [PASS] The response carries exactly one assertion 1 <saml:Assertion> elements. The SAML schema allows a response to carry more than one, which is what leaves room to hide the original. [PASS] Every assertion is a direct child of the response No assertion is buried inside another element.[PASS] The XML signature verifies Reference URI="#_asrt1" resolves to a <saml:Assertion> that has not been altered, so the digest matches and the signature is valid. [FAIL] The assertion read is the assertion signed The first <saml:Assertion> found is ID="_evil1" (NameID [email protected]); the signature covers ID="_asrt1". The Service Provider would trust an element nobody signed. [FAIL] The response carries exactly one assertion 2 <saml:Assertion> elements. The SAML schema allows a response to carry more than one, which is what leaves room to hide the original. [FAIL] Every assertion is a direct child of the response ID="_asrt1" is nested under samlp:Response > saml:Assertion > saml:Advice. An assertion the signature covers should never need a hiding place.Why it matters: Resolve the assertion through the signature's own reference rather than by tag name, reject a response carrying more than one assertion, and read the same document the verifier read. A valid signature is a fact about an element, not a licence to trust whatever the parser hands back.
Two of these artifacts have a tool to themselves: the JWT Decoder opens an id_token of your own into its header and claims, and the X.509 Certificate Analyzer reads the certificate whose key signs a SAML assertion, down to its validity window and SANs.
About this lab5 paragraphs
SAML 2.0 (SP-initiated Web Browser SSO) and OpenID Connect (Authorization Code + PKCE) cover every redirect, POST, and back-channel call between the browser, the service/relying party, and the identity/OpenID provider.
Each step shows its decoded message: the SAML AuthnRequest/Assertion, the OIDC /authorize query, the token response and its decoded id_token, with a note on why it matters.
The PKCE code_challenge and the id_token are computed for real from the same crypto the lab uses.
Everything is synthetic; with JavaScript on, the same flows become a stepper you click through in your browser, and they are also available offline via identity-cli.
A third mode, decode your own id_token, needs JavaScript, and its input mounts only behind an OPSEC acknowledgement: an id_token is a live bearer credential, so the field is absent from the page until you have read the warning. It decodes in your browser and never verifies the signature, which would need the provider's key. Offline: identity-cli decode, which reads the token from standard input and never from the command line.
identity-cli from the site's source with:
cargo build --release --bin identity-cliSource and licence terms