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.

SAML 2.0: SP-initiated Web Browser SSO: 7 steps
StepFromToBindingWhat happens
1BrowserService ProviderHTTP GETBrowser requests a protected resource
2Service ProviderBrowserHTTP-RedirectSP returns an AuthnRequest, redirecting to the IdP
3BrowserIdentity ProviderHTTP GETBrowser follows the redirect to the IdP SSO endpoint
4Identity ProviderIdentity Provideruser actionThe IdP authenticates the user
5Identity ProviderBrowserHTTP-POSTIdP returns a signed Response with an Assertion
6BrowserService ProviderHTTP-POSTBrowser POSTs the SAMLResponse to the SP's ACS
7Service ProviderService ProvidervalidationSP validates the Assertion and establishes a session
  1. 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. 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. 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. 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. 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. 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. 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.

OpenID Connect: Authorization Code + PKCE: 7 steps
StepFromToBindingWhat happens
1Relying PartyRelying Partyclient-sideRP generates state, nonce, and a PKCE verifier
2BrowserOpenID ProviderHTTP-RedirectBrowser is redirected to the OP /authorize endpoint
3OpenID ProviderOpenID Provideruser actionThe OP authenticates the user and gets consent
4OpenID ProviderBrowserHTTP-RedirectOP redirects back with an authorization code
5Relying PartyOpenID Providerback-channelRP exchanges the code at the OP /token endpoint
6OpenID ProviderRelying Partyback-channelOP returns the tokens (id_token is a JWT)
7Relying PartyRelying PartyvalidationRP validates the id_token and establishes a session
  1. 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-cM

    Why it matters: PKCE binds the later token request to THIS browser session, so an intercepted code is useless without the verifier.

  2. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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. 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.

Use it locally This lab has a native command line twin. Build identity-cli from the site's source with:
cargo build --release --bin identity-cli
Source and licence terms