01What is Single Sign-On?
Single Sign-On (SSO) allows users to authenticate once and access multiple applications without re-entering credentials. It improves user experience while enhancing security through centralized authentication.
02SSO Protocols Explained
SAML 2.0 (Security Assertion Markup Language)
Best For: Enterprise applications, legacy systems
How It Works:
- User attempts to access Service Provider (SP)
- SP redirects to Identity Provider (IdP)
- IdP authenticates user
- IdP sends SAML assertion to SP
- SP grants access based on assertion
Key Components:
- Assertion: XML document with authentication/authorization data
- Metadata: Configuration exchange between IdP and SP
- Bindings: How messages are transported (HTTP-POST, HTTP-Redirect)
OAuth 2.0 / OpenID Connect
Best For: Modern applications, APIs, mobile apps
How It Works (Authorization Code Flow):
- User clicks login, redirected to IdP
- User authenticates at IdP
- IdP returns authorization code
- Application exchanges code for tokens
- Application uses access token for APIs
Key Components:
- ID Token: JWT containing user identity claims
- Access Token: Token for API authorization
- Refresh Token: Token for obtaining new access tokens
Protocol Comparison
| Feature | SAML 2.0 | OIDC/OAuth 2.0 |
|---|---|---|
| Format | XML | JSON (JWT) |
| Use Case | Enterprise SSO | Modern apps, APIs |
| Mobile Support | Limited | Excellent |
| Complexity | Higher | Lower |
| Token Size | Larger | Smaller |
03Technical Architecture
Components
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │────▶│ Browser │────▶│ Application │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
▼
┌─────────────┐
│ IdP │
│ (Okta/ │
│ Entra ID) │
└─────────────┘
IdP-Initiated vs SP-Initiated SSO
SP-Initiated (Recommended):
- User visits application first
- Application redirects to IdP
- After auth, IdP redirects back
IdP-Initiated:
- User starts at IdP portal
- Clicks application tile
- IdP sends assertion to SP
04Implementation Steps
Step 1: Choose Your Protocol
Use SAML 2.0 when:
- Integrating with enterprise applications
- Application only supports SAML
- Need attribute-based access control
Use OIDC when:
- Building modern web/mobile apps
- Need API access
- Want simpler implementation
Step 2: Configure Identity Provider
For Okta:
- Create new application integration
- Select SAML 2.0 or OIDC
- Configure SSO URL and entity ID
- Set attribute mappings
- Download metadata/credentials
For Microsoft Entra ID:
- Go to Enterprise Applications
- Create new application
- Configure SSO settings
- Set up user provisioning
- Assign users/groups
Step 3: Configure Service Provider
SAML SP Configuration:
<!-- SP Metadata Example -->
<EntityDescriptor entityID="https://app.example.com">
<SPSSODescriptor>
<AssertionConsumerService
Location="https://app.example.com/saml/acs"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"/>
</SPSSODescriptor>
</EntityDescriptor>
OIDC Client Configuration:
// OIDC Configuration
const config = {
client_id: 'your-client-id',
redirect_uri: 'https://app.example.com/callback',
response_type: 'code',
scope: 'openid profile email',
authority: 'https://your-idp.com'
};
Step 4: Implement Authentication Flow
The example below is the OIDC authorization code flow with PKCE. The browser only ever sees the authorization code; the token exchange happens on your server, where the client secret lives.
Browser: start the login
// Generate a PKCE verifier and challenge, and a random state value.
async function login() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
// Both values are needed again when the IdP redirects back.
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oidc_state', state);
const params = new URLSearchParams({
client_id: config.client_id,
redirect_uri: config.redirect_uri,
response_type: 'code',
scope: config.scope,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
window.location.href = `${config.authority}/authorize?${params}`;
}
Browser: handle the redirect back
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
// Reject the response if the state does not match what this browser sent.
if (params.get('state') !== sessionStorage.getItem('oidc_state')) {
throw new Error('State mismatch: possible CSRF or a stale login attempt');
}
if (params.get('error')) {
throw new Error(`IdP returned ${params.get('error')}: ${params.get('error_description')}`);
}
// Hand the code and verifier to your own backend; it performs the token exchange.
const response = await fetch('/api/auth/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: params.get('code'),
code_verifier: sessionStorage.getItem('pkce_verifier'),
}),
});
if (!response.ok) throw new Error('Token exchange failed');
sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('oidc_state');
window.location.replace('/');
}
Server: exchange the code and validate the ID token
// Node.js (Express) example using the openid-client library.
import * as client from 'openid-client';
const oidc = await client.discovery(new URL(config.authority), config.client_id, config.client_secret);
app.post('/api/auth/callback', async (req, res) => {
const { code, code_verifier } = req.body;
// The library sends the code and verifier to the token endpoint, then checks the
// ID token signature, issuer, audience, expiry and nonce for you.
const tokens = await client.authorizationCodeGrant(oidc, new URL(`${config.redirect_uri}?code=${code}`), {
pkceCodeVerifier: code_verifier,
expectedState: client.skipStateCheck, // the browser already verified state
});
const claims = tokens.claims();
req.session.user = { id: claims.sub, email: claims.email, name: claims.name };
res.status(204).end();
});
Use a maintained library for the exchange and validation. Hand-written JWT checks miss things: accepting alg: none, skipping the audience check, or trusting an ID token that was issued for a different client are all mistakes that libraries prevent by default.
Step 5: Validate What the IdP Sends
Whichever protocol you use, the service provider must verify the response before trusting it:
| Check | SAML 2.0 | OIDC |
|---|---|---|
| Signature | Verify the assertion (and ideally the response) against the IdP certificate from metadata | Verify the ID token signature against the keys at the jwks_uri |
| Audience | <Audience> must equal your entity ID | aud must contain your client_id |
| Issuer | <Issuer> must match the IdP entity ID | iss must match the discovery document |
| Time window | NotBefore / NotOnOrAfter with a small clock skew allowance | exp, iat, and nbf if present |
| Replay | Track assertion IDs for the validity window; reject duplicates | Verify nonce matches the login request |
| Destination | Destination must equal your ACS URL | redirect_uri is registered exactly, no wildcards |
Step 6: Sessions and Logout
SSO authenticates the user; your application still owns its session. After a successful login, create a server-side session (or a short-lived session cookie) and do not keep reusing the ID token as a session credential. Set the cookie Secure, HttpOnly and SameSite=Lax.
Logout has two levels:
- Local logout clears your session only. The user stays signed in at the IdP, so the next visit signs them straight back in. Fine for most internal apps.
- Single logout also ends the IdP session (OIDC RP-Initiated Logout via
end_session_endpoint, or SAML Single Logout). Use it on shared or kiosk devices. SAML SLO in particular is unreliable across vendors; test it with the real IdP before you promise it.
05Testing Checklist
Run through this list with the real identity provider, not a mock, before go-live:
- SP-initiated login from a deep link returns the user to that page, not the homepage.
- IdP-initiated login (SAML) lands on the correct application and does not create a duplicate account.
- A user who is not assigned to the application gets a clear error, not a blank page.
- Attribute changes at the IdP (name, email, groups) show up on the next login.
- Expired assertion or token is rejected; set the IdP clock skew tolerance to a few minutes at most.
- Logout behaves as documented above on the browsers your users have.
- Certificate rotation: the SP picks up a new IdP signing certificate from metadata, or you have a dated reminder to replace it by hand.
06Common Pitfalls
Matching users by email. Email addresses change and can be reassigned. Map accounts on the stable identifier (sub in OIDC, NameID with a persistent format in SAML) and store the email only as an attribute.
Open redirects after login. If you store a "return to" URL before redirecting to the IdP, allow only relative paths on your own origin when you read it back.
Wildcard redirect URIs. Some IdPs allow them. Do not use them; register every redirect URI exactly.
Treating the access token as proof of identity. An OAuth access token is for calling APIs. Identity comes from the ID token (OIDC) or the assertion (SAML).
Letting the signing certificate expire. SAML certificates are typically valid for one to three years. When one expires every login fails at once. Put the expiry date in a calendar and in monitoring.
Group claims that are too large. Entra ID, for example, stops sending groups in the token past a limit and sends a link instead. Filter groups at the IdP to the ones the application needs.
07Troubleshooting
| Symptom | Likely cause | Where to look |
|---|---|---|
| "Invalid signature" | Wrong IdP certificate, or the response is signed but the assertion is not (or the reverse) | Compare the certificate in metadata; check which element the IdP signs |
| "Audience mismatch" | Entity ID or client_id differs between the two sides, often by a trailing slash | Both configurations, character by character |
| Login loops | The session cookie is not being set (SameSite, domain, HTTPS mismatch) | Browser dev tools, Application > Cookies |
| Works for admins, fails for users | Assignment or group filter at the IdP | IdP application assignment and sign-in logs |
"Clock skew" or NotOnOrAfter errors | Server time drift | NTP on the application servers |
| Attributes missing | Claim mapping not configured, or claims limited by scope | IdP attribute mapping; the scope parameter in OIDC |
Every mainstream IdP has a sign-in log that shows exactly why it rejected or what it sent. For the SP side, a browser SAML or OIDC tracer extension lets you read the actual assertion or token without touching server logs.
08Key Takeaways
- Pick OIDC for anything you are building now; pick SAML when the application or the customer requires it.
- Use PKCE and
state, exchange the code on the server, and validate every token or assertion with a maintained library. - Match users on the stable identifier, not on email.
- Your application owns its session; the IdP only owns authentication.
- Test with the real IdP, including the failure paths, and track certificate expiry before it tracks you.
