SSO with SAML 2.0 and OpenID Connect
How do I implement SSO with SAML and OpenID Connect?
TL;DR
- Bottom line: Use OpenID Connect for new greenfield apps and consumer-facing SSO; use SAML 2.0 for enterprise integrations with legacy IdPs that only support SAML.
- Key tool/command:
/.well-known/openid-configuration(OIDC Discovery) or SAML metadata XML at IdP/SP metadata URLs. - Watch out for: Not validating XML signatures on SAML assertions server-side — the #1 vulnerability in SAML implementations.
- Works with: Any language/framework. SAML 2.0 (2005+, XML-based), OIDC 1.0 (2014+, JSON/JWT-based). All major IdPs (Okta, Azure AD, Google, Keycloak, AWS IAM Identity Center).
Constraints
- Always validate SAML assertion XML signatures server-side using the IdP's public certificate — never accept unsigned assertions or validate only on the client
- OIDC
noncemust be generated per-request and validated in the ID token to prevent replay attacks - Validate
iss(issuer) andaud(audience) claims in both SAML assertions and OIDC ID tokens — mismatched values indicate token substitution attacks - SAML assertions must be consumed within the
NotBefore/NotOnOrAftertime window (allow 2-3 minutes clock skew maximum) - Never store tokens or assertions in
localStorage— usehttpOnly,Secure,SameSite=Laxcookies or server-side session stores - Enable XML External Entity (XXE) protection and defenses against XML Signature Wrapping (XSW) attacks in all SAML XML parsers
Quick Reference
| Feature | SAML 2.0 | OpenID Connect | When to Choose |
|---|---|---|---|
| Data format | XML assertions | JSON/JWT tokens | OIDC for simpler parsing |
| Transport | HTTP Redirect, POST, Artifact | HTTPS + JSON (OAuth 2.0 flows) | OIDC for REST APIs |
| Discovery | Metadata XML document | /.well-known/openid-configuration | OIDC for auto-config |
| Token type | XML assertion (signed, optionally encrypted) | ID token (JWT, signed) + access token | OIDC for stateless validation |
| Identity info | Attribute statements in assertion | userinfo endpoint + ID token claims | Equivalent — both carry user attributes |
| Session logout | SLO (Single Logout) protocol | end_session_endpoint (RP-Initiated Logout) | SAML SLO is more complex but more reliable |
| SP/RP setup | Exchange metadata XML, configure certificates | Register client_id + redirect_uri, use Discovery | OIDC is faster to integrate |
| Security model | XML Signature + optional XML Encryption | JWT signature (RS256/ES256) + TLS | Both strong; OIDC has smaller attack surface |
| Enterprise adoption | Dominant (90%+ enterprise IdPs support it) | Growing rapidly, most new IdPs default to it | SAML if IdP only speaks SAML |
| Mobile / SPA | Poor (XML parsing in browser is heavy) | Excellent (native JSON, PKCE flow) | Always OIDC for mobile/SPA |
| Spec complexity | High (~70 pages core + bindings + profiles) | Moderate (~30 pages, built on OAuth 2.0) | OIDC if team is new to federation |
| Typical IdPs | AD FS, Shibboleth, PingFederate, Okta | Okta, Azure AD, Google, Auth0, Keycloak | Check your IdP's preferred protocol |
| Maturity | 20+ years (2005) | 12+ years (2014) | Both production-ready |
| Key rotation | Manual certificate rotation | JWKS endpoint with automatic key rotation | OIDC for zero-downtime key rotation |
Decision Tree
START
├── Greenfield application (no existing SSO)?
│ ├── YES → Use OpenID Connect (simpler, modern, better tooling)
│ └── NO ↓
├── IdP only supports SAML (e.g., legacy AD FS, Shibboleth)?
│ ├── YES → Implement SAML 2.0 SP (see Step-by-Step: SAML SP)
│ └── NO ↓
├── Mobile app or Single Page Application (SPA)?
│ ├── YES → Use OIDC with Authorization Code + PKCE
│ └── NO ↓
├── Enterprise customer requiring SAML?
│ ├── YES → Implement SAML 2.0 SP alongside OIDC
│ └── NO ↓
├── Need to support both protocols (multi-tenant SaaS)?
│ ├── YES → Use an identity broker (Keycloak, Auth0) that bridges SAML ↔ OIDC
│ └── NO ↓
├── Regulatory compliance requiring specific protocol?
│ ├── YES → Check compliance requirements (many US gov specs prefer SAML)
│ └── NO ↓
└── DEFAULT → Use OpenID Connect
Step-by-Step Guide
SAML 2.0: Implement a Service Provider (SP)
1. Obtain IdP metadata
Request the SAML metadata XML from your Identity Provider. This contains the IdP's entity ID, SSO endpoint URLs, and signing certificate. [src1]
# Download IdP metadata (example: Okta)
curl -o idp-metadata.xml \
"https://your-org.okta.com/app/YOUR_APP_ID/sso/saml/metadata"
# Verify the metadata contains required elements
xmllint --xpath "//*[local-name()='SingleSignOnService']/@Location" idp-metadata.xml
Verify: The metadata XML should contain <SingleSignOnService> with a Location URL and an <X509Certificate> element.
2. Generate SP certificate and metadata
Create your SP's signing/encryption certificate and generate SP metadata XML to share with the IdP. [src4]
# Generate SP certificate (valid 2 years)
openssl req -x509 -newkey rsa:2048 -keyout sp-key.pem -out sp-cert.pem \
-days 730 -nodes -subj "/CN=your-app.example.com"
Verify: openssl x509 -in sp-cert.pem -text -noout shows your CN and validity dates.
3. Configure SAML SP in your application
Install the SAML library and configure it with the IdP metadata and SP credentials. [src6]
// Node.js with @node-saml/passport-saml v5.x
const passport = require('passport');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const fs = require('fs');
passport.use(new SamlStrategy(
{
callbackUrl: 'https://your-app.example.com/auth/saml/callback',
entryPoint: 'https://your-org.okta.com/app/YOUR_APP_ID/sso/saml',
issuer: 'https://your-app.example.com',
cert: fs.readFileSync('./idp-cert.pem', 'utf-8'),
decryptionPvk: fs.readFileSync('./sp-key.pem', 'utf-8'),
signatureAlgorithm: 'sha256',
wantAssertionsSigned: true,
wantAuthnResponseSigned: true,
maxAssertionAgeMs: 300000,
},
(profile, done) => {
return done(null, {
id: profile.nameID,
email: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
name: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'],
});
}
));
Verify: Start the app and navigate to /auth/saml/login — you should be redirected to the IdP login page.
4. Handle the SAML callback and create session
Process the SAML response, validate the assertion, and establish a local session. [src1]
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 3600000 }
}));
app.use(passport.initialize());
app.use(passport.session());
app.get('/auth/saml/login', passport.authenticate('saml'));
app.post('/auth/saml/callback',
passport.authenticate('saml', { failureRedirect: '/login/failed' }),
(req, res) => { res.redirect('/dashboard'); }
);
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user, done) => done(null, user));
Verify: Complete a login flow — check that req.user is populated with IdP attributes after redirect.
OpenID Connect: Implement a Relying Party (RP)
5. Discover OIDC provider configuration
Fetch the OIDC Discovery document to auto-configure endpoints. Register your app to obtain client_id and client_secret. [src2]
# Discover OIDC provider configuration
curl -s "https://your-org.okta.com/.well-known/openid-configuration" | jq '.'
Verify: Response contains issuer, authorization_endpoint, token_endpoint, jwks_uri, and userinfo_endpoint.
6. Implement OIDC Authorization Code flow with PKCE
Use the Authorization Code flow with PKCE for both server-side and SPA applications. [src3] [src5]
// Node.js with openid-client v6.x
const { Issuer, generators } = require('openid-client');
async function setupOidcClient() {
const issuer = await Issuer.discover('https://your-org.okta.com');
const client = new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: ['https://your-app.example.com/auth/oidc/callback'],
response_types: ['code'],
});
return client;
}
async function getAuthUrl(client) {
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const nonce = generators.nonce();
const state = generators.state();
const url = client.authorizationUrl({
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
nonce: nonce,
state: state,
});
return { url, codeVerifier, nonce, state };
}
Verify: Opening the authorization URL redirects to the IdP login page with correct parameters.
7. Handle the OIDC callback and validate tokens
Exchange the authorization code for tokens and validate the ID token claims. [src3]
app.get('/auth/oidc/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(
'https://your-app.example.com/auth/oidc/callback',
params,
{
code_verifier: req.session.codeVerifier,
nonce: req.session.nonce,
state: req.session.state,
}
);
const claims = tokenSet.claims();
const userinfo = await client.userinfo(tokenSet.access_token);
req.session.user = {
id: claims.sub,
email: claims.email || userinfo.email,
name: claims.name || userinfo.name,
};
delete req.session.codeVerifier;
delete req.session.nonce;
delete req.session.state;
res.redirect('/dashboard');
});
Verify: After login, req.session.user contains the authenticated user's sub, email, and name claims.
Code Examples
Python: SAML SP with python3-saml
# pip install python3-saml==1.16.0
# Input: SAML response from IdP (POST to /saml/acs)
# Output: Authenticated user attributes
from onelogin.saml2.auth import OneLogin_Saml2_Auth
def prepare_saml_request(request):
return {
'https': 'on',
'http_host': request.host,
'script_name': request.path,
'get_data': request.args.copy(),
'post_data': request.form.copy(),
}
def saml_callback(request):
auth = OneLogin_Saml2_Auth(prepare_saml_request(request), custom_base_path='./saml/')
auth.process_response()
errors = auth.get_errors()
if errors:
raise ValueError(f"SAML error: {', '.join(errors)}: {auth.get_last_error_reason()}")
if not auth.is_authenticated():
raise ValueError("SAML authentication failed")
return {
'name_id': auth.get_nameid(),
'session_index': auth.get_session_index(),
'attributes': auth.get_attributes(),
}
TypeScript: OIDC RP with oidc-client-ts (Browser SPA)
// npm install oidc-client-ts@3
// Input: OIDC provider configuration
// Output: Authenticated user with ID token claims
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
const userManager = new UserManager({
authority: 'https://your-org.okta.com',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://your-app.example.com/auth/callback',
response_type: 'code',
scope: 'openid profile email',
automaticSilentRenew: true,
userStore: new WebStorageStateStore({ store: sessionStorage }),
});
async function login(): Promise<void> {
await userManager.signinRedirect();
}
async function handleCallback(): Promise<void> {
const user = await userManager.signinRedirectCallback();
console.log('Authenticated:', user.profile.sub, user.profile.email);
}
Go: OIDC Token Validation
// go get github.com/coreos/go-oidc/[email protected]
// Input: Raw ID token string from OIDC callback
// Output: Validated token claims
package main
import (
"context"
"fmt"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
func newOIDCVerifier(ctx context.Context, issuerURL, clientID string) (*oidc.IDTokenVerifier, *oauth2.Config, error) {
provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil {
return nil, nil, fmt.Errorf("failed to discover OIDC provider: %w", err)
}
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
config := &oauth2.Config{
ClientID: clientID,
ClientSecret: "YOUR_CLIENT_SECRET",
Endpoint: provider.Endpoint(),
RedirectURL: "https://your-app.example.com/auth/callback",
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
return verifier, config, nil
}
Anti-Patterns
Wrong: Trusting SAML assertions without signature validation
// BAD — accepting assertion without verifying XML signature
passport.use(new SamlStrategy({
callbackUrl: 'https://app.example.com/callback',
entryPoint: 'https://idp.example.com/sso',
issuer: 'app-entity-id',
// MISSING: cert, wantAssertionsSigned, wantAuthnResponseSigned
}, (profile, done) => done(null, profile)));
Correct: Always validate signatures and require signed assertions
// GOOD — full signature validation configured
passport.use(new SamlStrategy({
callbackUrl: 'https://app.example.com/callback',
entryPoint: 'https://idp.example.com/sso',
issuer: 'app-entity-id',
cert: fs.readFileSync('./idp-cert.pem', 'utf-8'),
wantAssertionsSigned: true,
wantAuthnResponseSigned: true,
signatureAlgorithm: 'sha256',
maxAssertionAgeMs: 300000,
}, (profile, done) => done(null, profile)));
Wrong: Skipping nonce validation in OIDC
// BAD — not generating or validating nonce (allows replay attacks)
const url = client.authorizationUrl({
scope: 'openid profile email',
// MISSING: nonce parameter
});
const tokenSet = await client.callback(redirectUri, params, {
// MISSING: nonce check
});
Correct: Always generate and validate nonce
// GOOD — nonce generated per-request and validated
const nonce = generators.nonce();
req.session.nonce = nonce;
const url = client.authorizationUrl({
scope: 'openid profile email',
nonce: nonce,
state: generators.state(),
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: 'S256',
});
const tokenSet = await client.callback(redirectUri, params, {
nonce: req.session.nonce,
state: req.session.state,
code_verifier: req.session.codeVerifier,
});
Wrong: Storing tokens in localStorage
// BAD — XSS can steal tokens from localStorage
const userManager = new UserManager({
authority: 'https://idp.example.com',
client_id: 'app',
redirect_uri: 'https://app.example.com/callback',
userStore: new WebStorageStateStore({ store: localStorage }), // VULNERABLE
});
Correct: Use sessionStorage or server-side sessions
// GOOD — sessionStorage is tab-scoped and cleared on close
const userManager = new UserManager({
authority: 'https://idp.example.com',
client_id: 'app',
redirect_uri: 'https://app.example.com/callback',
userStore: new WebStorageStateStore({ store: sessionStorage }),
});
Wrong: Not validating audience claim
# BAD — accepting ID token without checking audience
import jwt
token = jwt.decode(id_token, key, algorithms=['RS256'])
user = token['sub'] # Token from another app would be accepted
Correct: Always validate issuer and audience
# GOOD — explicit issuer and audience validation
import jwt
token = jwt.decode(
id_token, key, algorithms=['RS256'],
issuer='https://your-org.okta.com',
audience='YOUR_CLIENT_ID',
options={'require': ['exp', 'iss', 'aud', 'sub', 'nonce']},
)
user = token['sub']
Common Pitfalls
- Clock skew between SP and IdP: SAML assertions rejected because server clocks differ by more than the allowed tolerance. Fix: configure
maxClockSkew: 180(3 minutes) and use NTP on all servers. [src1] - Certificate rotation breaking SSO: IdP rotates its signing certificate but SP still has the old one pinned. Fix: subscribe to IdP metadata updates and support multiple certificates during rotation windows. [src3]
- Missing RelayState forwarding: Users lose their deep-link destination after SSO login. Fix: pass the original URL as
RelayStatein the SAML AuthnRequest and redirect to it after authentication. [src4] - OIDC state parameter mismatch: CSRF attacks succeed because
stateis not validated. Fix: generate a cryptographically randomstateper request, store in session, validate in callback. [src2] - XML Signature Wrapping (XSW) attacks: Attacker manipulates SAML XML to inject a forged assertion. Fix: use well-maintained SAML libraries (python3-saml, passport-saml v5+) that defend against all 8 known XSW variants. [src7]
- Single Logout (SLO) not implemented: Users remain logged into IdP after SP logout. Fix: implement SAML SLO or OIDC RP-Initiated Logout, and expire local sessions independently. [src1]
- Token stored beyond session lifetime: Tokens persist after session expiry. Fix: tie token lifetime to session lifetime and implement
automaticSilentRenewfor OIDC. [src5] - Not checking amr (Authentication Methods References): SP accepts any auth method when MFA is required. Fix: check
amrin OIDC ID tokens orAuthnContextin SAML assertions. [src3]
Diagnostic Commands
# Decode a SAML response (base64-encoded POST body)
echo "$SAML_RESPONSE" | base64 -d | xmllint --format -
# Validate XML signature in a SAML assertion
xmlsec1 --verify --pubkey-cert-pem idp-cert.pem saml-response.xml
# Fetch OIDC Discovery document
curl -s "https://your-org.okta.com/.well-known/openid-configuration" | jq .
# Fetch OIDC provider's signing keys (JWKS)
curl -s "https://your-org.okta.com/oauth2/default/v1/keys" | jq '.keys[] | {kid, kty, alg}'
# Decode a JWT ID token (without verification — for debugging only)
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Check SAML metadata endpoints
curl -s "https://your-org.okta.com/app/YOUR_APP_ID/sso/saml/metadata" | xmllint --format -
# Verify certificate expiry dates
openssl x509 -in idp-cert.pem -noout -dates
openssl x509 -in sp-cert.pem -noout -dates
Version History & Compatibility
| Specification | Version | Status | Key Changes | Date |
|---|---|---|---|---|
| SAML | 2.0 | Current (stable) | Full spec: assertions, bindings, profiles, metadata | 2005-03 |
| SAML | 1.1 | Deprecated | Limited bindings, no SLO | 2003-09 |
| OpenID Connect | 1.0 | Current (stable) | Core, Discovery, Dynamic Registration, Session Management | 2014-02 |
| OIDC Federation | 1.0 | Draft | Trust chains without bilateral metadata exchange | 2024-ongoing |
| OAuth 2.0 | RFC 6749 | Current | Authorization framework — OIDC adds identity layer | 2012-10 |
| OAuth 2.1 | Draft | Draft | Merges PKCE, deprecates implicit flow | 2024-ongoing |
Library Compatibility
| Library | Language | Protocol | Min Runtime | Latest Stable |
|---|---|---|---|---|
@node-saml/passport-saml | Node.js | SAML 2.0 | Node 18+ | 5.x |
python3-saml | Python | SAML 2.0 | Python 3.8+ | 1.16.x |
openid-client | Node.js | OIDC | Node 18+ | 6.x |
oidc-client-ts | TypeScript | OIDC | ES2015+ browsers | 3.x |
go-oidc | Go | OIDC | Go 1.21+ | 3.11.x |
| Spring Security SAML | Java | SAML 2.0 | Java 17+ | 6.x |
| Spring Security OAuth2 | Java | OIDC | Java 17+ | 6.x |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Enterprise customers require SSO with their corporate IdP | Only need API-to-API authentication (no user identity) | OAuth 2.0 Client Credentials flow |
| Building multi-tenant SaaS where each tenant has its own IdP | Simple username/password login for a single-tenant app | Session-based auth with bcrypt |
| Regulatory compliance requires federated identity (SOC 2, HIPAA) | Social login only (Google, GitHub, Facebook) | OAuth 2.0 Authorization Code with PKCE |
| Integrating with legacy enterprise systems (AD FS, Shibboleth) | Internal microservice-to-microservice auth | mTLS or JWT service tokens |
| Need Single Logout across multiple applications | Mobile app with biometric login as primary auth | Platform native auth (Face ID, fingerprint) |
| Customer's IT department mandates SAML specifically | Real-time API authorization decisions | OAuth 2.0 token introspection or policy engines |
Important Caveats
- SAML 2.0 and OIDC solve authentication (who is this user?) — they do not solve authorization (what can this user do?). Authorization requires a separate layer (RBAC, ABAC, policy engines).
- SAML certificate rotation requires coordinated updates between SP and IdP. Plan rotation 30 days before expiry and support dual certificates during the transition window.
- OIDC
client_secretshould never be embedded in SPAs or mobile apps — use PKCE with a public client instead. - Some IdPs have proprietary extensions or non-standard claim names. Test with your specific IdP; don't assume standard-compliant behavior.
- SAML SLO (Single Logout) is notoriously unreliable in practice because it requires all SPs to respond to logout requests synchronously. Consider implementing independent session timeouts as a fallback.
- When bridging SAML and OIDC, use an identity broker (Keycloak, Auth0, Azure AD) rather than building the bridge yourself.
- XML-based SAML libraries are vulnerable to XXE, XSW, and Billion Laughs attacks if the XML parser is not properly hardened. Always use actively maintained and audited libraries.