Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 13 – Enterprise SSO and provisioning (SAML 2.0 and SCIM 2.0)

SAML 2.0 SSO flows, bindings and metadata

How a SAML sign-in moves between browser, app and identity provider, what metadata pins, and the checks a verified response still needs.

  • Intermediate
  • 40 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 40 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Trace a sign-in that the app starts and one that the identity provider starts, naming the binding of each message
  • Read an identity provider's metadata into one trust record, and refuse metadata that cannot be trusted as it stands
  • Apply the checks a verified response still needs: issuer, Destination, Recipient, InResponseTo, time window, audience and a replay memory
  • Send RelayState as an opaque one-use handle that the server resolves to a known route, never as a destination
  • Start the local session with a new session ID and a lifetime capped by SessionNotOnOrAfter, and end it even when single logout fails

Before you start

What you’ll learn · Before you start

What you will learn

  • Trace a sign-in that the app starts and one that the identity provider starts, naming the binding of each message
  • Read an identity provider's metadata into one trust record, and refuse metadata that cannot be trusted as it stands
  • Apply the checks a verified response still needs: issuer, Destination, Recipient, InResponseTo, time window, audience and a replay memory
  • Send RelayState as an opaque one-use handle that the server resolves to a known route, never as a destination
  • Start the local session with a new session ID and a lifetime capped by SessionNotOnOrAfter, and end it even when single logout fails

Before you start

On this page

A SAML response is signed XML, and a valid signature proves who made it, not that it is meant for your app, for this moment or for this sign-in. Run on 14 sample responses, a first draft that checked the signature, the status and the expiry gave the wrong verdict on 10: it accepted 9 that it should have refused and refused 1 that was fine. The fixed consumer was right on all 14.

You will trace a sign-in from the browser’s side, read the metadata that pins one identity provider to one connection, run the checks that a verified response still has to pass, treat RelayState as a handle, and start and end the local session safely.

A SAML sign-in is a redirect out and a form posted back

SAML 2.0 lets a company’s identity provider: The system that signs a person in and tells other applications who they are, in SAML by sending a signed assertion. A company's directory sign-in is the usual example. Also called an IdP. (IdP) tell your application, the service provider: The application that relies on an identity provider to sign people in, such as a product used by many companies. SAML calls it the service provider, or SP. (SP), who has signed in. It does so with a signed statement, an SAML assertion: A signed statement from an identity provider about a person: who they are, when and how they signed in, and for whom and until when the statement is good. The app reads it from the SAML response., that the person’s browser carries from one server to the other: the two never talk to each other during the sign-in. Step through one that the app starts:

Interactive · message by message One sign-in, from the first request to the session Step through a sign-in that the app starts. See which binding carries each message and what the app saves before it believes the response.
One sign-in, from the first request to the session, step 9 of 9. Messages so far: Browser to Your app: GET /orders/1042; Your app to Browser: 302 with SAMLRequest; Browser to Identity provider: GET sign-in URL; Identity provider to Browser: page with a form; Browser to Your app: POST /saml/acs; Your app to Browser: 302 and new cookie.Browsercarries every messageYour appsession: new IDIdentity providerperson authenticatedGET /orders/1042302 with SAMLRequestGET sign-in URLpage with a formPOST /saml/acs302 and new cookie

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. A page needs a session. The person opens /orders/1042 and has no session. The app finds the connection for their company and decides to ask the identity provider who they are.
  2. Make a request and save it. The app makes an AuthnRequest with a new random ID, saves that ID and the wanted path on the server, and makes a RelayState handle that points at the saved entry.
  3. Send the person away. The app answers with a redirect to the identity provider's sign-in address. The request travels in the URL as deflated, base64 text, with the handle as RelayState. This is the HTTP-Redirect binding.
  4. The browser follows it. The browser asks the identity provider for that address. Whatever is in the URL, including RelayState, passed through the browser, so the identity provider must treat it as data from outside.
  5. The person signs in there. The identity provider shows its own sign-in page, with its password, second factor or existing session. The app never sees the password.
  6. A signed response in a form. The identity provider answers with a page holding a form. The form carries the SAMLResponse, a signed assertion that names the person, and the RelayState it was given.
  7. The browser posts it. The form posts itself to the app's assertion consumer service. This is the HTTP-POST binding. The message crosses the browser, so only its signature and its contents tie it to this app and this moment.
  8. Check before believing. The app verifies the signature, then checks the issuer, Destination, Recipient, InResponseTo, time window, audience and that the assertion ID is new. Any failure ends the sign-in with a refusal.
  9. A new session. Only now does the app start a session, with a new session ID. It looks up the saved path through the RelayState handle, uses the handle up, and redirects the browser there.

Two messages crossed the browser, and each used a SAML binding: The way a SAML message travels over HTTP. The Redirect binding puts it in a URL, deflated and encoded. The POST binding puts it in a form field that the browser submits., the SAML name for a way of riding on HTTP. The request went out in a URL and the response came back in a form:

Binding Where the message sits Used in this sign-in for
HTTP-Redirect In the URL, deflated, base64 encoded and URL encoded The request to the IdP, and logout messages
HTTP-POST In a hidden form field, base64 encoded, posted by the browser The response to the app’s assertion consumer service: The address on the service provider that receives the SAML response from the browser and starts a session. It is listed in the provider's metadata and called the ACS. (ACS)

The Kantara interoperability profile asks for the same split, requests by Redirect and responses by POST. Nothing in either binding says who the browser is. The Web Browser SSO profile therefore requires the assertions of a POST response to be signed, and it tells the app to keep the IDs of the assertions it accepts for as long as they could still be valid (section 4.1.4.5), as the third section does. Whatever sat in the URL on the way out, RelayState included, passed through the person’s hands and comes back as input from outside.

The flow above began at the app, so an ID saved before the redirect can vouch for the response. The other flow begins at the IdP’s portal: the person clicks a tile, and the IdP posts an unsolicited response: A SAML response sent without a request from the service provider, as when a sign-in starts from the identity provider's portal. It has no InResponseTo to match against a saved request. that answers no request. The profile allows it, and requires that such a response carry no InResponseTo, which leaves the app nothing to match it with:

Interactive · message by message A response that nobody asked for Step through a sign-in that starts at the identity provider's portal and see what the app has to match it with.
A response that nobody asked for, step 5 of 5. Messages so far: Browser to Identity provider: click the app's tile; Identity provider to Browser: page with a form; Browser to Your app: POST /saml/acs; Your app to Browser: 302 to our own start.Browsercarries every messageYour appown request _req-8 savedIdentity providerruns the portalclick the app's tilepage with a formPOST /saml/acs302 to our own start

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A tile in the portal. The person is already signed in at the identity provider and clicks the tile for your app. Your app has not been asked anything yet, so it has no pending request.
  2. The portal posts a response. The identity provider builds a signed response without an AuthnRequest and sends the browser a form that posts it to the app. Its InResponseTo is absent, as the profile requires of an unsolicited response.
  3. The browser posts it. The form posts to the assertion consumer service. The app receives a valid signature and nothing to compare it with, because no saved request names this sign-in.
  4. Anyone can post one. A response that was made for someone else's account can be posted from a victim's browser just as well, and the app cannot tell it from a sign-in that the person began. The victim is then signed in to the wrong account.
  5. Start the app's own request. The safer default is to ignore the posted response and send the browser to the app's own sign-in address. The identity provider still has the person's session, so the person sees one extra redirect and no password prompt.

Because nothing ties an unsolicited response to the browser that posts it, an outsider can post a response made for the outsider’s own account into a victim’s browser, which is login CSRF: A forged sign-in: a form on another site posts credentials of an account its owner controls, so the visitor's browser is signed in to it. A token on the sign-in form, issued before any session exists, prevents it. More in “CSRF: tokens, SameSite and Fetch Metadata”. OWASP’s SAML cheat sheet calls the flow inherently less secure by design and says to enable it only where required. NIST SP 800-63C-4 lists prohibiting IdP-initiated flows among its protections against assertion injection, and at its second federation level it requires the relying party to start the transaction, while letting the IdP signal the app to start one. So the default is to refuse unsolicited responses and to answer a portal tile with a request of the app’s own. Choose for one connection:

Interactive · answer and compare How should this connection allow a launch from the portal? Answer two questions about the customer's portal. The table marks the flows that fit and says what the others cost.
Do the customer's people need to start from the identity provider's portal?
Can the customer's administrator add a tile that opens a web address of yours?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to start a sign-in against four things that decide between them
Flow Login CSRF stopped A saved request behind it Starts from the portal Little to set up
Our own start only The first request is: ours, from our address Good a response with no saved request is refused Good InResponseTo is checked on every response Poor people must open our address themselves Good nothing extra on either side
Tile that opens our start The first request is: ours, after a portal click Good the click only opens our start, which makes the request Good every response still answers a request we saved Good a tile in the portal, one extra redirect Mixed the customer adds a link tile to their portal
Accept unsolicited responses The first request is: the identity provider's response Poor nothing ties the response to this person's browser Poor no request ID exists, so InResponseTo cannot guard it Good the portal posts straight to our service Mixed a switch per connection, a landing page, an alert
How the answers decide
  • “Do the customer's people need to start from the identity provider's portal?” No, they can use our address: rules out Accept unsolicited responses and adds a caveat to Tile that opens our start. Nobody needs the portal, so an unsolicited response would add login CSRF and a replay risk for nothing. A tile still works, but it is one more thing to maintain.
  • “Do the customer's people need to start from the identity provider's portal?” Yes, they live in the portal and “Can the customer's administrator add a tile that opens a web address of yours?” Yes: rules out Our own start only and Accept unsolicited responses. A tile that opens your own start address gives the launch from the portal and keeps a saved request behind every response. An unsolicited response is not needed.
  • “Do the customer's people need to start from the identity provider's portal?” Yes, they live in the portal and “Can the customer's administrator add a tile that opens a web address of yours?” No, the portal only posts responses: rules out Our own start only and Tile that opens our start and adds a caveat to Accept unsolicited responses. Only an unsolicited response launches from a portal that cannot open a web address. Allow it for that connection alone, with a fixed landing page, signed assertions, the replay memory and an alert on its volume.

In short: start from your own address, take a portal tile only as a link to that address, and accept unsolicited responses only from a portal that cannot do that, one connection at a time.

Checkpoint

Check: Who starts a SAML sign-in

  1. Question 1 of 2 Which sign-in leaves the app with a saved request to compare each response with?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One that the app starts, with an AuthnRequest of its own

    The app that starts the sign-in saves a request ID and its wanted path before the redirect. The first step-through ends with that ID used up, and the second shows an unsolicited response arriving with nothing saved.

  2. Question 2 of 2 A customer's portal can only post responses and cannot open a web address of yours. What does the table allow for that connection?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Unsolicited responses for that connection alone, with a fixed landing page, the replay memory and an alert

    The third row of the table's rules says it: allow it for that connection alone, and add what the request would have given you, which is a fixed landing page, the replay memory and an alert on its volume.

Metadata pins one identity provider to one connection

Before any sign-in, the two sides exchange SAML metadata: An XML document in which an identity provider or service provider describes itself: its entity ID, endpoints, bindings and signing keys. The other side keeps it as the trust record for the connection., an XML document in which each describes itself. The app keeps the IdP’s metadata as the trust record of one connection, and the IdP keeps the app’s. These are the parts that matter, and what each pins:

Part What the app learns or pins Without it
entityID The name the IdP signs under, which every Issuer is compared with An assertion from another IdP is taken for this one’s
SingleSignOnService Where to send the request, and by which binding A request goes to an address nobody agreed
KeyDescriptor use="signing" The certificate whose key verifies this IdP’s signatures A key taken from the message is trusted
validUntil, cacheDuration How long this copy may be used An old copy outlives a change of key
On the app’s side: AssertionConsumerService, AuthnRequestsSigned, WantAssertionsSigned Where responses go, and whether requests and assertions are signed The IdP posts to an address you did not mean

The program turns nine versions of an IdP’s metadata into at most one record each. It starts after the metadata has been trusted: the SAML2Int profile asks that metadata be consumed only after its signature, or the TLS certificate of the connection it came over, has been verified, and that metadata with no validUntil, or one too far ahead, be refused.

Nine versions of an IdP's metadata, and the app's own Python · metadata/metadata_record.py
"""Reading an IdP's metadata into one trust record, refusing what cannot be trusted, and writing the app's own metadata.

The metadata arrives only after its signature, or the TLS connection it was fetched over, has been checked: this
program starts from there. The certificates below are 40-byte stand-ins, because the program only fingerprints them."""

import base64
import hashlib
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone

MD = "urn:oasis:names:tc:SAML:2.0:metadata"
DS = "http://www.w3.org/2000/09/xmldsig#"
PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
REDIRECT = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
NOW = datetime(2030, 6, 1, tzinfo=timezone.utc)  # the app's clock for this run
MAX_AHEAD = timedelta(days=400)  # the app's own limit on how far ahead validUntil may reach

CONNECTION = {"entity_id": "https://login.acme.example/saml", "tenant": "acme"}
SP_ENTITY = "https://app.ledgerly.example/saml/sp"
SP_ACS = "https://app.ledgerly.example/saml/acs"


def stand_in(seed):
    return base64.b64encode(hashlib.sha256(seed.encode()).digest()[:40]).decode()


def idp_metadata(entity="https://login.acme.example/saml", valid="2031-01-01T00:00:00Z", sso="https://login.acme.example/saml/sso", keys=(("signing", "key-1"), ("encryption", "key-2")), doctype=""):
    descriptors = "".join(
        f'<md:KeyDescriptor use="{use}"><ds:KeyInfo><ds:X509Data><ds:X509Certificate>{stand_in(seed)}</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor>'
        for use, seed in keys
    )
    until = f' validUntil="{valid}"' if valid else ""
    return (
        f'{doctype}<md:EntityDescriptor xmlns:md="{MD}" xmlns:ds="{DS}" entityID="{entity}"{until}>'
        f'<md:IDPSSODescriptor protocolSupportEnumeration="{PROTOCOL}" WantAuthnRequestsSigned="false">{descriptors}'
        f'<md:SingleSignOnService Binding="{REDIRECT}" Location="{sso}"/>'
        f'<md:SingleSignOnService Binding="{POST}" Location="{sso}"/>'
        "</md:IDPSSODescriptor></md:EntityDescriptor>"
    ).encode()


class NoDoctype(ET.TreeBuilder):
    def doctype(self, name, pubid, system):
        raise ValueError("DOCTYPE refused")


def trust_record(data, connection):
    """The one record kept for a connection, or a ValueError that says why the metadata is not accepted."""
    parser = ET.XMLParser(target=NoDoctype())
    parser.feed(data)
    root = parser.close()
    if root.tag != f"{{{MD}}}EntityDescriptor":
        raise ValueError("the root is not an EntityDescriptor")
    if root.get("entityID") != connection["entity_id"]:
        raise ValueError(f"entityID {root.get('entityID')} is not the one pinned for {connection['tenant']}")
    valid_until = root.get("validUntil")
    if not valid_until:
        raise ValueError("no validUntil on the root element")
    ends = datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
    if ends <= NOW:
        raise ValueError("the metadata has expired")
    if ends - NOW > MAX_AHEAD:
        raise ValueError(f"validUntil is {(ends - NOW).days} days ahead; the limit is {MAX_AHEAD.days}")
    idp = root.find(f"{{{MD}}}IDPSSODescriptor")
    if idp is None or PROTOCOL not in idp.get("protocolSupportEnumeration", ""):
        raise ValueError("no SAML 2.0 IDPSSODescriptor")
    endpoints = [(e.get("Binding").rsplit(":", 1)[1], e.get("Location")) for e in idp.findall(f"{{{MD}}}SingleSignOnService")]
    for _, location in endpoints:
        if not location.startswith("https://"):
            raise ValueError(f"single sign-on endpoint {location} is not https")
    fingerprints = []
    for key in idp.findall(f"{{{MD}}}KeyDescriptor"):
        if key.get("use") == "encryption":
            continue  # a key for encrypting to the IdP never verifies its signatures
        cert = key.find(f".//{{{DS}}}X509Certificate").text.strip()
        fingerprints.append(hashlib.sha256(base64.b64decode(cert)).hexdigest()[:16])
    if not fingerprints:
        raise ValueError("no signing key")
    return {"entityID": root.get("entityID"), "sso": endpoints, "signing": fingerprints, "days_left": (ends - NOW).days}


def sp_metadata():
    """The app's own metadata, built with a serializer, never by pasting strings together."""
    ET.register_namespace("md", MD)
    root = ET.Element(f"{{{MD}}}EntityDescriptor", {"entityID": SP_ENTITY})
    sp = ET.SubElement(root, f"{{{MD}}}SPSSODescriptor", {"protocolSupportEnumeration": PROTOCOL, "AuthnRequestsSigned": "false", "WantAssertionsSigned": "true"})
    ET.SubElement(sp, f"{{{MD}}}AssertionConsumerService", {"Binding": POST, "Location": SP_ACS, "index": "0", "isDefault": "true"})
    ET.indent(root)
    return ET.tostring(root, encoding="unicode")


CASES = [
    ("a good IdP, two keys of which one signs", idp_metadata()),
    ("a signing key and its replacement", idp_metadata(keys=(("signing", "key-1"), ("signing", "key-3")))),
    ("another IdP's entityID", idp_metadata(entity="https://sso.globex.example/saml")),
    ("no validUntil", idp_metadata(valid="")),
    ("already expired", idp_metadata(valid="2029-12-31T00:00:00Z")),
    ("valid for five years", idp_metadata(valid="2035-06-01T00:00:00Z")),
    ("single sign-on over http", idp_metadata(sso="http://login.acme.example/saml/sso")),
    ("only an encryption key", idp_metadata(keys=(("encryption", "key-2"),))),
    ("a DOCTYPE in the file", idp_metadata(doctype='<!DOCTYPE md [<!ENTITY n "x">]>')),
]
for label, data in CASES:
    try:
        record = trust_record(data, CONNECTION)
        print(f"{label}\n  kept: {record['entityID']}, {len(record['signing'])} signing key(s) {record['signing']}, {record['days_left']} days left")
        print(f"        sign-in endpoints {[b for b, _ in record['sso']]} at {record['sso'][0][1]}")
    except ValueError as error:
        print(f"{label}\n  refused: {error}")

print("\nthe app's own metadata for the IdP's administrator")
print(sp_metadata())

Output

a good IdP, two keys of which one signs
  kept: https://login.acme.example/saml, 1 signing key(s) ['a64fee24fe255fad'], 214 days left
        sign-in endpoints ['HTTP-Redirect', 'HTTP-POST'] at https://login.acme.example/saml/sso
a signing key and its replacement
  kept: https://login.acme.example/saml, 2 signing key(s) ['a64fee24fe255fad', '32908306a99fc65c'], 214 days left
        sign-in endpoints ['HTTP-Redirect', 'HTTP-POST'] at https://login.acme.example/saml/sso
another IdP's entityID
  refused: entityID https://sso.globex.example/saml is not the one pinned for acme
no validUntil
  refused: no validUntil on the root element
already expired
  refused: the metadata has expired
valid for five years
  refused: validUntil is 1826 days ahead; the limit is 400
single sign-on over http
  refused: single sign-on endpoint http://login.acme.example/saml/sso is not https
only an encryption key
  refused: no signing key
a DOCTYPE in the file
  refused: DOCTYPE refused

the app's own metadata for the IdP's administrator
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://app.ledgerly.example/saml/sp">
  <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" AuthnRequestsSigned="false" WantAssertionsSigned="true">
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://app.ledgerly.example/saml/acs" index="0" isDefault="true" />
  </md:SPSSODescriptor>
</md:EntityDescriptor>

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 metadata_record.py

Two versions were kept: the ordinary one, and one with a signing key and its replacement, which is how a key rollover looks, so both keys are trusted until the old one is removed. Seven were refused: another IdP’s entityID, since a record is pinned to the name its connection was set up with; no validUntil; an expired one; one valid for five years, past the app’s own limit of 400 days; single sign-on over http; a file with only an encryption key, which is for sending encrypted data to the IdP and never verifies its signatures; and a file with a DOCTYPE, refused as in the lesson on XML parsing. The last lines are the app’s own metadata, built with a serializer: one ACS on the POST binding, and WantAssertionsSigned set to true.

Verifying with the certificate that came in the message

A response may carry a certificate of its own in KeyInfo, and a verifier that trusts it checks the sender against itself. The key comes from this connection’s trust record. The next lesson, on signature wrapping, shows what goes wrong otherwise.

Checkpoint

Check: Metadata as a trust record

  1. Question 1 of 2 A response carries a certificate in its own KeyInfo, and that certificate verifies its signature. Where does the verification key come from?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: From the signing key in this connection's trust record

    The trust record holds the fingerprints of the signing keys in the metadata. The mistake callout names the same slip, and the next lesson shows what comes of it.

  2. Question 2 of 2 An identity provider's metadata arrives with a validUntil five years ahead. What does the program do, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It refuses the file, since the app allows 400 days at most and an old copy could outlive a change of key

    Of the nine versions, two were kept and seven refused. This one was refused with the message that validUntil is 1826 days ahead and the limit is 400.

A valid signature still leaves eight checks

The assertion of a browser sign-in is a bearer assertion: An assertion that any holder can present, with only its signature and validity window to tie it to a person's browser. The SAML browser sign-in uses bearer assertions., which anyone who holds it can present. The signature says that a trusted key signed these bytes. It does not say that the response is for this app, this moment or this sign-in, and each of those has its own rule in the Web Browser SSO profile. The program gives 14 responses to two consumers. Every response has a verified signature except one, and they differ in whom they are for, when they were valid and which request they answer. The first draft checks the signature, the status and the expiry. The fixed consumer makes the eight checks of this table, in this order:

Check What it stops Written down in
The Issuer is this connection’s IdP An assertion from another IdP that the app also trusts Profiles 4.1.4.2, considerations 6.4.3, ASVS 6.8.1
Destination is this ACS A signed response made for another site Bindings 3.4.5.2 and 3.5.5.2
Recipient is this ACS An assertion forwarded by the site it was made for Profiles 4.1.4.3, considerations 6.4.2
The confirmation’s NotOnOrAfter has not passed, within the skew A captured response used late Profiles 4.1.4.3, considerations 6.4.1
InResponseTo is a request this app sent, used once A response nobody asked for Profiles 4.1.4.3 and 4.1.5
NotBefore and NotOnOrAfter hold, and every condition is understood Early use, and restrictions the app ignores Core 2.5.1
The audience includes this app’s entityID An assertion written for another service Profiles 4.1.4.2, ASVS 9.2.3
The assertion ID is new The same response posted again Profiles 4.1.4.5, ASVS 6.8.3
Fourteen responses through a first draft and a fixed consumer Python · checks/saml_checks.py
"""Responses that already passed signature verification, checked by a first draft and by a fixed consumer.

The signature result is the `verified` flag of each fixture, as in an app whose SAML library did that step. Times are
seconds on one made-up clock, so every run prints the same."""

IDP = "https://login.acme.example/saml"
OTHER_IDP = "https://sso.globex.example/saml"
SP = "https://app.ledgerly.example/saml/sp"
OTHER_SP = "https://wiki.acme.example/saml/sp"
ACS = "https://app.ledgerly.example/saml/acs"
OTHER_ACS = "https://wiki.acme.example/saml/acs"
NOW = 10_000  # the clock of the app, in seconds
SKEW = 180  # three minutes either way; the SAML2Int profile asks for three to five
MAX_SESSION = 8 * 3600  # the longest session the app itself allows


def make(n, top=None, **changes):
    """A response in the shape a SAML library hands over, for request number n; `changes` edit its assertion."""
    assertion = {
        "id": f"_a-{n}",
        "issuer": IDP,
        "name_id": "u-1042",
        "recipient": ACS,  # SubjectConfirmationData Recipient
        "confirm_until": NOW + 240,  # SubjectConfirmationData NotOnOrAfter
        "in_response_to": f"_req-{n}",
        "not_before": NOW - 30,  # Conditions NotBefore
        "not_on_or_after": NOW + 240,  # Conditions NotOnOrAfter
        "audiences": [SP],
        "conditions": [],  # kinds of other conditions the assertion carries
        "session_until": NOW + 2 * 3600,  # AuthnStatement SessionNotOnOrAfter
    }
    assertion.update(changes)
    response = {"verified": True, "status": "success", "issuer": IDP, "destination": ACS, "assertion": assertion}
    response.update(top or {})
    return response


def expired(seconds_ago):
    return {"confirm_until": NOW - seconds_ago, "not_on_or_after": NOW - seconds_ago}


CASES = [  # (what the fixture is, the response, the right answer)
    ("an ordinary response", make(1), "accept"),
    ("the same response posted again", make(1), "refuse"),
    ("signature did not verify", make(3, {"verified": False}), "refuse"),
    ("another IdP's assertion", make(4, {"issuer": OTHER_IDP}, issuer=OTHER_IDP), "refuse"),
    ("Destination is another site's ACS", make(5, {"destination": OTHER_ACS}), "refuse"),
    ("Recipient is another site's ACS", make(6, recipient=OTHER_ACS), "refuse"),
    ("Audience is another service", make(7, audiences=[OTHER_SP]), "refuse"),
    ("expired 10 minutes ago", make(8, **expired(600)), "refuse"),
    ("expired 179 seconds ago", make(9, **expired(179)), "accept"),
    ("expired 180 seconds ago", make(10, **expired(180)), "refuse"),
    ("not valid for another 10 minutes", make(11, not_before=NOW + 600), "refuse"),
    ("InResponseTo is a request we never made", make(12, in_response_to="_req-0"), "refuse"),
    ("unsolicited, and unsolicited login is off", make(13, in_response_to=None), "refuse"),
    ("a condition the app does not understand", make(14, conditions=["urn:example:geo-fence"]), "refuse"),
]


class Draft:
    """A first version: the signature, the status and the expiry of the assertion."""

    def check(self, r):
        a = r["assertion"]
        if not r["verified"]:
            return False, "signature did not verify"
        if r["status"] != "success":
            return False, "status is not Success"
        if NOW >= a["not_on_or_after"]:
            return False, "assertion expired"
        return True, a["name_id"]


class Consumer:
    """The checks of the Web Browser SSO profile that a verified response still has to pass, in this order."""

    def __init__(self, pending, allow_unsolicited=False, remember=True):
        self.pending = set(pending)  # IDs of the AuthnRequests this app sent and has not seen answered
        self.allow_unsolicited = allow_unsolicited
        self.remember = remember
        self.seen = {}  # assertion ID -> the time until which it is remembered

    def check(self, r):
        a = r["assertion"]
        if not r["verified"]:
            return False, "signature did not verify"
        if r["status"] != "success":
            return False, "status is not Success"
        if r["issuer"] != IDP or a["issuer"] != IDP:
            return False, "issuer is not this connection's IdP"
        if r.get("destination") != ACS:
            return False, "Destination is not our ACS"
        if a["recipient"] != ACS:
            return False, "Recipient is not our ACS"
        if NOW >= a["confirm_until"] + SKEW:
            return False, "confirmation expired"
        request = a["in_response_to"]
        if request is None:
            if not self.allow_unsolicited:
                return False, "unsolicited response"
        elif request in self.pending:
            self.pending.discard(request)  # an answer is used once
        else:
            return False, "InResponseTo is not a pending request"
        if NOW + SKEW < a["not_before"]:
            return False, "assertion not valid yet"
        if NOW >= a["not_on_or_after"] + SKEW:
            return False, "assertion expired"
        if a["conditions"]:
            return False, "a condition we do not understand"
        if SP not in a["audiences"]:
            return False, "Audience is not our entityID"
        if self.remember:
            self.seen = {i: until for i, until in self.seen.items() if until > NOW}
            if a["id"] in self.seen:
                return False, "assertion ID already used"
            self.seen[a["id"]] = max(a["confirm_until"], a["not_on_or_after"]) + SKEW
        return True, a["name_id"]


requests = [f"_req-{n}" for n in range(1, 15)]  # every fixture's request was sent, but 12 names one that was not
draft, fixed = Draft(), Consumer(requests)
mistakes = {"draft": [0, 0], "fixed": [0, 0]}  # wrongly accepted, wrongly refused

print(f"{'fixture':<42}{'right':<8}{'first draft':<13}fixed consumer")
for label, response, right in CASES:
    shown = {}
    for name, consumer in (("draft", draft), ("fixed", fixed)):
        ok, why = consumer.check(response)
        verdict = "accept" if ok else "refuse"
        if verdict != right:
            mistakes[name][0 if ok else 1] += 1
        shown[name] = verdict if name == "draft" or ok else f"refuse ({why})"
    print(f"{label:<42}{right:<8}{shown['draft']:<13}{shown['fixed']}")

print()
for name, title in (("draft", "first draft"), ("fixed", "fixed consumer")):
    wrong, missed = mistakes[name]
    print(f"{title}: differs from the right answer on {wrong + missed} of {len(CASES)} ({wrong} accepted that it should refuse, {missed} refused that it should accept)")

# A response that passes starts a session; it ends at the earlier of the assertion's SessionNotOnOrAfter and the app's own limit.
ordinary = CASES[0][1]["assertion"]["session_until"] - NOW
print(f"\nthe ordinary response asks for a session of {ordinary} s; the app's own maximum is {MAX_SESSION} s, so the session lasts {min(ordinary, MAX_SESSION)} s")

# Unsolicited login for one legacy tenant leaves no request ID to match, so the memory of used IDs is the only check.
print("\nunsolicited login switched on for a legacy tenant, the same response posted twice")
for remember in (True, False):
    consumer = Consumer([], allow_unsolicited=True, remember=remember)
    verdicts = ["accept" if consumer.check(make(20, in_response_to=None))[0] else "refuse" for _ in range(2)]
    print(f"  memory of used IDs {'on ' if remember else 'off'}: {verdicts[0]}, then {verdicts[1]}")

kept = 240 + SKEW  # an ID is kept for the confirmation window plus the skew
print(f"\nan ID is remembered for {kept} s; at 50 sign-ins a second that is {50 * kept:,} IDs")

Output

fixture                                   right   first draft  fixed consumer
an ordinary response                      accept  accept       accept
the same response posted again            refuse  accept       refuse (InResponseTo is not a pending request)
signature did not verify                  refuse  refuse       refuse (signature did not verify)
another IdP's assertion                   refuse  accept       refuse (issuer is not this connection's IdP)
Destination is another site's ACS         refuse  accept       refuse (Destination is not our ACS)
Recipient is another site's ACS           refuse  accept       refuse (Recipient is not our ACS)
Audience is another service               refuse  accept       refuse (Audience is not our entityID)
expired 10 minutes ago                    refuse  refuse       refuse (confirmation expired)
expired 179 seconds ago                   accept  refuse       accept
expired 180 seconds ago                   refuse  refuse       refuse (confirmation expired)
not valid for another 10 minutes          refuse  accept       refuse (assertion not valid yet)
InResponseTo is a request we never made   refuse  accept       refuse (InResponseTo is not a pending request)
unsolicited, and unsolicited login is off refuse  accept       refuse (unsolicited response)
a condition the app does not understand   refuse  accept       refuse (a condition we do not understand)

first draft: differs from the right answer on 10 of 14 (9 accepted that it should refuse, 1 refused that it should accept)
fixed consumer: differs from the right answer on 0 of 14 (0 accepted that it should refuse, 0 refused that it should accept)

the ordinary response asks for a session of 7200 s; the app's own maximum is 28800 s, so the session lasts 7200 s

unsolicited login switched on for a legacy tenant, the same response posted twice
  memory of used IDs on : accept, then refuse
  memory of used IDs off: accept, then accept

an ID is remembered for 420 s; at 50 sign-ins a second that is 21,000 IDs

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 saml_checks.py

The first draft gave the wrong verdict on 10 of the 14. It accepted nine that should have been refused: the same response posted again, another IdP’s assertion, a Destination and a Recipient that named another site, an assertion for another service, one that was not valid yet, an answer to a request the app never made, an unsolicited response and a condition the app does not understand. It also refused one that should have been accepted, a response that expired 179 seconds ago, because it compared exact clocks. The fixed consumer differed on none, and its reasons name the check that stopped each one.

The Recipient row has a case of its own. An unsigned response can say anything in its Destination, so a signed assertion made for another site’s ACS and wrapped in a response that names yours passes the Destination check. The signed Recipient inside it does not, and that is why both are checked. A response with no Destination at all is refused too: the bindings require one on a signed message, and the OWASP cheat sheet says to reject a response without it.

Key idea

A signature proves who sent a response. The eight checks prove that it is meant for this app, this moment and this sign-in, and that nobody has used it before.

Clock skew gives the time checks an edge

clock skew: The difference between two machines' clocks. A checker that compares timestamps from another system allows a few minutes of skew either way, so a sound response is not refused for a clock that runs fast. is why. Two machines’ clocks differ by seconds or more, and a check that refuses a response one second after its NotOnOrAfter refuses sound sign-ins. The security considerations ask both sides to keep their clocks within a few minutes and the validity of an assertion to a few minutes, and the SAML2Int profile asks deployments to allow between three and five minutes of skew. The program allows three, 180 seconds: it accepted a response that expired 179 seconds ago and refused one that expired 180, because the rule is that now is at or after NotOnOrAfter plus the skew. A longer allowance would widen the window in which a stolen response works.

Move the skew

Press Edit on the program above, set SKEW to 600 and run it again: the response that expired 180 seconds ago and the one that is not valid for another 10 minutes are now accepted. Set it to 0 and the response that expired 179 seconds ago is refused, although it is sound.

Remembering assertion IDs

The second copy of the response was refused for a plain reason: the first copy used up its request. An unsolicited response has no request to use up, so for a connection that allows them the replay memory: The set of assertion IDs a service has accepted, kept until those assertions could no longer be accepted, so that a captured response cannot be used a second time., the set of assertion IDs already accepted, is the only check left. The last part of the output posts one unsolicited response twice: with the memory the second is refused, and without it both are accepted. An ID has to be remembered for as long as the assertion could still be accepted, its NotOnOrAfter plus the skew, which is 240 plus 180, or 420 seconds. At 50 sign-ins a second that is 21,000 IDs, a small table. With several instances of the app it lives in a store they share, and the write must be one step that fails if the ID exists: a read followed by a write lets two instances both pass.

Missing single checks are how real products fail. Grafana Enterprise’s SAML library skipped the InResponseTo check on every response once IdP-initiated login was switched on (CVE-2026-12704), and Keycloak’s SAML brokering did not validate the NotOnOrAfter of the subject confirmation (CVE-2026-1190). Each was one missing line of the table above.

Checkpoint

Check: The checks a verified response needs

  1. Question 1 of 3 With a skew of 180 seconds the program accepted a response that expired 179 seconds ago and refused one that expired 180. Which rule decides?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: Refuse when now is at or after NotOnOrAfter plus the skew

    The fixed consumer compares with now >= NotOnOrAfter + skew. A longer skew makes fewer sound responses fail and leaves a stolen response working for longer.

  2. Question 2 of 3 An unsigned wrapper names your ACS in Destination, but the signed assertion inside it has a Recipient that is another site's ACS. Which check stops it?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: Recipient, because it sits inside the signed assertion and the sender of the wrapper cannot change it

    The program's Recipient fixture is this case: the Destination is yours and the Recipient is another site's. Only the fixed consumer refused it.

  3. Question 3 of 3 Unsolicited login is on for one tenant. Which control is then the only one against posting the same response twice?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: The memory of assertion IDs that were accepted

    The program's last section posted one unsolicited response twice. With the memory the second was refused. Without it both were accepted.

RelayState is a handle for the server, never a destination

RelayState: A value that travels with a SAML message and comes back unchanged, so the app can resume what the person was doing. It is not signed with the assertion, so it counts as input from outside. travels to the IdP with the request and comes back unchanged with the response, so the app can resume what the person was doing. It is not part of the signed assertion. The bindings say that it must not exceed 80 bytes and should be protected against tampering, and they warn that nothing protects the pairing of a message with its RelayState: two valid responses can be recombined by swapping their values. The security considerations list tampering with it as a threat. It is input from outside, like any query parameter, and it can steer a redirect. The first design most teams write puts the wanted path in it and redirects to whatever returns. This program compares that design with one that sends a random handle:

Ten sign-ins through a design that sends the path and one that sends a handle JavaScript · relay/relaystate.mjs
// RelayState carries "where to go afterwards" through the identity provider. Two designs run on the same ten sign-ins.
// The weak one puts the wanted path in RelayState and redirects to whatever comes back. The fixed one sends a random
// handle, keeps the path on the server as a known route, and accepts the handle once, for the request it was made for.
// Handles here come from a seeded generator so that every run prints the same; a real app draws 128 random bits.

const ROUTES = [/^\/orders\/[0-9]{1,9}$/, /^\/reports$/, /^\/settings\/(profile|billing)$/]; // where a sign-in may return to
const HOME = '/';
const LIFETIME = 600; // seconds a pending sign-in is kept
const MAX_RELAY_STATE = 80; // bytes, from the SAML bindings

const weak = {
  start: (path) => encodeURIComponent(path),
  finish: (relayState) => (relayState ? decodeURIComponent(relayState) : HOME),
};

function fixedDesign(seed) {
  let x = seed;
  const next = () => {
    x ^= x << 13;
    x ^= x >>> 17;
    x ^= x << 5;
    return x >>> 0;
  };
  const pending = new Map(); // handle -> { requestId, path, expires }
  return {
    start(path, requestId, now) {
      const handle = 'rs_' + Array.from({ length: 3 }, () => next().toString(36).padStart(7, '0')).join('');
      const known = ROUTES.some((route) => route.test(path));
      pending.set(handle, { requestId, path: known ? path : HOME, expires: now + LIFETIME });
      return handle;
    },
    finish(relayState, requestId, now) {
      if (typeof relayState !== 'string' || relayState.length > MAX_RELAY_STATE) return HOME;
      const entry = pending.get(relayState);
      if (!entry) return HOME;
      pending.delete(relayState); // a handle is used once, whatever happens next
      if (entry.requestId !== requestId || now >= entry.expires) return HOME;
      return entry.path;
    },
  };
}

const leavesSite = (target) => !/^\/(?![\/\\])/.test(target);

const scenarios = [
  ['wants /orders/1042', (d) => d.finish(d.start('/orders/1042', 'r1', 0), 'r1', 5)],
  ['wants /reports', (d) => d.finish(d.start('/reports', 'r1', 0), 'r1', 5)],
  ['wants //evil.example/phish', (d) => d.finish(d.start('//evil.example/phish', 'r1', 0), 'r1', 5)],
  ['wants https://evil.example/', (d) => d.finish(d.start('https://evil.example/', 'r1', 0), 'r1', 5)],
  ['wants /\\evil.example', (d) => d.finish(d.start('/\\evil.example', 'r1', 0), 'r1', 5)],
  ['wants /orders/1042/../../admin', (d) => d.finish(d.start('/orders/1042/../../admin', 'r1', 0), 'r1', 5)],
  ['RelayState posted a second time', (d) => {
    const relayState = d.start('/orders/1042', 'r1', 0);
    d.finish(relayState, 'r1', 5);
    return d.finish(relayState, 'r1', 6);
  }],
  ["RelayState of another sign-in's request", (d) => {
    d.start('/orders/1042', 'r1', 0);
    return d.finish(d.start('/reports', 'r2', 0), 'r1', 5);
  }],
  ['RelayState used after 11 minutes', (d) => d.finish(d.start('/orders/1042', 'r1', 0), 'r1', 660)],
  ['RelayState replaced by https://evil.example/', (d) => {
    d.start('/orders/1042', 'r1', 0);
    return d.finish('https://evil.example/', 'r1', 5);
  }],
];

console.log(`${'sign-in'.padEnd(46)}${'weak design goes to'.padEnd(32)}fixed design goes to`);
let off = { weak: 0, fixed: 0 };
for (const [label, run] of scenarios) {
  const targets = { weak: run(weak), fixed: run(fixedDesign(7)) };
  for (const name of ['weak', 'fixed']) if (leavesSite(targets[name])) off[name]++;
  console.log(`${label.padEnd(46)}${targets.weak.padEnd(32)}${targets.fixed}`);
}
console.log(`\nleft the site: weak design ${off.weak} of ${scenarios.length}, fixed design ${off.fixed} of ${scenarios.length}`);

const sample = fixedDesign(7).start('/orders/1042', 'r1', 0);
console.log(`a handle looks like ${sample} and is ${sample.length} bytes; the SAML bindings allow at most ${MAX_RELAY_STATE}`);

Output

sign-in                                       weak design goes to             fixed design goes to
wants /orders/1042                            /orders/1042                    /orders/1042
wants /reports                                /reports                        /reports
wants //evil.example/phish                    //evil.example/phish            /
wants https://evil.example/                   https://evil.example/           /
wants /\evil.example                          /\evil.example                  /
wants /orders/1042/../../admin                /orders/1042/../../admin        /
RelayState posted a second time               /orders/1042                    /
RelayState of another sign-in's request       /reports                        /
RelayState used after 11 minutes              /orders/1042                    /
RelayState replaced by https://evil.example/  https://evil.example/           /

left the site: weak design 4 of 10, fixed design 0 of 10
a handle looks like rs_0014kbr07s22o71s7d4xv and is 24 bytes; the SAML bindings allow at most 80

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node relaystate.mjs

The weak design sent the browser off the site in 4 of the 10 sign-ins: for a protocol-relative address, an address on another host, a backslash lookalike and a RelayState that had been replaced on the way. The path with dot segments stayed on the site but reached a page the app has no route for, and the replayed and swapped values were followed all the same. The fixed design never left the site. It keeps the path on the server and only if it matches one of its known routes, sends a handle of 24 bytes, accepts the handle once, for the request it was made for, within ten minutes, and sends anything else to the home page. The lesson on open redirects checks a URL against an allowlist of hosts. Keeping routes instead of URLs needs no URL parser at all.

A bad RelayState must not fail the sign-in. The assertion was verified, so the person has signed in, and the home page is a safe place to land. An unsolicited response may carry a RelayState by agreement, such as a deep link, and the same rule applies to it: it is a candidate that has to match a route.

Putting state in RelayState

A record ID, a tenant name or a role in RelayState gives the browser, the IdP and every log in between a value to read and change. Keep the state on the server and send the handle.

Checkpoint

Check: RelayState as a handle

  1. Question 1 of 2 Why does the fixed design send a handle of 24 bytes rather than the wanted path?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The path stays on the server, so nothing that the browser or the identity provider can change decides where the person lands

    The weak design, which sent the path, left the site in 4 of the 10 sign-ins. The fixed design, which sent a handle, left it in none.

  2. Question 2 of 2 A response passes every check, but its RelayState is a handle that nobody made. What does the fixed design do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It signs the person in and sends them to the home page

    Every failure of the RelayState store ended at the home page in the program: a second use, a swapped value, a late one and a replaced one.

After the response: a new session, a capped lifetime and a logout that works

A response that passes every check proves one thing, that the IdP vouched for this person a moment ago. The session that follows is the app’s own, and three decisions belong to it. The app issues a new session ID at sign-in and ends any earlier one, so that an ID planted before the sign-in is worth nothing, as the lesson on sessions explains and ASVS 7.2.4 asks. It caps the lifetime: the AuthnStatement may carry a SessionNotOnOrAfter, and the profile says the security context should be discarded when that time is reached, unless the person signs in again. The app’s own maximum applies too, so the session ends at the earlier of the two. In the program with the 14 responses, the ordinary response asked for 7,200 seconds and the app allows 28,800, so the session lasts 7,200. And a session needs the person’s own action: ASVS 7.6.2 asks that no session be created without it, which is one more reason to refuse unsolicited responses.

Logout is where SAML is least reliable. Single logout lets the IdP and the apps end each other’s sessions, and it depends on a browser that stays open, an app that is up and a message that arrives. So the app’s own logout must end the local session whether or not any logout message goes out or comes back: delete the server-side session record first, then tell the IdP if you can. A logout request that does arrive is a message from outside, and it comes by the Redirect binding, so the app unpacks a message from a URL. This program shows the two places where that has gone wrong:

A logout request in a URL: raw octets for the signature, a cap for the inflating Python · redirect/redirect_binding.py
"""A message that arrives in a URL: a logout request from the IdP over the HTTP-Redirect binding.

Two things go wrong when an app unpacks such a message. The signature covers the octets as they were sent, so a
verifier that decodes and re-encodes the parameters can refuse a genuine message. And DEFLATE can expand a few
kilobytes into gigabytes, so the inflating needs a cap. The signature below is an HMAC with a throwaway key, a
stand-in so that the program needs only the standard library: the octet string it signs is the one the binding
defines for RSA and ECDSA signatures."""

import base64
import hashlib
import hmac
import re
import zlib
from urllib.parse import parse_qsl, quote, unquote

KEY = b"throwaway-test-key"  # stands in for the IdP's private key
SIG_ALG = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
LOGOUT = (
    '<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_lo-1" Version="2.0" '
    'IssueInstant="2030-06-01T00:00:00Z" Destination="https://app.ledgerly.example/saml/slo">'
    '<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://login.acme.example/saml</saml:Issuer>'
    '<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">u-1042</saml:NameID></samlp:LogoutRequest>'
)
CAP = 64 * 1024  # the most an inflated message may be, in bytes
MAX_QUERY = 8 * 1024  # the longest query string accepted at all


def sign(octets):
    return base64.b64encode(hmac.new(KEY, octets, hashlib.sha256).digest()).decode()


def deflate(data):
    packer = zlib.compressobj(9, zlib.DEFLATED, -15)  # raw DEFLATE, as the binding says
    return packer.compress(data) + packer.flush()


def lower_hex(text):
    return re.sub(r"%[0-9A-F]{2}", lambda m: m.group().lower(), text)


def message_url(encode, relay="rs_0014kbr07s22o71s7d4xv", order=("SAMLRequest", "RelayState", "SigAlg", "Signature")):
    """The query string an IdP sends; `encode` is how that IdP percent-encodes (the binding fixes only the order of the signed octets)."""
    values = {
        "SAMLRequest": encode(base64.b64encode(deflate(LOGOUT.encode())).decode()),
        "RelayState": encode(relay),
        "SigAlg": encode(SIG_ALG),
    }
    signed = f"SAMLRequest={values['SAMLRequest']}&RelayState={values['RelayState']}&SigAlg={values['SigAlg']}"
    values["Signature"] = encode(sign(signed.encode()))
    return "&".join(f"{name}={values[name]}" for name in order)


def re_encoded_check(query):
    """Decodes the parameters, then builds the octets again with this program's own encoder."""
    p = dict(parse_qsl(query))
    again = f"SAMLRequest={quote(p['SAMLRequest'], safe='')}&RelayState={quote(p['RelayState'], safe='')}&SigAlg={quote(p['SigAlg'], safe='')}"
    return hmac.compare_digest(sign(again.encode()), p["Signature"])


def raw_octets_check(query):
    """Cuts the query at & and = without decoding anything, and signs the values as they were received."""
    raw = dict(part.split("=", 1) for part in query.split("&"))
    signed = f"SAMLRequest={raw['SAMLRequest']}&RelayState={raw['RelayState']}&SigAlg={raw['SigAlg']}"
    return hmac.compare_digest(sign(signed.encode()), unquote(raw["Signature"]))


def inflate_capped(value):
    data = base64.b64decode(unquote(value))
    packer = zlib.decompressobj(-15)
    out = packer.decompress(data, CAP + 1)
    if len(out) > CAP:
        raise ValueError(f"more than {CAP:,} bytes once inflated")
    return out


upper = message_url(lambda s: quote(s, safe=""))
lower = message_url(lambda s: lower_hex(quote(s, safe="")))
reordered = message_url(lambda s: quote(s, safe=""), order=("Signature", "SigAlg", "RelayState", "SAMLRequest"))
tampered = upper.replace("rs_0014kbr07s22o71s7d4xv", "rs_0014kbr07s22o71s7d4xw")
fixtures = [
    ("IdP encodes with upper-case hex", upper, True),
    ("IdP encodes with lower-case hex", lower, True),
    ("parameters in another order", reordered, True),
    ("RelayState changed on the way", tampered, False),
]
print(f"{'message':<34}{'right':<8}{'re-encoded':<12}raw octets")
for label, query, genuine in fixtures:
    verdicts = ["accept" if check(query) else "refuse" for check in (re_encoded_check, raw_octets_check)]
    print(f"{label:<34}{'accept' if genuine else 'refuse':<8}{verdicts[0]:<12}{verdicts[1]}")

print("\ninflating")
request = inflate_capped(upper.split("&")[0].split("=", 1)[1])
print(f"  a logout request: {len(request)} bytes once inflated, accepted")
bomb = deflate(b"\0" * 10_000_000)
assert len(bomb) < 20_000
naive = zlib.decompress(bomb, -15)
print(f"  a payload under 20,000 bytes, inflated without a cap: {len(naive):,} bytes")
try:
    inflate_capped(quote(base64.b64encode(bomb).decode(), safe=""))
except ValueError as error:
    print(f"  the same payload with a cap: refused, {error}")

Output

message                           right   re-encoded  raw octets
IdP encodes with upper-case hex   accept  accept      accept
IdP encodes with lower-case hex   accept  refuse      accept
parameters in another order       accept  accept      accept
RelayState changed on the way     refuse  refuse      refuse

inflating
  a logout request: 400 bytes once inflated, accepted
  a payload under 20,000 bytes, inflated without a cap: 10,000,000 bytes
  the same payload with a cap: refused, more than 65,536 bytes once inflated

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 redirect_binding.py

The signature covers the octets SAMLRequest=…&RelayState=…&SigAlg=… exactly as sent, and the binding says that URL encoding is not canonical, so the verifier must use the encoded values it received. The check that decoded the parameters and encoded them again refused a genuine message whose IdP writes its hex digits in lower case, and the check over the raw octets refused only the message that had been changed. The second place is the inflating. A payload of under 20,000 bytes expanded to 10,000,000 without a cap and was refused with one of 65,536 bytes. In CVE-2023-28119 a Go library inflated input with no limit, and in CVE-2023-40178 a Node library accepted a logout request after its NotOnOrAfter. The signature in the program is an HMAC with a throwaway key, standing in for RSA or ECDSA so that it needs only the standard library; which bytes are signed is the same. Verify first, with the key of the connection named in the URL, and inflate second.

Checkpoint

Check: The session after SAML

  1. Question 1 of 2 The ordinary response asks for a session of 7,200 seconds in its SessionNotOnOrAfter, and the app's own maximum is 28,800 seconds. How many seconds does the session last?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 7200 seconds

    The session ends at the earlier of the assertion's SessionNotOnOrAfter and the app's own maximum. The program with 14 responses prints it: 7200 s.

  2. Question 2 of 2 The identity provider is down, so the app's logout button cannot send it a LogoutRequest. What must still happen?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The app deletes the server-side session record, so the session ID no longer works

    Single logout depends on a browser that stays open, an app that is up and a message that arrives. Local logout depends on none of them.

Practise: the response checks and a RelayState store

Build both pieces yourself. The first exercise is the eight checks, with the edges of the time checks and the rule that a request answers once. The second is the store that turns a wanted path into a handle and back:

Code lab · 2 steps

Build the response checks and a RelayState store

Write the checks that a verified SAML response still needs, then a store that turns RelayState into a one-use handle.

  1. The checks a verified SAML response still needs To do
  2. A RelayState store that hands out one-use handles To do

Exercise · Hard · Python

The checks a verified SAML response still needs

Write check_response(response, context) in response_checks.py. The signature of the response has been verified already: response["verified"] says so. The function returns (True, name_id) when the response may start a session, or (False, reason) for the first check in this list that fails.

response has verified, status, issuer, destination (it may be None) and an assertion with id, issuer, name_id, recipient, confirm_until (the NotOnOrAfter of the bearer confirmation), in_response_to (it may be None), not_before, not_on_or_after, audiences (a list) and conditions (a list of conditions other than time and audience). context has idp, sp, acs, now, skew (all numbers are seconds), pending (a set of the IDs of the requests this app sent), seen (a dict of assertion ID to the time until which it is remembered) and allow_unsolicited.

  1. verified is false: "signature did not verify".
  2. status is not "success": "status is not Success".
  3. The issuer of the response or of the assertion is not context["idp"]: "issuer is not this connection's IdP".
  4. destination is not context["acs"], or is missing: "Destination is not our ACS".
  5. recipient is not context["acs"]: "Recipient is not our ACS".
  6. now >= confirm_until + skew: "confirmation expired".
  7. in_response_to is None: refuse with "unsolicited response" unless allow_unsolicited. Otherwise it must be in pending, or refuse with "InResponseTo is not a pending request". A request answers once: remove it from pending as soon as you have matched it, whatever the later checks say.
  8. now + skew < not_before: "assertion not valid yet".
  9. now >= not_on_or_after + skew: "assertion expired".
  10. conditions is not empty: "a condition we do not understand".
  11. sp is not in audiences: "Audience is not our entityID".
  12. First forget the entries of seen whose time is not after now. If the assertion ID is still in seen: "assertion ID already used". Otherwise remember it until max(confirm_until, not_on_or_after) + skew.

The starter trusts a verified signature and nothing else.

Starter code · response_checks.py

def check_response(response, context):
    """Starter: it trusts a response because its signature verified, and checks nothing else."""
    if not response["verified"]:
        return False, "signature did not verify"
    return True, response["assertion"]["name_id"]
The sample tests · test_response_checks.py
from response_checks import check_response

IDP = "https://login.acme.example/saml"
SP = "https://app.ledgerly.example/saml/sp"
ACS = "https://app.ledgerly.example/saml/acs"
OTHER = "https://wiki.acme.example/saml/acs"
NOW = 10_000


def make(top=None, **changes):
    assertion = {
        "id": "_a-1", "issuer": IDP, "name_id": "u-1042", "recipient": ACS, "confirm_until": NOW + 240,
        "in_response_to": "_req-1", "not_before": NOW - 30, "not_on_or_after": NOW + 240, "audiences": [SP],
        "conditions": [],
    }
    assertion.update(changes)
    response = {"verified": True, "status": "success", "issuer": IDP, "destination": ACS, "assertion": assertion}
    response.update(top or {})
    return response


def context(**changes):
    base = {"idp": IDP, "sp": SP, "acs": ACS, "now": NOW, "skew": 180, "pending": {"_req-1"}, "seen": {}, "allow_unsolicited": False}
    base.update(changes)
    return base


def refused(response, reason, **changes):
    result = check_response(response, context(**changes))
    assert result == (False, reason), f"expected a refusal for {reason!r}, got {result!r}"


def test_an_ordinary_response_is_accepted_and_its_request_is_used_up():
    ctx = context()
    assert check_response(make(), ctx) == (True, "u-1042")
    assert ctx["pending"] == set()
    assert ctx["seen"] == {"_a-1": NOW + 240 + 180}


def test_each_check_refuses_with_its_own_reason():
    refused(make({"verified": False}), "signature did not verify")
    refused(make({"status": "requester"}), "status is not Success")
    refused(make({"issuer": "https://sso.globex.example/saml"}), "issuer is not this connection's IdP")
    refused(make(issuer="https://sso.globex.example/saml"), "issuer is not this connection's IdP")
    refused(make({"destination": OTHER}), "Destination is not our ACS")
    refused(make({"destination": None}), "Destination is not our ACS")
    refused(make(recipient=OTHER), "Recipient is not our ACS")
    refused(make(audiences=["https://wiki.acme.example/saml/sp"]), "Audience is not our entityID")
    refused(make(conditions=["urn:example:geo-fence"]), "a condition we do not understand")


def test_the_skew_edges_of_the_three_time_checks():
    ok = lambda response: check_response(response, context())[0]
    assert ok(make(confirm_until=NOW - 179, not_on_or_after=NOW - 179))
    refused(make(confirm_until=NOW - 180), "confirmation expired")
    refused(make(not_on_or_after=NOW - 180), "assertion expired")
    assert ok(make(not_before=NOW + 180))
    refused(make(not_before=NOW + 181), "assertion not valid yet")


def test_requests_are_matched_once_and_unsolicited_responses_need_a_switch():
    refused(make(in_response_to="_req-0"), "InResponseTo is not a pending request")
    refused(make(in_response_to=None), "unsolicited response")
    ctx = context(allow_unsolicited=True)
    assert check_response(make(in_response_to=None), ctx) == (True, "u-1042")
    refused(make(in_response_to="_req-0"), "InResponseTo is not a pending request", allow_unsolicited=True)
    ctx = context()
    assert check_response(make(), ctx)[0]
    assert check_response(make(id="_a-2"), ctx) == (False, "InResponseTo is not a pending request")


def test_a_request_is_used_up_even_when_a_later_check_fails():
    ctx = context()
    assert check_response(make(audiences=["https://wiki.acme.example/saml/sp"]), ctx) == (False, "Audience is not our entityID")
    assert ctx["pending"] == set()


def test_the_first_failing_check_in_the_list_decides_the_reason():
    both = make({"issuer": "https://sso.globex.example/saml", "destination": OTHER}, recipient=OTHER, audiences=[])
    refused(both, "issuer is not this connection's IdP")
    refused(make({"destination": OTHER}, recipient=OTHER), "Destination is not our ACS")
    refused(make(recipient=OTHER, confirm_until=NOW - 600), "Recipient is not our ACS")
    refused(make(not_before=NOW + 600, not_on_or_after=NOW - 600, in_response_to="_req-0"), "InResponseTo is not a pending request")
    refused(make(not_before=NOW + 600, not_on_or_after=NOW - 600), "assertion not valid yet")
    refused(make(audiences=[], conditions=["x"]), "a condition we do not understand")


def test_an_assertion_id_is_remembered_until_it_can_no_longer_be_accepted():
    ctx = context(allow_unsolicited=True)
    unsolicited = make(in_response_to=None, id="_a-9")
    assert check_response(unsolicited, ctx)[0]
    assert ctx["seen"] == {"_a-9": NOW + 240 + 180}
    assert check_response(unsolicited, ctx) == (False, "assertion ID already used")
    later = context(allow_unsolicited=True, now=NOW + 420, seen={"_a-9": NOW + 420})
    later_response = make(in_response_to=None, id="_a-9", confirm_until=NOW + 420 + 240, not_before=NOW + 420 - 30, not_on_or_after=NOW + 420 + 240)
    assert check_response(later_response, later) == (True, "u-1042")
    assert later["seen"] == {"_a-9": NOW + 420 + 240 + 180}
    kept = context(allow_unsolicited=True, seen={"_a-9": NOW + 1})
    assert check_response(unsolicited, kept) == (False, "assertion ID already used")
    assert kept["seen"] == {"_a-9": NOW + 1}


def test_a_refused_response_leaves_no_trace_in_the_memory_of_ids():
    ctx = context()
    check_response(make(audiences=[]), ctx)
    assert ctx["seen"] == {}
    expiry_wins = make(confirm_until=NOW + 300, not_on_or_after=NOW + 240)
    ctx = context()
    assert check_response(expiry_wins, ctx)[0]
    assert ctx["seen"] == {"_a-1": NOW + 300 + 180}
A hint

Return at the first failing check, in the order of the list, and keep the comparisons exactly as written: >= for the two expiries and < for the start. Take response["assertion"] into a variable, test in_response_to is None before you look in pending, and use context["pending"].discard(request) when you match one. Build the new seen entries only after every other check has passed.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · JavaScript

A RelayState store that hands out one-use handles

Write createRelayStore(newHandle) in relayState.mjs. It returns { start, finish }, the two halves of RelayState handling around one SAML sign-in. newHandle() gives the next random handle, a string; the tests pass in a counter so that their results are repeatable.

  • start(path, requestId, now) makes a handle with newHandle(), keeps { requestId, path, expires } under it and returns the handle. path is kept only when it is one of the routes in ROUTES; any other path is kept as "/". expires is now + 600.
  • finish(relayState, requestId, now) returns the path to redirect to, and returns "/" when relayState is not a string, is longer than 80 characters, is not a handle that start made, was made for another requestId, or has expired (now >= expires).
  • A handle is used up the moment finish finds it, even when the request does not match or the handle has expired.

The starter puts the path itself into RelayState and returns whatever comes back.

Starter code · relayState.mjs

export const ROUTES = [/^\/orders\/[0-9]{1,9}$/, /^\/reports$/, /^\/settings\/(profile|billing)$/];

export function createRelayStore(newHandle) {
  // Starter: RelayState is the path itself, and finish() sends the browser wherever it says.
  return {
    start(path, requestId, now) {
      return path;
    },
    finish(relayState, requestId, now) {
      return relayState;
    },
  };
}
The sample tests · relayState.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { createRelayStore } from './relayState.mjs';

function store() {
  let n = 0;
  return createRelayStore(() => `rs_${++n}`);
}

test('a handle returns the saved route, once', () => {
  const s = store();
  const handle = s.start('/orders/1042', 'r1', 0);
  assert.notEqual(handle, '/orders/1042');
  assert.equal(s.finish(handle, 'r1', 5), '/orders/1042');
  assert.equal(s.finish(handle, 'r1', 6), '/');
});

test('only known routes are kept; the handle is not the path', () => {
  const s = store();
  for (const path of ['/reports', '/settings/billing', '/orders/7']) assert.equal(s.finish(s.start(path, 'r1', 0), 'r1', 1), path);
  for (const path of ['//evil.example/x', 'https://evil.example/', '/\\evil.example', '/orders/1042/../../admin', '/orders/', '/settings/other', '']) {
    assert.equal(s.finish(s.start(path, 'r1', 0), 'r1', 1), '/', `${JSON.stringify(path)} must fall back to the home page`);
  }
});

test('a handle made for another request is refused, and used up by trying it', () => {
  const s = store();
  const handle = s.start('/reports', 'r2', 0);
  assert.equal(s.finish(handle, 'r1', 5), '/');
  assert.equal(s.finish(handle, 'r2', 6), '/');
});

test('a handle lives 600 seconds: the last second is the 599th', () => {
  const a = store();
  assert.equal(a.finish(a.start('/reports', 'r1', 100), 'r1', 699), '/reports');
  const b = store();
  assert.equal(b.finish(b.start('/reports', 'r1', 100), 'r1', 700), '/');
  const c = store();
  const handle = c.start('/reports', 'r1', 100);
  assert.equal(c.finish(handle, 'r1', 800), '/');
  assert.equal(c.finish(handle, 'r1', 801), '/');
});

test('anything that is not a handle of this store goes home', () => {
  const s = store();
  s.start('/reports', 'r1', 0);
  assert.equal(s.finish('https://evil.example/', 'r1', 1), '/');
  assert.equal(s.finish('/reports', 'r1', 1), '/');
  assert.equal(s.finish(undefined, 'r1', 1), '/');
  assert.equal(s.finish(42, 'r1', 1), '/');
  assert.equal(s.finish(null, 'r1', 1), '/');
});

test('a RelayState of more than 80 characters is refused, 80 is allowed', () => {
  const long = createRelayStore(() => 'h'.repeat(81));
  assert.equal(long.finish(long.start('/reports', 'r1', 0), 'r1', 1), '/');
  const exact = createRelayStore(() => 'h'.repeat(80));
  assert.equal(exact.finish(exact.start('/reports', 'r1', 0), 'r1', 1), '/reports');
});

test('two sign-ins in flight do not mix their paths', () => {
  const s = store();
  const first = s.start('/reports', 'r1', 0);
  const second = s.start('/orders/9', 'r2', 0);
  assert.equal(s.finish(second, 'r2', 1), '/orders/9');
  assert.equal(s.finish(first, 'r1', 2), '/reports');
});
A hint

Keep the entries in a Map from handle to entry. In finish, check that relayState is a short string, get the entry, return "/" if there is none, delete the handle at once, and only then compare the request and the time. In start, decide the kept path with ROUTES.some((route) => route.test(path)).

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then check yourself, one question at a time, weakest idea first:

Practice that adapts to you

Practise this lesson

14 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 Which binding carried the response back to the app in the lesson's sign-in?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: HTTP-POST, a form that the browser submits

    The request went out by the Redirect binding and the response came back by the POST binding, which is also the split the interoperability profile asks for.

  2. Question 2 of 14 What makes a SAML response unsolicited?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: No request of the app asked for it, so it carries no InResponseTo

    The checks that depend on a saved request, InResponseTo among them, have nothing to compare when there is none.

  3. Question 3 of 14 Why can an unsolicited response lead to login CSRF?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: Nothing ties it to the browser that posts it, so a response made for an outsider's account can be posted into a victim's browser

    The second step-through ends at this: the victim is signed in to the wrong account, and the app has nothing to compare the response with.

  4. Question 4 of 14 What is the entityID in an identity provider's metadata used for?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: It is the name the provider signs under, which every Issuer is compared with

    The program refused metadata whose entityID was not the one pinned for the connection.

  5. Question 5 of 14 The only key in a file of metadata is marked use="encryption". What does the program do with the file?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: It refuses it for having no signing key

    A KeyDescriptor with use set to signing, or with no use at all, can verify. One set to encryption is skipped.

  6. Question 6 of 14 A file lists a signing key and its replacement, and the program kept both. Why?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: During a rollover the provider may sign with either, so the app trusts both until the old one leaves the metadata

    The two fingerprints in the program's second record are the two keys. When the old key is dropped from the metadata, the record shrinks to one.

  7. Question 7 of 14 On how many of the 14 responses did the first draft differ from the right answer?

    Type a number.

    Show the answer: Question 7 of 14

    Answer: 10 responses

    It accepted 9 that it should have refused and refused 1 that it should have accepted, the response that expired 179 seconds ago. The fixed consumer differed on none.

  8. Question 8 of 14 What does the Audience check compare?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: This app's entityID with the audiences named in the assertion's conditions

    Each of the eight checks compares one value in the response with one the app knows. The audience is the app's own name.

  9. Question 9 of 14 The memory keeps each assertion ID for 420 seconds. How many IDs does it hold at 50 sign-ins a second?

    Type a number.

    Show the answer: Question 9 of 14

    Answer: 21000 IDs

    50 sign-ins a second for 420 seconds is 21,000 IDs, a small table. The 420 comes from the 240 seconds of validity plus the 180 seconds of skew.

  10. Question 10 of 14 In the program with 14 responses, the same response posted again was refused with "InResponseTo is not a pending request". Why not by the memory of assertion IDs?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: The first copy used up the request, and that check comes before the memory is consulted

    The order of the checks decides which one speaks. The memory is the last check, and for a response that answers a request, it is the second line.

  11. Question 11 of 14 What is the most bytes that the SAML bindings allow in RelayState?

    Type a number.

    Show the answer: Question 11 of 14

    Answer: 80 bytes

    Both the Redirect and the POST binding say that RelayState must not exceed 80 bytes. The program's handle takes 24.

  12. Question 12 of 14 Two valid responses are recombined by swapping their RelayState values. What in the fixed design stops the swapped value from sending a person to the other sign-in's path?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: The handle is kept with the request ID it was made for, and a handle from another request sends the person to the home page

    The program's swapped case: the weak design followed the other sign-in's path, and the fixed design went to the home page.

  13. Question 13 of 14 What does the app do with the session ID when a sign-in succeeds?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: It issues a new session ID and ends any earlier one

    ASVS 7.2.4 asks for a new session token on authentication, and the lesson on sessions explains why.

  14. Question 14 of 14 Why does the program inflate a message under a cap of 65,536 bytes?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: A payload of under 20,000 bytes expanded to 10,000,000 bytes without a cap

    The same payload was refused with the cap, with the message that it was more than 65,536 bytes once inflated, and a real logout request took 400 bytes.

Interview questions

Three warm-ups come first. Say your own answer before you read each one.

  • What is the difference between a sign-in that the app starts and one that the identity provider starts, and why do many teams leave the second off? In a sign-in that the app starts, it saves a new AuthnRequest ID, redirects the browser to the identity provider and expects a response whose InResponseTo equals that ID. In one that the identity provider starts, it posts an unsolicited response, for instance from a tile in its portal, so there is no request to match and InResponseTo is absent. Without the match nothing ties the response to the browser that posts it, which allows login CSRF, and protection against replay rests on the memory of assertion IDs alone. OWASP calls the flow less secure by design and says to enable it only when required. The usual compromise leaves unsolicited responses off and answers a portal tile with a link to the app’s own start address, so that the app makes the request.
  • What does RelayState carry in a SAML sign-in, and why should it never hold the address to return to? RelayState is a value that travels with a SAML message and comes back unchanged with the response, so the app can resume what the person was doing. It is not part of the signed assertion, the bindings limit it to 80 bytes, and nothing ties it to the message it arrived with. That makes it input from outside, like a query parameter: an address placed in it can be replaced on the way and then followed by a redirect. The safe design sends a random handle and keeps the wanted path on the server, as one of its known routes. The app accepts the handle once, for the request it was made for, and sends the person to the home page when anything does not match.
  • Which binding carries the request and which the response in a normal SAML browser sign-in, and why are they not the same? The request goes by the HTTP-Redirect binding and the response by the HTTP-POST binding. The Redirect binding puts the message in the URL, deflated, base64 encoded and URL encoded, which suits a short AuthnRequest. A response holds a signed assertion and is far too long for a URL, so the identity provider answers with a page that has a form whose hidden field holds the base64 message, and the browser posts it to the assertion consumer service. In both bindings the browser carries every message and the two servers never talk. The bindings therefore say nothing about who the browser is, which is why the app checks the response itself.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Your SAML service provider verifies the signature of each response and then signs the person in. A colleague signs in to your app with an assertion that the identity provider issued for the company's wiki. Which checks are missing, and how would you test that they now hold?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    You run the SAML sign-in of a product on twelve instances behind a load balancer. Design the memory of used assertion IDs: where it lives, how long each ID stays, what it costs at 50 sign-ins a second, and what happens when the store is down.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How does a SAML 2.0 browser sign-in work, and which bindings carry its messages?

    The service provider (your app) redirects the browser to the identity provider with a request, and the IdP answers with a signed assertion that the browser carries back; the two servers never talk during the sign-in. The request goes by the HTTP-Redirect binding (in the URL, deflated and base64 encoded) and the response by HTTP-POST (a hidden form field posted to the assertion consumer service). Neither binding says who the browser is.

  2. Why are unsolicited, IdP-initiated SAML responses off by default?

    They answer no request, so InResponseTo is absent and nothing ties the response to the browser that posts it: an outsider can post a response made for the outsider's own account into a victim's browser (login CSRF), and protection against replay rests on assertion IDs alone. OWASP's SAML cheat sheet calls the flow less secure by design. Answer a portal tile with a link to the app's own start address.

  3. What does SAML metadata pin for one connection, and what should the app refuse?

    The entityID (the name the IdP signs under, compared with every Issuer), the SingleSignOnService (where to send the request, by which binding), the KeyDescriptor use="signing" (the certificate that verifies its signatures) and validUntil and cacheDuration. Refuse another entityID, no or too distant validUntil, http endpoints, a file with only an encryption key, and a DOCTYPE. Keep both keys during a rollover, and never verify with a certificate that arrived in the message.

  4. Which eight checks does a verified SAML response still need?

    The Issuer is this connection's IdP; Destination is this ACS; Recipient is this ACS; the confirmation's NotOnOrAfter has not passed, within the skew; InResponseTo is a request this app sent, used once; NotBefore and NotOnOrAfter hold and every condition is understood; the audience includes this app's entityID; and the assertion ID is new. The signature proves who sent the response, the checks prove it is meant for this app, moment and sign-in.

  5. How much clock skew should the time checks allow?

    A few minutes: the SAML2Int profile asks deployments to allow three to five. The lesson allows three, 180 seconds, and accepted a response that expired 179 seconds ago while refusing one that expired 180. A longer allowance widens the window in which a stolen response works.

  6. How long must the replay memory keep an assertion ID, and what must the write be?

    For as long as the assertion could be accepted: its NotOnOrAfter plus the skew, which is 240 plus 180, or 420 seconds in the lesson, about 21,000 IDs at 50 sign-ins a second. With several instances it lives in a shared store, and the write must be one step that fails if the ID exists, because a read followed by a write lets two instances both pass. For unsolicited responses it is the only check left.

  7. Why should RelayState never hold the address to return to?

    It travels with the request and comes back unchanged, but it is not part of the signed assertion, the bindings limit it to 80 bytes, and nothing protects its pairing with a message. It is input from outside that can steer a redirect. Send a random handle and keep the wanted path on the server as one of your known routes, accept the handle once, for its request, within ten minutes, and send anything else to the home page, because a bad RelayState must not fail a verified sign-in.

  8. What belongs to the app after a SAML response passes every check?

    A new session ID at sign-in (ASVS 7.2.4); a lifetime that ends at the earlier of SessionNotOnOrAfter and the app's own maximum; and a logout that deletes the server-side session first, because single logout depends on a browser staying open and a message arriving. For a logout request in a URL, verify the signature over the raw octets as received, then inflate under a size cap.

Key takeaways

  • A SAML sign-in crosses the browser twice, as a redirect out and a form posted back, and the app should start it with its own saved request; unsolicited responses are off by default, because nothing ties them to the browser that posts them.
  • Metadata is the trust record of one connection: nine versions gave two records and seven refusals, and only a signing key from the pinned record verifies a response.
  • A verified signature leaves eight checks, from the issuer to a new assertion ID, and the edge of the time checks is the skew: a response that expired 179 seconds ago was accepted and one that expired 180 was refused.
  • Remember assertion IDs for the NotOnOrAfter plus the skew, 420 seconds in the program and 21,000 IDs at 50 sign-ins a second, in a store that all instances share.
  • RelayState is input from outside: a 24-byte handle for a route kept on the server stopped the 4 of 10 sign-ins that the path-in-RelayState design sent off the site.
  • Start a new session at sign-in, end it at the earlier of SessionNotOnOrAfter and the app’s maximum, make local logout work without single logout, and verify a message in a URL over its raw octets before inflating it under a cap.

Terms in this lesson

assertion consumer service
The address on the service provider that receives the SAML response from the browser and starts a session. It is listed in the provider's metadata and called the ACS.
bearer assertion
An assertion that any holder can present, with only its signature and validity window to tie it to a person's browser. The SAML browser sign-in uses bearer assertions.
clock skew
The difference between two machines' clocks. A checker that compares timestamps from another system allows a few minutes of skew either way, so a sound response is not refused for a clock that runs fast.
identity provider
The system that signs a person in and tells other applications who they are, in SAML by sending a signed assertion. A company's directory sign-in is the usual example. Also called an IdP.
RelayState
A value that travels with a SAML message and comes back unchanged, so the app can resume what the person was doing. It is not signed with the assertion, so it counts as input from outside.
replay memory
The set of assertion IDs a service has accepted, kept until those assertions could no longer be accepted, so that a captured response cannot be used a second time.
SAML assertion
A signed statement from an identity provider about a person: who they are, when and how they signed in, and for whom and until when the statement is good. The app reads it from the SAML response.
SAML binding
The way a SAML message travels over HTTP. The Redirect binding puts it in a URL, deflated and encoded. The POST binding puts it in a form field that the browser submits.
SAML metadata
An XML document in which an identity provider or service provider describes itself: its entity ID, endpoints, bindings and signing keys. The other side keeps it as the trust record for the connection.
service provider
The application that relies on an identity provider to sign people in, such as a product used by many companies. SAML calls it the service provider, or SP.
unsolicited response
A SAML response sent without a request from the service provider, as when a sign-in starts from the identity provider's portal. It has no InResponseTo to match against a saved request.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.