SAML vs OIDC: when to use each and why most apps need both

The question comes up in every enterprise deal: "Do you support SSO?" What the IT director actually means is "do you support SAML?" because that is what their Okta or Azure AD tenant uses. What your frontend engineer means when they set up social login is OIDC. These are different protocols solving related but distinct problems, and building a serious SaaS application usually means implementing both.

The core difference: XML vs JSON, assertions vs tokens

SAML (Security Assertion Markup Language) is a 2005 OASIS standard built around XML. An identity provider issues a signed XML document called an assertion that contains statements about the authenticated user. This assertion is typically base64-encoded and sent via HTTP POST to your application's Assertion Consumer Service (ACS) URL. The assertion is not a bearer token — it is a signed statement that expires quickly and is intended for one-time use.

<!-- Simplified SAML Assertion structure -->
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
  ID="_abc123" IssueInstant="2025-09-22T10:00:00Z" Version="2.0">
  <saml:Issuer>https://idp.company.com</saml:Issuer>
  <ds:Signature>...XML DSig...</ds:Signature>
  <saml:Subject>
    <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
      alice@company.com
    </saml:NameID>
  </saml:Subject>
  <saml:Conditions NotBefore="2025-09-22T09:59:50Z"
                   NotOnOrAfter="2025-09-22T10:05:00Z">
    <saml:AudienceRestriction>
      <saml:Audience>https://yourapp.com</saml:Audience>
    </saml:AudienceRestriction>
  </saml:Conditions>
  <saml:AttributeStatement>
    <saml:Attribute Name="email">
      <saml:AttributeValue>alice@company.com</saml:AttributeValue>
    </saml:Attribute>
    <saml:Attribute Name="groups">
      <saml:AttributeValue>engineering</saml:AttributeValue>
    </saml:Attribute>
  </saml:AttributeStatement>
</saml:Assertion>

OpenID Connect (OIDC) is a 2014 identity layer built on top of OAuth 2.0. It is JSON everywhere: the authorization server returns JSON tokens (JWTs), the configuration is discoverable via a JSON endpoint at /.well-known/openid-configuration, and user attributes are returned from the /userinfo endpoint as JSON. OIDC is built for the web and API era; SAML predates mobile apps and JSON APIs entirely.

Why enterprise IT teams default to SAML

Okta and Azure AD have supported SAML since their inception. Every enterprise IT organization has Okta or Azure AD, and their IT teams have years of experience creating SAML application integrations. The workflow is familiar: download the IdP metadata XML, upload the SP metadata XML, test the assertion, done. There is no OAuth flow to understand, no token endpoint to configure, no PKCE to explain.

Additionally, SAML is deeply integrated with enterprise security requirements:

  • Attribute-based access control via assertion attributes (groups, department, job title)
  • Centralized session management — the IdP can kill all active sessions from a single revocation
  • Compliance requirements in healthcare and finance often specifically name SAML as the required SSO mechanism
  • Network policy enforcement at the IdP layer (only allow logins from corporate IP ranges)

Why developer tools default to OIDC

OIDC is better suited to modern application architectures. Signing in with Google, GitHub, or Apple uses OIDC. Mobile apps use OIDC with PKCE. Single-page apps use OIDC. Machine-to-machine API authentication uses OAuth 2.0 client credentials (the foundation of OIDC). JWT-based access tokens in OIDC are stateless and can be validated locally without a network call to the IdP, which is critical for API performance.

The developer experience is also significantly better. The discovery document means your SDK can automatically configure itself from a single URL. Libraries like openid-client, passport-openidconnect, and jose handle all the token validation correctly with a few lines of code. Implementing SAML correctly — particularly XML signature validation and assertion replay prevention — requires more effort and has more failure modes.

SP-initiated vs IdP-initiated flows

Both SAML and OIDC support two initiation patterns. In SP-initiated flow (service provider initiates), the user navigates to your application, which redirects them to the IdP for authentication. This is the default pattern for OIDC and the preferred pattern for SAML.

In IdP-initiated flow, the user clicks a tile in their IdP portal (the Okta dashboard, for example) which directly POSTs a SAML assertion to your ACS URL — without a prior request from your application. IdP-initiated SAML has a significant security consideration: your application never issued a request, so there is no RelayState or InResponseTo to validate against. This makes it vulnerable to injection attacks where a malicious party substitutes a valid assertion from a different session. If you implement IdP-initiated SAML, validate the assertion timestamp strictly and maintain a short-lived nonce store to detect replays.

OIDC does not have an IdP-initiated equivalent in the core spec. Some implementations support a Form Post Response Mode that approximates it, but it is non-standard. For OIDC, SP-initiated with PKCE is always the right choice.

Processing a SAML assertion in Node.js

import { ServiceProvider, IdentityProvider } from 'samlify';
import fs from 'fs';

const sp = ServiceProvider({
  entityID: 'https://yourapp.com',
  assertionConsumerService: [{
    Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
    Location: 'https://yourapp.com/auth/saml/callback'
  }],
  signingCert: fs.readFileSync('./sp-cert.pem'),
  privateKey: fs.readFileSync('./sp-key.pem'),
  wantAssertionsSigned: true,
  wantMessageSigned: true
});

const idp = IdentityProvider({
  entityID: 'https://idp.company.com',
  singleSignOnService: [{
    Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
    Location: 'https://idp.company.com/sso/saml'
  }],
  signingCert: fs.readFileSync('./idp-cert.pem')
});

// Handle SAML POST callback
app.post('/auth/saml/callback', async (req, res) => {
  try {
    const { extract } = await sp.parseLoginResponse(idp, 'post', req);
    const email = extract.attributes.email || extract.nameID;
    const groups = extract.attributes.groups || [];

    const user = await upsertUserFromSAML({ email, groups });
    req.session.userId = user.id;
    res.redirect('/dashboard');
  } catch (err) {
    res.redirect('/login?error=saml_failed');
  }
});

When you need both

A typical B2B SaaS that targets both SMB and enterprise needs:

  • OIDC for social login (Google, GitHub) — SMB and developer customers
  • OIDC for your own email/password flow (OIDC is the protocol your own auth server speaks)
  • SAML for enterprise SSO — any customer with Okta, Azure AD, or OneLogin
  • OIDC enterprise connections for customers who use an IdP that supports OIDC (Ping Identity, some Okta configurations)

The cleanest architecture is to normalize all authentication paths to an internal user identity as early as possible. Whether a user authenticated via SAML assertion, OIDC social login, or email/password, your session layer should receive the same { userId, email, tenantId } tuple and proceed identically from there. Bastionary's connection model handles this normalization — you configure SAML and OIDC connections per tenant, and your application code only ever sees the normalized identity.

Implementation priority

If you are building enterprise features in a prioritized order, the sequence that closes the most deals first is: SAML SSO first (it is the single most common enterprise procurement blocker), then SCIM provisioning (required by security-conscious IT teams), then OIDC enterprise connections for IdPs that prefer it. The OIDC social login (Google, GitHub) is typically already in place from day one.

← Back to blog Try Bastionary free →