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.
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
InResponseToto validate — which weakens replay protection. Keep unsolicited responses disabled (strictmode 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
NotOnOrAfterfield 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: falseleft 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
InResponseTofield. 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
wantAssertionsSignedis still enabled after any refactor. - Redirect loop between
/saml/loginand/saml/acs: the session cookie set during login never survives the cross-site POST back from the IdP. The cookie needsSameSite=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
/dashboardinstead of the page they asked for:RelayStateis 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
SessionIndexin the LogoutRequest doesn’t match the one stored at login, the IdP may ignore it. Storeauth.get_session_index()at ACS time (the examples above do this) and keep local sessions short. NotBefore/NotOnOrAftererrors 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, andNotOnOrAfterbefore you trust a single attribute inside it. - The SP metadata file is a contract — once the IdP registers it, renaming the
entityIdor 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
InResponseToto check, so they weaken replay protection — only enable them when a customer requires it.
Further Reading
- OASIS SAML V2.0 Technical Overview — the committee page for the SAML specifications.
- OWASP SAML Security Cheat Sheet — XSW attacks, validation rules, and hardening checklist.
- python3-saml documentation, passport-saml, and Spring Security SAML — the library docs behind the three examples.
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.
Related Resources
Implement RBAC
How to implement role-based access control with hierarchical roles, permission grants, and middleware enforcement across Python, Node.js, and Java.
RecipeImplement ABAC
How to implement attribute-based access control with policy engines, live context evaluation, and fine-grained authorization decisions across Python, Node.js, and Java.
GuideSecrets Management: Vault, Cloud Managers
A practical guide to secrets management: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager with rotation, access control, and CI/CD integration.
RecipeHash Passwords with Argon2
How to hash and verify passwords securely with Argon2id, the winner of the Password Hashing Competition, with correct parameter tuning and migration strategies from bcrypt.
RecipeImplement Secure Session Management
How to create, validate, and expire user sessions securely using HTTP-only cookies, server-side stores like Redis, and CSRF protection.