SSO with SAML 2.0 and OpenID Connect

Type: Software Reference Confidence: 0.90 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

FeatureSAML 2.0OpenID ConnectWhen to Choose
Data formatXML assertionsJSON/JWT tokensOIDC for simpler parsing
TransportHTTP Redirect, POST, ArtifactHTTPS + JSON (OAuth 2.0 flows)OIDC for REST APIs
DiscoveryMetadata XML document/.well-known/openid-configurationOIDC for auto-config
Token typeXML assertion (signed, optionally encrypted)ID token (JWT, signed) + access tokenOIDC for stateless validation
Identity infoAttribute statements in assertionuserinfo endpoint + ID token claimsEquivalent — both carry user attributes
Session logoutSLO (Single Logout) protocolend_session_endpoint (RP-Initiated Logout)SAML SLO is more complex but more reliable
SP/RP setupExchange metadata XML, configure certificatesRegister client_id + redirect_uri, use DiscoveryOIDC is faster to integrate
Security modelXML Signature + optional XML EncryptionJWT signature (RS256/ES256) + TLSBoth strong; OIDC has smaller attack surface
Enterprise adoptionDominant (90%+ enterprise IdPs support it)Growing rapidly, most new IdPs default to itSAML if IdP only speaks SAML
Mobile / SPAPoor (XML parsing in browser is heavy)Excellent (native JSON, PKCE flow)Always OIDC for mobile/SPA
Spec complexityHigh (~70 pages core + bindings + profiles)Moderate (~30 pages, built on OAuth 2.0)OIDC if team is new to federation
Typical IdPsAD FS, Shibboleth, PingFederate, OktaOkta, Azure AD, Google, Auth0, KeycloakCheck your IdP's preferred protocol
Maturity20+ years (2005)12+ years (2014)Both production-ready
Key rotationManual certificate rotationJWKS endpoint with automatic key rotationOIDC 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.signinRe­directCallback();
  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

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

SpecificationVersionStatusKey ChangesDate
SAML2.0Current (stable)Full spec: assertions, bindings, profiles, metadata2005-03
SAML1.1DeprecatedLimited bindings, no SLO2003-09
OpenID Connect1.0Current (stable)Core, Discovery, Dynamic Registration, Session Management2014-02
OIDC Federation1.0DraftTrust chains without bilateral metadata exchange2024-ongoing
OAuth 2.0RFC 6749CurrentAuthorization framework — OIDC adds identity layer2012-10
OAuth 2.1DraftDraftMerges PKCE, deprecates implicit flow2024-ongoing

Library Compatibility

LibraryLanguageProtocolMin RuntimeLatest Stable
@node-saml/passport-samlNode.jsSAML 2.0Node 18+5.x
python3-samlPythonSAML 2.0Python 3.8+1.16.x
openid-clientNode.jsOIDCNode 18+6.x
oidc-client-tsTypeScriptOIDCES2015+ browsers3.x
go-oidcGoOIDCGo 1.21+3.11.x
Spring Security SAMLJavaSAML 2.0Java 17+6.x
Spring Security OAuth2JavaOIDCJava 17+6.x

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Enterprise customers require SSO with their corporate IdPOnly need API-to-API authentication (no user identity)OAuth 2.0 Client Credentials flow
Building multi-tenant SaaS where each tenant has its own IdPSimple username/password login for a single-tenant appSession-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 authmTLS or JWT service tokens
Need Single Logout across multiple applicationsMobile app with biometric login as primary authPlatform native auth (Face ID, fingerprint)
Customer's IT department mandates SAML specificallyReal-time API authorization decisionsOAuth 2.0 token introspection or policy engines

Important Caveats

Related Units