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 sign-in request travels through the browser as a redirect. A field called RelayState carries the page you originally asked for, so you land back on it.
-
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">user@example.com</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 and the Assertion. They arrive as an auto-submitting HTML form, which the browser POSTs to the SP's assertion consumer service (ACS), the URL that takes sign-in responses.
-
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, so the user's own machine handles it. That is why the signature and the Conditions checks below matter: the SP cannot assume the message came straight from the IdP.
-
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 user@example.com
Why it matters: Signature: proves who wrote it and that nobody changed it. NotOnOrAfter: how long it stays usable, so an old copy cannot be replayed. AudienceRestriction: names the service provider it was minted for, and only that one. InResponseTo: ties this response to the request we sent, so an attacker cannot inject one of their own.
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 (proof key for code exchange) ties the later token request to this browser session, so a stolen 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="user@example.com">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="user@example.com">
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="user@example.com">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <ds:Object> <saml:Assertion ID="_asrt1" NameID="user@example.com">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="user@example.com">
<samlp:Response ID="_r9x8"> <saml:Issuer> <saml:Assertion ID="_evil1" NameID="admin@example.com"> <ds:Signature Reference URI="#_asrt1"> <ds:Object> <saml:Assertion ID="_asrt1" NameID="user@example.com">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 = user@example.com session established for user@example.com
first <saml:Assertion> found: NameID = admin@example.com session established for admin@example.com
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 admin@example.com); 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. A signed assertion has no reason to be buried inside another element.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="user@example.com">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_asrt1" NameID="user@example.com">
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="user@example.com">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Advice> <saml:Assertion ID="_asrt1" NameID="user@example.com">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="user@example.com">
<samlp:Response ID="_r9x8"> <saml:Issuer> <ds:Signature Reference URI="#_asrt1"> <saml:Assertion ID="_evil1" NameID="admin@example.com"> <saml:Advice> <saml:Assertion ID="_asrt1" NameID="user@example.com">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 = user@example.com session established for user@example.com
first <saml:Assertion> found: NameID = admin@example.com session established for admin@example.com
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 admin@example.com); 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. A signed assertion has no reason to be buried inside another element.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
Two flows are covered: SAML 2.0 browser single sign-on, started at the site you are visiting, and OpenID Connect using the Authorization Code flow with PKCE (a check that ties the code to the app that asked for it). Both cover every redirect, POST and back-channel call between the browser, the site you are signing in to (the service or relying party), and the provider that signs you in (the identity or 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. Its input appears only after you read a short warning and click to acknowledge it. That acknowledgement is there because an id_token is a live credential: anyone holding it can act as you, so the field is not on the page until you have accepted. 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