API Design

Security · Lesson 07

OpenID Connect & SAML

OAuth 2.0 answers "what may this app do?" — but it says nothing about who is logged in. OpenID Connect adds that identity layer. SAML solves the same problem with XML in enterprise environments. Both power the "sign in with…" buttons you use every day.

⏱ 12 min Difficulty: core Prereq: OAuth 2.0 (Lesson 06)

By the end you'll be able to

The missing piece in OAuth 2.0

Imagine you hand your valet key (from the previous lesson) to a parking attendant, and then you ask: "Did a human hand you this key, or was it passed through a relay?" The valet key says nothing about its owner — only what doors it can open. OAuth 2.0 has the same blind spot: an access token proves what a client may do, but not which human granted it, or even whether a human was involved at all.

OpenID Connect (OIDC) fills that gap by adding one new token — the ID token — and a standard way to ask "who is this user?" It is OAuth 2.0 underneath, with an identity contract bolted on top. You get both an access token (for API calls) and an ID token (for your app to know who logged in).

The ID token: a JWT about the user

An ID token is a JSON Web Token (JWT) signed by the identity provider. Its payload always includes a standard set of claims:

// ID token payload (decoded — the actual token is Base64URL-encoded)
{
  "iss": "https://accounts.example.com",  // issuer
  "sub": "user_7f3a9c",                   // subject (stable user id)
  "aud": "client_app_xyz",               // audience — must match your client_id
  "exp": 1750000000,                    // expiry (Unix timestamp)
  "iat": 1749996400,                    // issued-at
  "nonce": "k9sRt2pQ",                   // replay-attack protection
  "email": "ada@example.com",            // profile scope granted
  "name":  "Ada Lovelace"
}

Your app verifies the signature, checks the aud matches your own client ID, and confirms the token has not expired — then you know exactly who logged in. The access token is still used for API calls; the ID token is for your app's session. Never send the ID token to an API as a Bearer token — it is for the client, not the resource server.

SSO: one login, many apps

Single Sign-On (SSO) means logging in once to an identity provider and then gaining seamless access to multiple connected services without re-entering credentials. The identity provider authenticates you; each service trusts the IdP's assertion.

User one login Identity Provider authenticates user issues ID token / SAML assertion App A CRM App B HR Portal App C Dev Dashboard login once token / assertion
With SSO the identity provider holds one session. Each downstream app trusts the IdP's token rather than maintaining its own login — the user never re-enters credentials.

SAML: SSO the enterprise way

Security Assertion Markup Language (SAML 2.0) is an older XML-based standard that predates OAuth. It is still dominant in large enterprises because it integrates tightly with Active Directory / LDAP identity stores and legacy applications.

The flow is conceptually similar to OIDC: a Service Provider (the app) redirects the user to an Identity Provider (e.g. Okta, Microsoft AD FS), the IdP authenticates the user and returns a signed XML assertion to the SP, and the SP validates the assertion and creates a session.

FeatureOpenID Connect (OIDC)SAML 2.0
Token formatJWT (compact, JSON)XML assertion (verbose)
TransportHTTP redirect + JSON bodyHTTP redirect or POST with Base64 XML
EcosystemModern web, mobile, SaaSEnterprise, legacy apps, government
Built onOAuth 2.0Own independent standard
Typical IdPsGoogle, Auth0, Cognito, Okta (OIDC mode)Okta (SAML mode), ADFS, Shibboleth
When to chooseNew apps, APIs, mobile, B2CEnterprise B2B, Active Directory integration
🎯 Interview angle

Three concepts that interviewers conflate — the clean answer: Authentication (authN) = "prove who you are" (password check, biometric). Authorization (authZ) = "decide what you may do" (roles, scopes, object-level checks). SSO = "authenticate once, access many services without re-logging in." SSO is an application of authN delegated to a central IdP — it is not a separate type of security. OIDC and SAML are both protocols that implement SSO; OAuth 2.0 on its own is only authZ.

⚠️ Common trap

Using an ID token as a Bearer token for API calls. The ID token's aud claim is set to your client ID — not to the API. If you send it as a Bearer token, a correctly-implemented API will reject it because the audience does not match. Use the access token for API calls. Use the ID token only in your application to know who the user is.

✅ Do this, not that

Do always validate the nonce claim in OIDC responses to prevent replay attacks — your client generates a random nonce, includes it in the authorization request, and verifies it appears unchanged in the returned ID token. Don't skip this step just because the connection is over HTTPS; a stolen token from browser history or a log file can still be replayed without nonce validation.

Under the hood: how OIDC actually works

OpenID Connect is OAuth 2.0 authorization-code flow with two additions: the openid scope in the initial redirect, and an id_token returned alongside the access token. Everything else — PKCE, state, the token exchange — is identical to OAuth. The identity layer is thin; the transport layer is already OAuth.

# ① /authorize request — identical to OAuth but adds "openid" scope
GET https://accounts.example.com/authorize
  ?response_type=code
  &client_id=my_app
  &redirect_uri=https://app.example.com/callback
  &scope=openid profile email   // "openid" triggers OIDC; "profile"/"email" request claims
  &state=r8tQmZ4k
  &nonce=k9sRt2pQ               // OIDC-specific: client stores this; IdP must echo it in id_token
  &code_challenge=E9Melhoa2Ow...
  &code_challenge_method=S256

# ② POST /token exchange (same as OAuth) returns TWO tokens:
HTTP/1.1 200 OK
{
  "access_token":  "eyJhbGci...",   // for API calls
  "id_token":      "eyJhbGci...",   // for your app to know who logged in
  "token_type":    "Bearer",
  "expires_in":    3600
}

Decoded ID token and client-side validation

An ID token is a signed JWT. Its payload contains a fixed set of claims the client must validate in strict order before trusting the identity. Skipping any step leaves the app vulnerable to token substitution or replay.

# Decoded id_token payload (the actual token is three Base64URL segments joined by dots)
{
  "iss":   "https://accounts.example.com",  // issuer
  "sub":   "user_7f3a9c",                   // stable, opaque user ID — use as primary key
  "aud":   "my_app",                        // MUST match your client_id exactly
  "exp":   1750003600,                      // reject if exp < now()
  "iat":   1750000000,                      // issued-at; reject if too far in the past
  "nonce": "k9sRt2pQ",                     // MUST match the nonce the client sent in ①
  "email": "ada@example.com",
  "name":  "Ada Lovelace",
  "email_verified": true
}

# Client validation algorithm — in this exact order
1. Fetch the IdP's JWKS from /.well-known/openid-configurationjwks_uri
2. Match the token's "kid" header claim against a key in the JWKS
3. Verify the signature using that public key and the pinned algorithm ("RS256" typical)
4. Assert iss == expected issuer URL (prevents cross-IdP token substitution)
5. Assert aud contains your client_id (prevents tokens meant for other apps)
6. Assert exp > now()  (token not expired)
7. Assert nonce == nonce you stored in session before the redirect  (replay protection)
8. Optionally assert iat is recent (e.g. within 5 minutes)
# Only after all 8 checks pass: trust the sub and create your app session

SAML assertion exchange at a protocol level

SAML uses XML POSTed through the browser as the transport mechanism. The browser never touches the assertion content — it just carries an opaque base64-encoded blob from the IdP to the Service Provider (SP). The core exchange has three steps:

Browser IdP (e.g. Okta) Service Provider (SP) ① GET /app (unauthenticated) ② 302 → IdP SSO URL?SAMLRequest=<base64 AuthnRequest>&RelayState=... ③ browser follows redirect to IdP ④ IdP shows login screen; user authenticates ⑤ user authenticates with IdP credentials ⑥ IdP returns auto-POSTing HTML form with SAMLResponse ⑦ browser auto-POSTs SAMLResponse to SP Assertion Consumer Service URL ⑧ SP validates signature, extracts NameID + attributes, creates session → 302 to /app
SAML uses the browser as a passive relay. The SP never contacts the IdP directly — the signed XML assertion travels through the browser. The SP validates the XML digital signature to prove authenticity.

The SAMLResponse is a base64-encoded, XML-signed document. It contains the user's NameID (usually an email or opaque ID), attribute statements (department, role, groups), and validity timestamps (NotBefore, NotOnOrAfter). The SP must validate the XML signature against the IdP's public certificate, check the NotOnOrAfter timestamp, and verify the Recipient matches its own ACS URL.

How to debug & inspect it

OIDC and SAML failures almost always fall into three categories: token validation errors, clock-skew issues, and configuration mismatches. Here is how to find each one in practice.

# OIDC: decode the id_token payload in the shell ID_TOKEN="eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmV4YW1wbGUuY29tIiwic3ViIjoidXNlcl83ZjNhOWMiLCJhdWQiOiJteV9hcHAiLCJleHAiOjE3NTAwMDM2MDAsIm5vbmNlIjoiazlzUnQycFEifQ.SIG" echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool { "iss": "https://accounts.example.com", "sub": "user_7f3a9c", "aud": "my_app", "exp": 1750003600, "nonce": "k9sRt2pQ" } # Check expiry: date -d @1750003600 Fri Jun 15 01:00:00 UTC 2025 # Verify the signature against the IdP's JWKS (using jwt-cli or openssl) curl -s https://accounts.example.com/.well-known/openid-configuration | python3 -m json.tool | grep jwks_uri "jwks_uri": "https://accounts.example.com/.well-known/jwks.json" curl -s https://accounts.example.com/.well-known/jwks.json { "keys": [{ "kid": "key-1", "alg": "RS256", "n": "...", "e": "AQAB" }] } # Match the "kid" from the token header against this list # SAML: decode a SAMLResponse (it's base64 XML) echo "<SAMLResponse base64 value>" | base64 -d | xmllint --format - # Look for: NotOnOrAfter, Issuer, NameID, Recipient, and the ds:Signature block
SymptomProtocolCauseFix
"Invalid signature" / token rejectedOIDCIdP rotated its signing key; client is caching the old JWKS or the wrong kidForce a JWKS refresh; confirm the client looks up the key by kid, not by index
"aud mismatch" / "invalid audience"OIDCToken's aud is a different client_id; or you sent the ID token to an API that checks audienceUse the access token for API calls; ensure the ID token's aud equals your client ID
"iss mismatch"OIDCMulti-tenant IdP — token's iss contains a tenant ID your code doesn't account for, or you're comparing against the wrong base URLPin the expected issuer string exactly (including trailing slash or tenant path)
"nonce mismatch"OIDCSession was lost before callback, or the nonce was not included in the initial requestEnsure the nonce is stored in a session cookie before the redirect and read back in the callback handler
Token expired immediatelyOIDC / SAMLServer clock skew — server is ahead of the IdP by more than the token's validity windowSynchronise clocks with NTP; add a ±30 s leeway in token validation
"SAML Response: Recipient mismatch"SAMLThe SP's Assertion Consumer Service URL in the SAMLResponse doesn't match the configured ACS URLRegister the exact ACS URL in the IdP; ensure no trailing-slash difference
"Signature verification failed" in SAMLSAMLIdP certificate expired or the SP is using an outdated certificate copyExport the current IdP certificate from the IdP admin UI and re-import into the SP
SSO works but user attributes missingSAMLIdP attribute mapping not configured — the assertion doesn't include the expected attribute statementsConfigure attribute mappings in the IdP admin; confirm attribute names match what the SP expects

OIDC / SAML debug checklist:

  1. Decode the id_token or SAMLResponse payload (shell commands above) — read the raw claims before touching library-level errors.
  2. Check exp / NotOnOrAfter against date -d @<timestamp>. If the token is already expired, the clock difference is the root cause, not the token itself.
  3. For OIDC: confirm iss exactly matches your pinned issuer URL and aud exactly matches your client_id.
  4. For OIDC: confirm the nonce in the token equals what you stored in the session before the redirect.
  5. For signature errors: fetch the IdP's JWKS, find the key whose kid matches the token header, and verify it is current (not expired or replaced).
  6. For SAML: decode the SAMLResponse with base64 -d | xmllint and check Recipient, NotOnOrAfter, and the Issuer element.

🧠 Quick check

1. What does OpenID Connect add to OAuth 2.0?

OAuth 2.0 has refresh tokens and PKCE is layered on top separately. OIDC's distinctive addition is the ID token — a signed JWT that identifies the user — plus the /userinfo endpoint and standardized claims like sub, email, and name.

2. In which scenario would you choose SAML over OIDC?

SAML's XML-based assertion model and deep Active Directory / LDAP integration make it the default choice for enterprise SSO. Consumer apps and APIs are better served by OIDC's lighter JSON/JWT format. Machine-to-machine is OAuth client-credentials — no user, no SSO needed.

3. What is the purpose of the nonce claim in an OIDC ID token?

The client generates a random nonce and includes it in the authorization request. The IdP echoes it back in the ID token. The client verifies the returned nonce matches what it sent — so a token captured from one flow cannot be replayed to a different session.

✍️ Exercise: choose the right protocol

Three scenarios are described below. For each one, state which protocol you would use (OAuth 2.0 only, OIDC, or SAML) and give a one-sentence reason.

  1. A SaaS task-manager wants to add "Log in with GitHub."
  2. A healthcare network needs all 40 of its internal web portals to share a single login, backed by a Microsoft Active Directory forest.
  3. A payments service needs to call the fraud-scoring service with machine-level credentials at night, no user involved.

Model answer:

  1. OIDC — GitHub supports OpenID Connect; OIDC returns an ID token identifying the user, which is exactly what a "log in with" button needs. Plain OAuth 2.0 would give you an access token but no standard way to know who the user is.
  2. SAML — Microsoft Active Directory and ADFS have native SAML 2.0 support; integrating 40 internal apps via SAML federation is the standard enterprise pattern and avoids re-implementing directory sync across services.
  3. OAuth 2.0 client-credentials — no user is present, so SSO is irrelevant; client-credentials issues a scoped access token for the service account without requiring any user interaction or identity claims.

Rubric: Full marks for correct protocol per scenario with a clear rationale. Partial marks for correct protocol without explaining the "why." Bonus: noting that many enterprise IdPs (Okta, Azure AD) support both SAML and OIDC — the choice may come down to what the client library ecosystem supports.

Key takeaways

Sources & further reading