/.well-known/openid-configuration (OIDC Discovery) or SAML metadata XML at IdP/SP metadata URLs.nonce must be generated per-request and validated in the ID token to prevent replay attacksiss (issuer) and aud (audience) claims in both SAML assertions and OIDC ID tokens — mismatched values indicate token substitution attacksNotBefore / NotOnOrAfter time window (allow 2-3 minutes clock skew maximum)localStorage — use httpOnly, Secure, SameSite=Lax cookies or server-side session stores| 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 |
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
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.
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.
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.
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.
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.
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.
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.
# 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(),
}
// 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 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
}
// 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)));
// 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)));
// 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
});
// 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,
});
// 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
});
// 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 }),
});
# 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
# 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']
maxClockSkew: 180 (3 minutes) and use NTP on all servers. [src1]RelayState in the SAML AuthnRequest and redirect to it after authentication. [src4]state is not validated. Fix: generate a cryptographically random state per request, store in session, validate in callback. [src2]automaticSilentRenew for OIDC. [src5]amr in OIDC ID tokens or AuthnContext in SAML assertions. [src3]# 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
| 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 | 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 |
| 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 |
client_secret should never be embedded in SPAs or mobile apps — use PKCE with a public client instead.