Skip to main content
IAMRoadmapIAMRoadmap
General
9 min read

Single Sign-On Implementation: Complete Technical Guide

Step-by-step SSO implementation guide covering SAML 2.0, OAuth/OIDC, technical setup, common pitfalls, and troubleshooting tips.

I

IAM Roadmap Team

IAM Security Expert

December 15, 2025

What 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.

SSO Protocols Explained

SAML 2.0 (Security Assertion Markup Language)

Best For: Enterprise applications, legacy systems

How It Works:

  1. User attempts to access Service Provider (SP)
  2. SP redirects to Identity Provider (IdP)
  3. IdP authenticates user
  4. IdP sends SAML assertion to SP
  5. 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):

  1. User clicks login, redirected to IdP
  2. User authenticates at IdP
  3. IdP returns authorization code
  4. Application exchanges code for tokens
  5. 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

FeatureSAML 2.0OIDC/OAuth 2.0
FormatXMLJSON (JWT)
Use CaseEnterprise SSOModern apps, APIs
Mobile SupportLimitedExcellent
ComplexityHigherLower
Token SizeLargerSmaller

Technical Architecture

Components

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│    User     │────▶│   Browser   │────▶│ Application │
└─────────────┘     └─────────────┘     └──────┬──────┘
                                               │
                                               ▼
                                        ┌─────────────┐
                                        │     IdP     │
                                        │  (Okta/    │
                                        │  Entra ID)  │
                                        └─────────────┘

IdP-Initiated vs SP-Initiated SSO

SP-Initiated (Recommended):

  1. User visits application first
  2. Application redirects to IdP
  3. After auth, IdP redirects back

IdP-Initiated:

  1. User starts at IdP portal
  2. Clicks application tile
  3. IdP sends assertion to SP

Implementation 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:

  1. Create new application integration
  2. Select SAML 2.0 or OIDC
  3. Configure SSO URL and entity ID
  4. Set attribute mappings
  5. Download metadata/credentials

For Microsoft Entra ID:

  1. Go to Enterprise Applications
  2. Create new application
  3. Configure SSO settings
  4. Set up user provisioning
  5. 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:

CheckSAML 2.0OIDC
SignatureVerify the assertion (and ideally the response) against the IdP certificate from metadataVerify the ID token signature against the keys at the jwks_uri
Audience<Audience> must equal your entity IDaud must contain your client_id
Issuer<Issuer> must match the IdP entity IDiss must match the discovery document
Time windowNotBefore / NotOnOrAfter with a small clock skew allowanceexp, iat, and nbf if present
ReplayTrack assertion IDs for the validity window; reject duplicatesVerify nonce matches the login request
DestinationDestination must equal your ACS URLredirect_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.

Testing Checklist

Run through this list with the real identity provider, not a mock, before go-live:

  1. SP-initiated login from a deep link returns the user to that page, not the homepage.
  2. IdP-initiated login (SAML) lands on the correct application and does not create a duplicate account.
  3. A user who is not assigned to the application gets a clear error, not a blank page.
  4. Attribute changes at the IdP (name, email, groups) show up on the next login.
  5. Expired assertion or token is rejected; set the IdP clock skew tolerance to a few minutes at most.
  6. Logout behaves as documented above on the browsers your users have.
  7. Certificate rotation: the SP picks up a new IdP signing certificate from metadata, or you have a dated reminder to replace it by hand.

Common 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.

Troubleshooting

SymptomLikely causeWhere 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 slashBoth configurations, character by character
Login loopsThe session cookie is not being set (SameSite, domain, HTTPS mismatch)Browser dev tools, Application > Cookies
Works for admins, fails for usersAssignment or group filter at the IdPIdP application assignment and sign-in logs
"Clock skew" or NotOnOrAfter errorsServer time driftNTP on the application servers
Attributes missingClaim mapping not configured, or claims limited by scopeIdP 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.

Key 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.

Related Topics

SSOSAMLOIDCOAuthImplementationTechnical Guide

Found this helpful?

Share it with your network