StackPractices
advanced By Mathias Paulenko

Implement SSO with SAML 2.0 (Python, Node.js, Java)

Implement SAML 2.0 single sign-on as a Service Provider: XML signature verification, IdP metadata, and secure sessions in Python, Node.js, and Java.

Overview

SAML 2.0 is the dominant enterprise single sign-on protocol. It allows an organization to authenticate users in a central Identity Provider (IdP) — Okta, Azure AD, Keycloak, or ADFS — and then assert their identity to downstream Service Providers (SPs) via signed XML documents. Implementing SAML correctly requires handling XML parsing securely, validating signatures, managing metadata exchange, and preventing replay attacks.

When to Use

  • Your application serves enterprise customers who already have a centralized IdP
  • You need federated identity across organizations (multi-tenant SaaS)
  • The organization requires protocol-level interoperability with existing SSO infrastructure
  • You need to support Just-In-Time provisioning (creating accounts from SAML assertions)
  • Compliance requirements mandate a standard protocol rather than OAuth 2.0 / OIDC

For a broader comparison of authentication approaches, see the Complete Guide to Authentication Patterns.

When NOT to Use

  • You’re building a consumer-facing application — use OAuth 2.0 + OIDC instead
  • The IdP supports OIDC (most modern IdPs do) — OIDC is simpler, JSON-based, and more widely supported by developer libraries
  • You need mobile or SPA authentication — SAML is web-browser-centric and awkward for native apps
  • The overhead of XML security (signature verification, schema validation) exceeds your team’s expertise

How the SAML SSO Flow Works

SAML authentication is a browser-mediated handshake between three parties: the user agent, the Service Provider (SP), and the Identity Provider (IdP). The SP never sees the user’s password — it receives a signed XML assertion from the IdP that vouches for the user’s identity.

sequenceDiagram diagram: actor User

Three fields carry most of the security weight in the Response: the signature proves the assertion came from the IdP, InResponseTo binds it to the AuthnRequest you issued (blocking replay of a captured assertion), and RelayState carries the URL the user originally wanted.

Step-by-Step Implementation

Python (python3-saml / onelogin)

from onelogin.saml2.auth import OneLogin_Saml2_Auth
from onelogin.saml2.settings import OneLogin_Saml2_Settings
from flask import Flask, request, session, redirect, url_for
import os

# SAML settings (store certificates securely, not in code)
SAML_CONFIG = {
    "sp": {
        "entityId": "https://app.example.com/saml/metadata",
        "assertionConsumerService": {
            "url": "https://app.example.com/saml/acs",
            "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
        },
        "singleLogoutService": {
            "url": "https://app.example.com/saml/sls",
            "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
        },
        "x509cert": os.environ["SP_CERT"],
        "privateKey": os.environ["SP_PRIVATE_KEY"]
    },
    "idp": {
        "entityId": os.environ["IDP_ENTITY_ID"],
        "singleSignOnService": {
            "url": os.environ["IDP_SSO_URL"],
            "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
        },
        "x509cert": os.environ["IDP_CERT"]
    },
    "security": {
        "nameIdEncrypted": False,
        "authnRequestsSigned": True,
        "logoutRequestSigned": True,
        "wantAssertionsSigned": True,
        "wantMessagesSigned": True,
        "wantNameId": True,
        "requestedAuthnContext": True,
        "signMetadata": True
    }
}

app = Flask(__name__)
app.secret_key = os.environ["SECRET_KEY"]

def init_saml_auth(req):
    return OneLogin_Saml2_Auth(req, SAML_CONFIG)

@app.route("/saml/login")
def saml_login():
    req = {
        "https": "on" if request.is_secure else "off",
        "http_host": request.host,
        "script_name": request.path,
        "server_port": request.environ.get("SERVER_PORT"),
        "get_data": request.args.copy(),
        "post_data": request.form.copy()
    }
    auth = init_saml_auth(req)
    return redirect(auth.login())

@app.route("/saml/acs", methods=["POST"])
def saml_acs():
    req = {
        "https": "on" if request.is_secure else "off",
        "http_host": request.host,
        "script_name": request.path,
        "server_port": request.environ.get("SERVER_PORT"),
        "get_data": request.args.copy(),
        "post_data": request.form.copy()
    }
    auth = init_saml_auth(req)
    auth.process_response()

    errors = auth.get_errors()
    if errors:
        app.logger.error(f"SAML errors: {errors}")
        return "Authentication failed", 401

    if not auth.is_authenticated():
        return "Not authenticated", 401

    # Extract attributes and create session
    session["saml_user"] = {
        "email": auth.get_nameid(),
        "attributes": auth.get_attributes(),
        "session_index": auth.get_session_index()
    }

    # Optional: JIT provisioning
    user = find_or_create_user(
        email=auth.get_nameid(),
        name=auth.get_attributes().get("firstName", [""])[0],
        groups=auth.get_attributes().get("groups", [])
    )
    session["user_id"] = user.id

    return redirect("/dashboard")

@app.route("/saml/sls")
def saml_sls():
    req = {
        "https": "on" if request.is_secure else "off",
        "http_host": request.host,
        "script_name": request.path,
        "server_port": request.environ.get("SERVER_PORT"),
        "get_data": request.args.copy(),
        "post_data": request.form.copy()
    }
    auth = init_saml_auth(req)
    url = auth.process_slo(delete_session_cb=lambda: session.clear())
    return redirect(url or "/")

Node.js (passport-saml)

import passport from 'passport';
import { Strategy as SamlStrategy } from 'passport-saml';
import fs from 'fs';

const samlConfig = {
  entryPoint: process.env.IDP_SSO_URL,
  issuer: 'https://app.example.com/saml/metadata',
  callbackUrl: 'https://app.example.com/saml/acs',
  logoutUrl: 'https://app.example.com/saml/sls',
  cert: fs.readFileSync(process.env.IDP_CERT_PATH, 'utf-8'),
  privateKey: fs.readFileSync(process.env.SP_PRIVATE_KEY_PATH, 'utf-8'),
  decryptionPvk: fs.readFileSync(process.env.SP_PRIVATE_KEY_PATH, 'utf-8'),
  signatureAlgorithm: 'sha256',
  digestAlgorithm: 'sha256',
  validateInResponseTo: true,
  disableRequestedAuthnContext: false,
  acceptedClockSkewMs: 300000  // 5 minutes
};

passport.use(new SamlStrategy(samlConfig, (profile, done) => {
  // JIT provisioning
  const user = {
    email: profile.nameID,
    name: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || profile.nameID,
    groups: profile['http://schemas.xmlsoap.org/claims/Group'] || [],
    sessionIndex: profile.sessionIndex
  };
  return done(null, user);
}));

passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));

// Express routes
app.get('/saml/login', passport.authenticate('saml'));
app.post('/saml/acs',
  passport.authenticate('saml', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/dashboard')
);
app.get('/saml/logout', (req, res) => {
  samlStrategy.logout(req, (err, url) => {
    if (err) return res.status(500).send('Logout failed');
    req.logout(() => res.redirect(url));
  });
});

Java (Spring Security SAML)

@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {

    @Bean
    public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
        RelyingPartyRegistration registration = RelyingPartyRegistrations
            .fromMetadataLocation("https://idp.example.com/metadata.xml")
            .registrationId("okta")
            .entityId("https://app.example.com/saml/metadata")
            .assertionConsumerServiceBinding(Saml2MessageBinding.POST)
            .signingX509Credentials(c -> c.add(
                Saml2X509Credential.signing(loadPrivateKey(), loadCertificate())
            ))
            .decryptionX509Credentials(c -> c.add(
                Saml2X509Credential.decryption(loadPrivateKey(), loadCertificate())
            ))
            .build();

        return new InMemoryRelyingPartyRegistrationRepository(registration);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .saml2Login(saml2 -> saml2
                .loginPage("/saml/login")
                .defaultSuccessUrl("/dashboard", true)
            )
            .saml2Logout(withDefaults());

        return http.build();
    }

    // Custom user mapping from SAML assertion
    @Bean
    public GrantedAuthoritiesMapper authoritiesMapper() {
        return authorities -> {
            Set<GrantedAuthority> mapped = new HashSet<>();
            for (GrantedAuthority auth : authorities) {
                if (auth.getAuthority().startsWith("GROUP_")) {
                    mapped.add(new SimpleGrantedAuthority(
                        "ROLE_" + auth.getAuthority().substring(6)
                    ));
                }
            }
            return mapped;
        };
    }
}

Registering the Service Provider

Before the first login works, the IdP needs your SP metadata: the entityId, the ACS URL, the SLS URL, and your signing certificate. Most SAML libraries generate this document for you. With python3-saml, expose it as an endpoint and hand the URL to the IdP admin:

@app.route("/saml/metadata")
def saml_metadata():
    settings = OneLogin_Saml2_Settings(SAML_CONFIG, sp_validation_only=True)
    metadata = settings.get_sp_metadata()
    errors = settings.validate_metadata(metadata)
    if errors:
        return f"Invalid SP metadata: {errors}", 500
    return metadata, 200, {"Content-Type": "application/samlmetadata+xml"}

Treat this file as a contract: once the IdP registers it, changing the entityId or ACS URL breaks the integration. The same applies in reverse — when the IdP rotates its signing certificate, re-import its metadata instead of pinning the old cert.

You don’t need a customer’s Okta tenant to develop against. Spin up Keycloak in Docker (docker run -p 8080:8080 quay.io/keycloak/keycloak:26.0 start-dev, then create a SAML client in a realm) or a free Okta developer org, and you’ve got a real IdP to test login, attribute mapping, and SLO end to end.

Two details worth deciding early:

  • RelayState for deep links. Whatever opaque value you send with the AuthnRequest comes back untouched in the POST to your ACS. Use it to return users to the page they originally requested instead of dumping everyone on /dashboard.
  • IdP-initiated SSO. An IdP can send an unsolicited Response with no InResponseTo to validate — which weakens replay protection. Keep unsolicited responses disabled (strict mode in python3-saml, or rejecting them in your ACS) unless a customer specifically requires an IdP-side “launch” button.

When you map incoming IdP groups to application roles during JIT provisioning, the RBAC recipe shows a clean role model to attach to. And since the SP’s private key signs every AuthnRequest, store it the way the secrets management guide recommends — a secret manager or HSM, never the repo.

A runnable version of the Flask SP — with the metadata endpoint, RelayState handling, and a docker-compose Keycloak IdP already wired up — lives in the companion repo.

What Works

  • Validate the SAML response signature before parsing any assertions. XML Signature Wrapping (XSW) attacks inject malicious assertions that bypass signature checks if the code looks in the wrong XML node.
  • Use HTTPS everywhere. SAML assertions contain authentication tokens; transmitting them over HTTP allows interception and replay.
  • Store private keys in a hardware security module (HSM) or secret manager. Never commit SP private keys to git or expose them in environment variables on developer machines.
  • Set a short assertion lifetime (5-10 minutes). Assertions are bearer tokens; a stolen assertion can be replayed until it expires. The NotOnOrAfter field must be strictly validated.
  • Implement single logout (SLO). Without SLO, logging out of the SP doesn’t terminate the IdP session, allowing the user to re-authenticate silently via another SP.

Common Mistakes

  • Disabling signature validation in development and forgetting to re-enable. The most common production SAML vulnerability is wantAssertionsSigned: false left over from local testing.
  • Using string-based XML parsing instead of a secure XML library. Standard DOM parsers are vulnerable to XXE attacks. Use SAML-specific libraries that disable DTD processing.
  • Trusting the NameID as the only user identifier. If an attacker can change their NameID at the IdP, they can impersonate another user. Map to a stable, internal user ID.
  • Ignoring clock skew. IdP and SP clocks that differ by more than the assertion lifetime cause legitimate logins to fail. Allow a small skew (1-5 minutes) but log warnings.
  • Not validating the InResponseTo field. Without this check, an attacker can capture a legitimate assertion and replay it against a different authentication request.

Troubleshooting

  • Signature validation suddenly fails for every user: the IdP probably rotated its signing certificate. Re-import the IdP metadata (or fetch it on a schedule) instead of pinning the old cert, and confirm wantAssertionsSigned is still enabled after any refactor.
  • Redirect loop between /saml/login and /saml/acs: the session cookie set during login never survives the cross-site POST back from the IdP. The cookie needs SameSite=None; Secure. Also compare the ACS URL the IdP calls with the one registered in its client config — a trailing slash or port mismatch sends the Response to a different endpoint.
  • Users land on /dashboard instead of the page they asked for: RelayState is being dropped. Check that the IdP echoes it back verbatim; some IdPs strip it on IdP-initiated flows, so keep a default target as fallback.
  • SLO logs the user out locally but they re-authenticate silently: front-channel SLO is best-effort — if the SessionIndex in the LogoutRequest doesn’t match the one stored at login, the IdP may ignore it. Store auth.get_session_index() at ACS time (the examples above do this) and keep local sessions short.
  • NotBefore/NotOnOrAfter errors that come and go: clock skew between SP and IdP. Sync both against NTP, allow a small tolerance (the Node example above uses 5 minutes), and alert when the observed delta grows — a drifting SP clock will fail every login eventually.

Key Takeaways

  • A SAML assertion is a bearer token: verify the signature, Audience, InResponseTo, and NotOnOrAfter before you trust a single attribute inside it.
  • The SP metadata file is a contract — once the IdP registers it, renaming the entityId or moving the ACS URL breaks the integration.
  • Debug against a real IdP you control (a Keycloak container or a free Okta dev org). Chasing XML signature bugs through a customer’s production IdP is painful and slow.
  • Prefer SP-initiated SSO. IdP-initiated responses have no InResponseTo to check, so they weaken replay protection — only enable them when a customer requires it.

Further Reading

Frequently Asked Questions

What is the difference between SAML and OAuth 2.0?

SAML is an XML-based standard for authentication, built for enterprise SSO. OAuth 2.0 is an authorization framework for delegated API access; pair it with OIDC when you actually need login.

What are the roles in SAML?

The Service Provider (SP) is the application the user wants to reach. The Identity Provider (IdP) authenticates the user and issues the signed assertion the SP validates.

How do I secure SAML assertions?

Require signed responses and assertions, validate InResponseTo, Audience and timestamps, transmit everything over HTTPS, and keep the SP private key in a secret manager.

Can I test SAML without access to a real enterprise IdP?

Yes. Run Keycloak in Docker and register your app as a SAML client, or use a free Okta developer org. Both act as real IdPs, so you test the full redirect — POST — session flow.

What is the difference between SP-initiated and IdP-initiated SSO?

SP-initiated starts with a redirect carrying an AuthnRequest, so the response can be bound via InResponseTo. IdP-initiated sends an unsolicited assertion — convenient for IdP dashboards, but it can't be validated against a request.

What is RelayState used for?

It's an opaque value the SP sends with the AuthnRequest and gets back with the Response — the standard way to deep-link users to the page they originally requested after login.