OpenID Connect and SAML
OIDC layers identity on OAuth; SAML is the older enterprise SSO. When to pick each, and why both still ship in 2026.
What it is#
OpenID Connect (OIDC) and SAML are the two protocols that solve federated identity: letting a user log into one application using credentials managed by a different system (Okta, Google Workspace, Azure AD, ADFS, Auth0). Both predate the modern API-design era; both ship in production in 2026; they have very different shapes.
- OpenID Connect is OAuth 2 plus identity. RFC-spec’d by the OpenID Foundation in 2014. The flow is identical to OAuth 2’s Authorization Code grant — the additional contract is an
id_token(a JWT) returned alongside the access token, plus a UserInfo endpoint for fetching claims about the authenticated user. JSON on the wire. Modern web stack. - SAML 2.0 is the older enterprise standard, ratified by OASIS in 2005. XML-based; SOAP-adjacent; rich in profiles and bindings. The flow is built around assertions — signed XML documents that say “this user is alice@acme.com and these are her group memberships, signed by the Identity Provider.” Browser-based POST or redirect bindings carry assertions between the Identity Provider and the Service Provider.
Both protocols share the same actors: an Identity Provider (IdP) that authenticates users, a Service Provider (SP) (or Relying Party in OIDC terminology) that wants to know who the user is, and the user in the middle. Both rely on signed tokens to prove the IdP vouched for the identity claim. The difference is everything below that level — wire format, ecosystem, tooling, complexity.
When to use it#
- Reach for OIDC for modern consumer logins (“Sign in with Google / Apple / Microsoft”), B2B SaaS that needs SSO with a small number of enterprise customers, and any new identity integration in 2026. JSON, JWTs, well-tooled in every language.
- Reach for SAML when an enterprise customer mandates it — government agencies, large financial institutions, and any organisation running Okta / OneLogin / Ping / ADFS will often require SAML for procurement reasons. New SaaS products typically ship SAML support somewhere between their first 10 and first 50 enterprise customers.
- Ship both if you sell to enterprises. Okta and Auth0 are full businesses built around abstracting away the OIDC-vs-SAML choice for SaaS vendors — you integrate with them once, they speak SAML to the enterprise IdP and OIDC to your app.
The decision rule for greenfield is OIDC unless a partner mandates SAML. The decision rule for existing enterprise software is whatever the customer’s IdP speaks — fighting the IdP is a losing battle.
How it works#
The two protocols solve the same problem in very different shapes.
OIDC: Authorization Code flow + id_token#
OIDC reuses OAuth 2’s Authorization Code grant verbatim. The Relying Party redirects the user to the IdP’s /authorize endpoint with scope=openid (plus optionally email, profile) — the openid scope is what tells the IdP “this is an identity request, return an id_token”. After the user authenticates, the IdP issues an authorisation code; the RP exchanges it at /token and gets back:
{ "access_token": "eyJ...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 3600, "scope": "openid email profile"}The id_token is a JWT — three base64url-encoded parts (header.payload.signature) separated by dots. The payload is the identity claim:
{ "iss": "https://accounts.example.com", "sub": "248289761001", "aud": "client_abc", "exp": 1717003600, "iat": 1717000000, "nonce": "n-0S6_WzA2Mj", "email": "alice@example.com", "email_verified": true, "name": "Alice Example"}The RP must verify the id_token before trusting it:
- Fetch the IdP’s signing keys from its
/.well-known/openid-configurationdocument (the JWKS URI). - Verify the JWT signature using the public key matching the
kidin the JWT header. - Check
issmatches the expected IdP,audincludes the RP’s client_id,expis in the future,iatis not too far in the past, andnoncematches the value the RP sent in the initial request.
A library handles all of this in five or six lines of real code, but skipping any single check breaks the security model — id_token verification gaps are a recurring CVE source.
SAML: SP-initiated, POST binding#
The most common SAML flow is SP-initiated with HTTP-POST binding:
User-Agent Service Identity(browser) Provider Provider │ │ │ │ 1. visit app │ │ │─────────────────►│ │ │ 2. 302 redirect with │ │ SAMLRequest (deflated, base64) │ │◄─────────────────│ │ │─────────────────────────────────────►│ │ 3. user authenticates at IdP │ │ 4. IdP returns HTML form with hidden │ │ SAMLResponse field, auto-submit │ │◄─────────────────────────────────────│ │ 5. POST SAMLResponse to SP's ACS URL │ │─────────────────►│ │ │ 6. SP validates assertion, sets │ │ session cookie, redirects to app │ │◄─────────────────│ │The SAML assertion inside the SAMLResponse is an XML document containing:
<saml:Assertion ID="..." IssueInstant="2026-05-30T08:14:23Z"> <saml:Issuer>https://idp.example.com</saml:Issuer> <ds:Signature>...</ds:Signature> <saml:Subject> <saml:NameID>alice@example.com</saml:NameID> <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"> <saml:SubjectConfirmationData Recipient="https://sp.example.com/acs" NotOnOrAfter="2026-05-30T08:19:23Z"/> </saml:SubjectConfirmation> </saml:Subject> <saml:Conditions NotBefore="2026-05-30T08:14:23Z" NotOnOrAfter="2026-05-30T08:19:23Z"> <saml:AudienceRestriction> <saml:Audience>https://sp.example.com</saml:Audience> </saml:AudienceRestriction> </saml:Conditions> <saml:AttributeStatement> <saml:Attribute Name="groups"> <saml:AttributeValue>engineering</saml:AttributeValue> <saml:AttributeValue>admin</saml:AttributeValue> </saml:Attribute> </saml:AttributeStatement></saml:Assertion>Validation rules are denser than OIDC: verify the XML signature with XML Digital Signature rules, check Audience, Recipient, time bounds, replay protection (the SP must reject duplicate AssertionID values within their validity window), and ensure the signature covers the parts of the document it claims to cover.
XML signature wrapping (XSW) attacks have hit major SAML implementations because the XML signature is on a subtree, and a naive parser may verify the signature against one node and then read attributes from a different node. The fix is a careful library — never roll your own XML signature verification.
Verifying an OIDC id_token in three languages#
The minimum-viable verification step that an RP does on every login.
# pip install python-jose[cryptography] requestsfrom jose import jwtimport requests
ISSUER = "https://accounts.example.com"CLIENT_ID = "client_abc"
def verify_id_token(token: str, expected_nonce: str) -> dict: jwks = requests.get(f"{ISSUER}/.well-known/jwks.json", timeout=5).json() claims = jwt.decode( token, jwks, algorithms=["RS256"], audience=CLIENT_ID, issuer=ISSUER, ) if claims.get("nonce") != expected_nonce: raise ValueError("nonce mismatch") return claims # claims["sub"], claims["email"], etc.package main
import ( "context" "github.com/coreos/go-oidc/v3/oidc")
func verifyIDToken(ctx context.Context, rawToken, expectedNonce string) (*oidc.IDToken, error) { provider, err := oidc.NewProvider(ctx, "https://accounts.example.com") if err != nil { return nil, err }
verifier := provider.Verifier(&oidc.Config{ClientID: "client_abc"}) idTok, err := verifier.Verify(ctx, rawToken) if err != nil { return nil, err }
var claims struct { Nonce string `json:"nonce"` Email string `json:"email"` } if err := idTok.Claims(&claims); err != nil { return nil, err } if claims.Nonce != expectedNonce { return nil, errMismatch("nonce") } return idTok, nil}
func errMismatch(s string) error { return &mismatchErr{s} }type mismatchErr struct{ field string }func (e *mismatchErr) Error() string { return e.field + " mismatch" }// npm install joseimport { jwtVerify, createRemoteJWKSet } from "jose";
const ISSUER = "https://accounts.example.com";const CLIENT_ID = "client_abc";const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
export async function verifyIdToken(rawToken, expectedNonce) { const { payload } = await jwtVerify(rawToken, JWKS, { issuer: ISSUER, audience: CLIENT_ID, }); if (payload.nonce !== expectedNonce) throw new Error("nonce mismatch"); return payload; // payload.sub, payload.email, etc.}SAML verification is conceptually similar but mechanically heavier — XML canonicalisation, signature wrapping defences, replay caches. Use a library (python-saml, gosaml2, node-saml); do not parse SAML assertions by hand.
Variants#
The major shapes within each protocol family:
| Variant | Family | When it fits |
|---|---|---|
| OIDC Authorization Code + PKCE | OIDC | The modern default for browser and mobile clients. |
| OIDC Implicit | OIDC | Deprecated. Don’t ship. |
| OIDC Hybrid Flow | OIDC | Rare, mostly for compatibility with old SP-initiated flows. |
| SAML SP-Initiated POST | SAML | The most common SAML flow. User visits SP first. |
| SAML IdP-Initiated | SAML | User starts at the IdP (a portal); IdP launches the SP. CSRF concerns are real. |
| SAML HTTP-Redirect Binding | SAML | Smaller assertions go through URL parameters. |
| OAuth 2 + JWT-shaped access tokens (RFC 9068) | OAuth+ | Access tokens that the resource server can verify locally, like an id_token. Not OIDC, but uses the same JWT machinery. |
| WS-Federation | Enterprise | Older Microsoft-led SSO protocol, mostly seen with on-prem ADFS. |
Trade-offs#
OIDC strengths. JSON wire format; JWT signing is well-tooled across every language; small spec surface (one core spec, a handful of optional extensions); discovery is built in (.well-known/openid-configuration); composes with OAuth 2 access tokens; widely supported by consumer IdPs (Google, Apple, Microsoft). Costs: token-storage and refresh-rotation patterns still require care; JWT verification gaps are a recurring CVE source.
SAML strengths. Deeply deployed in enterprise IT; IdP-side group/role mapping is rich; signed assertions are self-contained; predates and survives every framework cycle. Costs: XML and XSLT and XML Signature; large spec surface (multiple bindings, profiles, NameID formats); homemade implementations have a CVE history (XSW, signature stripping); developer hostility is real.
What both share:
- Both rely on a signing key managed by the IdP. Rotation of that key must be planned; both protocols define a key-discovery mechanism (JWKS URI for OIDC, SAML metadata document for SAML).
- Both produce a session at the SP after the federated flow completes. The IdP step is one-time; subsequent calls use a local session cookie or local access token.
- Both are browser-based. Neither protocol fits server-to-server identity directly — for service identity, use mTLS or OAuth 2 Client Credentials.
Common pitfalls#
- Confusing OAuth 2 and OIDC. Access tokens are not identity. If your “login” endpoint reads
access_tokenand treatsaccess_token.subas the user, you have a confused-deputy bug. Use theid_token. - Skipping
noncein OIDC. The RP generates a nonce, sends it in/authorize, expects it back in theid_token. Without this check, an attacker can replay an old id_token from another session. - Trusting the
emailclaim inid_tokenwithoutemail_verified. Some IdPs let users set their email without verifying it. If you tie account access to email, check the verified flag. - Hard-coding JWKS keys. Keys rotate. Always fetch the JWKS dynamically and cache with a short TTL (5-15 minutes), or honour the
kidheader on every token. - Forgetting algorithm pinning. Many JWT libraries default to “verify with whatever algorithm the header says”. An attacker who can swap
alg: RS256foralg: none(oralg: HS256against the public key) bypasses signature verification. Pin the expected algorithm explicitly. - Rolling your own SAML XML signature verification. XSW attacks are subtle and well-documented. Use a library; never reimplement.
- Long-lived SAML assertion windows. The
NotOnOrAftertime should be minutes, not hours. Bearer assertions stolen in transit can be replayed up to that bound. - IdP-initiated SAML without CSRF protection. Without a
RelayStatethat ties the SAML response to a prior browser session, an attacker can deliver an attacker-controlled SAML response and log the user in as themselves. SP-initiated is safer; IdP-initiated needs extra care. - Mixing same-origin and SAML. Some SAML libraries set the session cookie without
SameSite=Lax, breaking cross-site logins from the IdP. The fix isSameSite=Laxand accepting the redirect-back constraint. - No clock skew tolerance. OIDC and SAML both check time bounds. A 30-second skew between IdP and SP fails every login. Allow
~60sof skew.
Related building blocks#
- OAuth 2 — The Authorization Framework — OIDC is OAuth 2 + identity; the OAuth 2 flow is the foundation.
- Authentication vs Authorization — OIDC is authentication; OAuth 2 is authorization; conflating them is the most common API bug.
- API Security — An Overview — where federated identity sits in the broader security model.
- Transport Layer Security (TLS) — both protocols assume TLS everywhere; without it, tokens and assertions are interceptable.
- API Security — A High-Level Recap — the checklist; OIDC/SAML covers the AuthN box.