SAML 2.0 single sign-on

Generated by Claude (Fable 5.1) by hand from the identity and integration ops spike, Scout learning center on 2026-10-14, reviewed by a second reader (Claude Fable 5.1), Scout learning center on 2026-10-14.

Concept: Single sign-on with SAML and OpenID Connect · also covers Identity and access, Threat modelling and authorization design · Lesson 5 of 10 in Identity and integration ops · version 1

Practice this

SAML 2.0 is older than OAuth, XML all the way down, and still how most enterprise customers' identity providers talk to the products they buy. An implementation engineer has to be able to read its metadata, register a service provider in a customer's IdP, follow an SP-initiated login through the AuthnRequest and the Response, name what the service provider must validate (signatures, audience, time conditions, InResponseTo) and what breaks on a Tuesday (certificate rotation, clock skew). This lesson does that with the vocabulary first, then the two flows, the messages, NameID and attributes, the validation list from OWASP, replay and skew, rotation, and when to recommend SAML over OpenID Connect. The practice is the learning center's SAML test run: Keycloak as the IdP, a 40-line node-saml service provider, a full login, a replayed response rejected and a tampered assertion rejected.

Read more

How it is done in practice

A customer's IT team sends 'our SAML metadata' and asks for yours. The engineer reads theirs for the entity ID, the SSO URL and the signing certificate, sends back the product's entity ID, assertion consumer service URL and the NameID format and attributes it expects, and configures the service provider with the IdP's certificate. They run an SP-initiated login with the browser's SAML tracer open, decode the response, and check the Audience matches the entity ID, the NameID is the attribute agreed (usually email or a stable id), the attributes carry what the app maps (email, groups), and the signatures are where the SP expects them (assertion, response, or both). They write down the certificate's expiry date and who gets the reminder. When the customer later reports 'SSO broken for everyone since this morning', the engineer's first three checks are the IdP certificate, the clock on the SP, and whether someone changed the ACS URL.

Technologies

SAML 2.0XML Digital SignatureHTTP-Redirect and HTTP-POST bindingsSP metadataKeycloaknode-samlpython3-samlSAML-tracersamltool.com

Diagram

sequenceDiagram
  participant B as Browser
  participant SP as Service provider (your app)
  participant IdP as Identity provider (customer)
  B->>SP: GET /login
  SP->>B: 302 IdP SSO URL ?SAMLRequest=deflate+base64(AuthnRequest)&RelayState
  B->>IdP: GET SSO URL (HTTP-Redirect binding)
  IdP->>B: login page, then an auto-submitting form
  B->>SP: POST /saml/acs SAMLResponse=base64(Response), RelayState (HTTP-POST binding)
  SP->>SP: verify signatures, Issuer, Status, Conditions, Audience, Recipient, InResponseTo, replay
  SP->>B: Set-Cookie session; redirect to RelayState
sequenceDiagram
  participant B as Browser
  participant SP as Service provider (your app)
  participant IdP as Identity provider (customer)
  B->>SP: GET /login
  SP->>B: 302 IdP SSO URL ?SAMLRequest=deflate+base64(AuthnRequest)&RelayState
  B->>IdP: GET SSO URL (HTTP-Redirect binding)
  IdP->>B: login page, then an auto-submitting form
  B->>SP: POST /saml/acs SAMLResponse=base64(Response), RelayState (HTTP-POST binding)
  SP->>SP: verify signatures, Issuer, Status, Conditions, Audience, Recipient, InResponseTo, replay
  SP->>B: Set-Cookie session; redirect to RelayState

Code

javascript
// A minimal SAML service provider with @node-saml/node-saml (pure JS, no xmlsec1), trimmed from the proof
const { SAML } = require("@node-saml/node-saml");
const SP = "http://127.0.0.1:18082";
const saml = new SAML({
  callbackUrl: SP + "/saml/acs",                       // our ACS
  entryPoint: "http://127.0.0.1:18080/realms/learn/protocol/saml",   // the IdP's SSO URL
  issuer: SP + "/saml/metadata",                       // our entityID (Keycloak: the client id must equal it)
  idpCert: fs.readFileSync(process.env.IDP_CERT_PEM, "utf8"),          // from the IdP descriptor
  audience: SP + "/saml/metadata",                     // the assertion's <Audience> must be us
  wantAssertionsSigned: true, wantAuthnResponseSigned: true,
  identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
  acceptedClockSkewMs: 5000,
  validateInResponseTo: "always",                      // a response must answer one of our requests, once
});
app.get("/saml/metadata", (req, res) => res.type("application/xml").send(saml.generateServiceProviderMetadata(null, null)));
app.get("/login", async (req, res) => res.redirect(await saml.getAuthorizeUrlAsync("relay-123", undefined, {})));
app.post("/saml/acs", async (req, res) => {
  try { const { profile } = await saml.validatePostResponseAsync(req.body);
        res.json({ ok: true, nameID: profile.nameID, email: profile.email, groups: profile.groups }); }
  catch (e) { res.status(400).json({ ok: false, error: String(e.message || e) }); }   // 'InResponseTo is not valid', 'Invalid document signature'
});
python
# Read an AuthnRequest off the HTTP-Redirect binding (what a SAML tracer does): base64, then inflate (raw deflate)
import base64, zlib, urllib.parse
q = urllib.parse.parse_qs(urllib.parse.urlsplit(redirect_location).query)
authn_request_xml = zlib.decompress(base64.b64decode(q["SAMLRequest"][0]), -15).decode()
# A Response on the HTTP-POST binding is only base64:
response_xml = base64.b64decode(form_fields["SAMLResponse"]).decode()

Subtopics

Vocabulary and metadata

The words on every SAML call

The identity provider (IdP) authenticates the user and issues assertions: the customer's Okta, Entra ID, ADFS, Ping or Keycloak. The service provider (SP) is the application that consumes them: your product. Each side has an entity ID, a URI that names it (a convention is the SP's metadata URL; the practice SP uses that and Keycloak requires the client id to equal it). The SP's assertion consumer service (ACS) is the URL the IdP posts the response to; the IdP's single sign-on URL is where the SP sends the AuthnRequest. A binding is how a message travels: HTTP-Redirect (deflated and base64 in a query string) for requests, HTTP-POST (base64 in a form the browser auto-submits) for responses. A profile is a use of all of it; the Web Browser SSO profile is the one that matters here.

Metadata is the exchange

Each side publishes an XML metadata document: entity ID, endpoints with bindings, the signing (and optionally encryption) certificates, the NameID formats it supports, and for the SP whether it wants assertions signed. Keycloak's IdP descriptor is one GET at the realm's protocol/saml/descriptor; node-saml generated the SP's from its configuration. Exchanging metadata is the integration: an IdP that imports the SP's metadata gets the ACS and entity ID right by construction, and an SP that imports the IdP's gets the certificate. When a customer cannot import metadata, the same values are typed into console fields, which the Okta and Entra lesson walks through.

SP-initiated and IdP-initiated flows

SP-initiated

The user arrives at your app, the app redirects the browser to the IdP's SSO URL with an AuthnRequest (and an optional RelayState to remember where the user was going), the IdP authenticates and returns the browser to your ACS with a Response that answers that request by id, and the app creates a session. This is the flow the practice runs and the one to recommend: the SP knows it asked, can check InResponseTo, and controls the RelayState.

IdP-initiated

The user clicks the app's tile in the IdP's portal, and the IdP posts an unsolicited Response to the ACS with no AuthnRequest. Customers like it because the portal is how their users find apps. It is weaker: there is no request to bind the response to, so an attacker who captures a response can replay it anywhere the SP accepts unsolicited responses, and RelayState becomes an attacker-controllable deep link. OWASP's advice is to prefer SP-initiated, and when IdP-initiated is required, to keep the assertion lifetime short, validate everything else twice as carefully, and never use RelayState as a redirect target without validating it. node-saml's validateInResponseTo setting has an 'ifPresent' mode for exactly this trade-off.

The AuthnRequest and the Response

The request

An AuthnRequest is a small XML element: an ID, an IssueInstant, the Destination (the IdP's SSO URL), the AssertionConsumerServiceURL and ProtocolBinding the SP wants the answer on, the Issuer (the SP's entity ID), and optionally a NameIDPolicy (which format) and a RequestedAuthnContext (how strong a login). Over the HTTP-Redirect binding it is deflated, base64-encoded and URL-encoded into the SAMLRequest parameter; the practice script inflates one from the redirect and read Issuer and RelayState out of it, which is what a tracer does for you. The Entra protocol page lists what Entra reads from each element and what it ignores.

The response

A Response carries a Status (urn:oasis:names:tc:SAML:2.0:status:Success or a failure code with a message), an InResponseTo that names the request ID, the Issuer (the IdP's entity ID), and one Assertion: its own Issuer, a Subject with the NameID and a SubjectConfirmation (bearer, with Recipient = the ACS, NotOnOrAfter and InResponseTo), Conditions (NotBefore, NotOnOrAfter and an AudienceRestriction naming the SP's entity ID), an AuthnStatement (when and how the user authenticated, with a SessionIndex for logout), and an AttributeStatement. It arrives base64-encoded in the SAMLResponse form field of an auto-submitting page; the decoded response from Keycloak in the learning center's run had Status Success, NameID alice@example.test, Audience equal to the SP's entity ID, three attributes (Role, email, groups) and two signatures, one over the response and one over the assertion. The Entra claims reference prints a full sample assertion worth reading line by line.

NameID formats and attribute statements

NameID

The NameID is the subject identifier and its Format says what kind: emailAddress, persistent (an opaque stable id per SP, the best choice for keying users), transient (changes each login, for anonymous access), unspecified (whatever the IdP has) and a few legacy ones. The SP says which it wants in the AuthnRequest's NameIDPolicy and in its metadata; the IdP decides what it can supply, and the console fields for this are 'Name ID format' and 'Application username' in Okta, 'Name identifier format' and 'source attribute' in Entra. Keying a user on an email NameID means a renamed mailbox is a new user; persistent avoids that, and the practice SP asks for emailAddress only because it is the easiest to read.

Attributes

Everything else about the user arrives as Attribute elements in the AttributeStatement, each with a Name, a NameFormat (basic, URI or unspecified) and one or more values. The agreement with the customer is a table: attribute name, source in their directory, format, required or optional. Common rows are email, first and last name, a display name, and groups (multi-valued, often filtered to the groups the app cares about). Keycloak mappers, Okta attribute statements and Entra claim customisation all produce the same statement; what differs is the naming convention of the Name, which is why the SP should read attributes by the exact Name agreed and not guess.

Signatures, encryption and what the SP must validate

XML signatures

A SAML message is trusted because of XML Digital Signatures: the IdP signs the Assertion, the Response, or both, with its private key, and the SP verifies with the certificate from the IdP's metadata. The signature covers a referenced element by its ID with a canonicalisation, which is where the trouble lives: XML Signature Wrapping attacks move a signed assertion elsewhere in the document and put an unsigned one where the SP looks, so the SP has to verify that the element it reads is the element that was signed. Mature libraries (node-saml, python3-saml, the Java and .NET toolkits) do this; ruby-saml's 2024 CVEs were exactly this class and are the lesson's cautionary tale. Encryption (EncryptedAssertion with the SP's public key) is optional and worth turning on when the assertion carries sensitive attributes and the browser hop is a concern; it adds a certificate the SP must publish and rotate.

The OWASP list

What the SP validates, in the order the OWASP cheat sheet gives it: the Response and Assertion signatures against the configured certificate, using a safe XML parser (no external entities); the Issuer equals the expected IdP; the Status is Success; the Conditions' NotBefore and NotOnOrAfter against the clock with small skew; the AudienceRestriction names this SP's entity ID; the SubjectConfirmationData's Recipient is this ACS URL and its NotOnOrAfter holds; InResponseTo matches an outstanding request; the assertion ID has not been seen before (replay); and only then read the NameID and attributes. The practice SP is configured to want both the assertion and the response signed and to validate InResponseTo always, and it rejected a response whose NameID had been edited inside the signed assertion with 'Invalid document signature'.

InResponseTo, replay and clock skew

Replay

A SAML response is a bearer document for a few minutes. The SP's defences are the request binding (InResponseTo must name an AuthnRequest this SP issued and has not yet consumed) and a cache of assertion IDs seen within their validity window. The practice SP, with validateInResponseTo set to always, accepted a genuine response once and rejected the identical POST a second later with 'InResponseTo is not valid'. The library forgets the request ID after any validation attempt, successful or not, which is why the tampered-assertion test in the practice needs a fresh login rather than a second edit of the same response.

Clocks

Conditions and SubjectConfirmation carry absolute times from the IdP's clock, so a service provider whose clock runs a few minutes fast rejects every assertion as 'not yet valid' and one that runs slow accepts stale ones. Libraries expose an accepted skew (node-saml's acceptedClockSkewMs; the practice SP uses five seconds); a production value of a minute or two covers real drift, and a server that needs more has an NTP problem, not a SAML problem. 'Works for some users and not others' with time-shaped errors usually means several SP nodes with different clocks.

Certificate rotation

The Tuesday outage

The IdP's signing certificate expires. Entra's default is three years, Okta's are long-lived too, and the day one expires every assertion fails signature validation at every SP that still holds the old certificate, which reads as 'SSO is down for everyone and nobody changed anything'. The Entra certificate tutorial describes the rotation: create a new certificate (status inactive), give it to each application, make it active, and set notification email addresses for expiry. Okta's Sign On tab has the same under signing certificates. On the SP side the fix is to accept two certificates during the overlap, or to refresh the IdP metadata from its URL on a schedule so the new certificate arrives before it is used.

What to write down

For every SAML integration record the IdP certificate's expiry date, where the metadata URL is (if there is one), who at the customer gets the expiry reminder, and the steps to update the SP's trusted certificate. The practice's last step rotates Keycloak's realm key and shows the SP failing until its certificate is replaced; running that once makes the runbook entry real.

SAML or OpenID Connect: what to recommend

Both, usually; here is the order

A product sold to enterprises supports both, because the customer's IdP and policy decide. Recommend OpenID Connect when the customer's IdP supports it for the app type in question, the app is new, it is a single-page or mobile app, or an API needs tokens as well as a login: OIDC's JSON and JWTs are simpler to validate, the discovery document removes the metadata exchange, and PKCE and the token model fit modern apps. Recommend SAML when the customer's catalogue is SAML-only (many are, especially with older ADFS or on-premises IdPs), when their security team has a SAML review process and no OIDC one, or when IdP-initiated portal tiles are a requirement. Logout and group claims exist in both; attribute naming is the customer's convention in both.

The conversation

The question to ask is not 'SAML or OIDC' but 'which IdP, which app integration type does it offer for products like ours, and who on your side owns it'. The Okta and Entra lesson gives the console paths for both protocols; the answer comes from the customer's catalogue entry, and the engineer's job is to make either one work and to say clearly which validations the product performs.

Sources

Related lessons

Practice this