Skip to main content
Articles

Adding Okta SSO to a React app: SAML and OIDC walkthrough

Author: Roy Anger
Published:

How do I add Okta SSO to a React app using SAML or OIDC?

For a Vite-based React single-page app, use OIDC with the Authorization Code and PKCE flow through Okta's official @okta/okta-react SDK (built on @okta/okta-auth-js). That's Okta's recommended approach for SPAs, and it needs no backend. Choose SAML only when an enterprise customer or identity provider requires it: a signed SAML assertion can't be validated securely in browser-only JavaScript, so SAML needs a small backend service provider (Node or Express, for example). This walkthrough configures both the Okta side and the React side for each protocol, then shows when a managed auth platform is the simpler path.

Here's the route through this guide: choose your protocol, set up the prerequisites and your Okta account, then follow the OIDC walkthrough (the default for most SPAs) or the SAML-with-a-backend walkthrough when something requires it. After that, weigh the managed-platform option, and lean on the troubleshooting section and FAQ to close gaps. Scope: this targets a Vite plus React 18 or 19 single-page app written in TypeScript. Native and React Native apps, and server-rendered React frameworks like Next.js or Remix, are out of scope, since they can validate SAML server-side and have their own Okta patterns.

Choosing between OIDC and SAML for your React app

Use OIDC for a React SPA unless something specific forces SAML. The OIDC Authorization Code with PKCE flow runs entirely in the browser through @okta/okta-react, so a Vite SPA needs no backend. SAML delivers a signed XML assertion that has to be validated on a server, so it always needs a backend service provider. Choose the protocol first, because it decides the entire implementation path.

OIDC and SAML in one minute (for React developers)

OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0. It carries identity as JSON and signed JSON Web Tokens (JWTs), which JavaScript reads natively, and it's the modern default for web and mobile sign-in. For a single-page app, the relevant flow is Authorization Code with PKCE: the browser gets a short-lived authorization code, then exchanges it for an ID token and an access token.

Security Assertion Markup Language (SAML) is older and XML-based. An identity provider (IdP) like Okta signs an XML assertion and delivers it to your app over browser redirect and POST bindings. SAML is entrenched in enterprise IT, so many large customers can only federate with SAML.

Both protocols give you single sign-on (SSO): the user authenticates once with Okta, and your app trusts that result. The difference that matters for React is where the token gets validated.

The architectural fact that decides it: a SPA is browser-only

A pure React SPA can finish the OIDC flow in the browser, but it can't safely validate a SAML assertion in the browser.

With OIDC, PKCE (Proof Key for Code Exchange) protects the authorization-code exchange for a public client that has no client secret. PKCE is not a form of client authentication; the OAuth Security Best Current Practice (RFC 9700) requires public clients to use it precisely because a browser app can't keep a secret (RFC 7636). The SDK then verifies the ID token's signature against Okta's published public keys, and public-key verification is safe to do client-side.

With SAML, the IdP returns a signed XML assertion that must be validated on a server: the signature check, the audience restriction, and the time conditions all run server-side, and the service provider's private key can't live in browser JavaScript. Okta HTTP-POSTs the assertion to a server endpoint, the Assertion Consumer Service, by design (Okta SAML concepts).

Important

OIDC with Authorization Code + PKCE needs no backend in a React SPA. SAML always needs a backend service provider to validate the signed XML assertion. This one fact decides which walkthrough you follow.

Okta's own recommendation

Okta recommends OIDC with Authorization Code + PKCE for single-page apps, and reserves SAML for cases that already have a backend or face a hard enterprise requirement (Okta OAuth and OIDC overview). Okta also recommends its hosted, redirect-based sign-in over an embedded widget for most integrations (Okta redirect vs. embedded). The native walkthrough below follows both recommendations.

Decision table: OIDC vs SAML for a React app

DimensionOIDC (Authorization Code + PKCE)SAML 2.0
Token formatJSON / JWT (ID token)Signed XML assertion
Runs entirely in the browser?YesNo (needs a backend SP)
Backend required?NoYes (validate signed XML server-side)
Client secret?None; PKCE protects the code exchangeSP private key, held on the server
Refresh / renewalRefresh tokens (offline_access) plus auto-renewBackend session, then re-SSO
Okta app typeOIDC, Single-Page ApplicationSAML 2.0
Typical trigger to chooseGreenfield React SPA (the default)An enterprise customer or IdP mandates SAML
Relative effort in a SPALowHigh (build, secure, and maintain an SP)

Decision tree: which path to choose

Walk this top-down. Each branch points at a section below.

  • Start: do you control the identity provider, or must you support customers' IdPs?
    • You control one Okta org (a workforce app, or you are the Okta tenant): continue to Protocol.
    • Each B2B customer brings their own IdP (multi-tenant): use a managed platform with enterprise connections, and stop here (Option C).
  • Protocol: is SAML specifically required, because a customer or contract mandates it?
    • No: use OIDC (Authorization Code + PKCE) via @okta/okta-react. No backend. (Walkthrough A.)
    • Yes, and you're fine running and securing a backend: use SAML with a Node/Express SP via @node-saml/node-saml. (Walkthrough B.)
    • Yes, but you don't want to operate an SP, patch SAML CVEs, or rotate certificates: use a managed platform to broker SAML. (Option C.)
  • Also choose a managed platform if you need both protocols at once, per-tenant routing, or SCIM provisioning.

When the answer is a managed platform

If you need both SAML and OIDC, per-tenant routing for many enterprise customers, or SCIM user provisioning, a managed auth platform can broker the Okta connection for you. The platform acts as the service provider, so you write neither a SAML SP nor the protocol plumbing. Option C covers this honestly, including where it's the better call and where direct integration is fine.

Prerequisites

You need an Okta account and a Vite React app for both protocols, plus a backend for the SAML track. Here's the full preflight.

For both protocols:

  • A free Okta account (the Integrator Free Plan, with a domain like integrator-XXXXXXX.okta.com) and admin access.
  • A Vite app with React 18 or 19 and TypeScript, created with npm create vite@latest.
  • A current Node.js LTS. As of mid-2026 that's Node 24 (Active LTS); Node 22 (Maintenance LTS) also works. Node 18 and Node 20 are both end-of-life, so don't target them (Node.js releases).
  • React Router installed. This guide uses v6 as primary, with v7 notes inline.
  • A user or email you can assign to the Okta app.

Additional for the SAML track:

  • A backend service (Node with Express, or equivalent) to act as the SAML service provider.
  • An HTTPS-reachable callback URL for the Assertion Consumer Service.
  • A maintained SAML library. This guide uses @node-saml/node-saml at version 5.1.0 or later.
  • A way to manage a session cookie or app token for the SPA.

Run through this checklist before you start:

Checklist

Set up your Okta account and orient to the Admin Console

Both walkthroughs start from the same Okta setup. Create an org, find your issuer URL, and learn where the four screens you'll use actually live.

Create a free Okta account (or use an existing org)

Sign up at developer.okta.com. New sign-ups now create an Integrator Free Plan org (the older Developer Edition was deactivated in 2025), so your domain looks like integrator-XXXXXXX.okta.com, not the legacy dev-XXXXXX.okta.com (Okta Developer Edition changes). The free plan includes 10 active users, SSO, adaptive MFA, and API Access Management, and that last item is what provides the custom authorization server you'll use (Okta Integrator Free Plan defaults).

Record two things from the dashboard:

  • Your Okta domain, for example https://integrator-1234567.okta.com.
  • Your default issuer, which is https://<your-org>.okta.com/oauth2/default. That /oauth2/default path is the default custom authorization server, and it's the issuer the OIDC walkthrough uses (Okta authorization servers).

Admin MFA is mandatory on the Integrator Free Plan (Okta Support: super-admin locked out after disabling MFA), and an org deactivates after 180 days of inactivity, so keep your enrollment factor handy.

Find your way around the Admin Console

Four areas of the Admin Console matter for this guide:

  • Applications: create and manage app integrations (your OIDC or SAML app).
  • Directory: manage users and groups, and assign them to apps.
  • Security → API → Authorization Servers: the default authorization server, its scopes, and custom claims (including the groups claim for role-based access).
  • Security → API → Trusted Origins: register browser origins for CORS and redirects, which the Vite dev server needs.

Knowing where these live makes the numbered steps below quick to follow.

Walkthrough A: Add Okta OIDC to a React (Vite) SPA

This is the recommended path for most React apps. OIDC with the Authorization Code flow plus PKCE completes entirely in the browser, so a Vite single-page app needs no backend. You configure an Okta app integration, wire up Okta's official @okta/okta-react (built on @okta/okta-auth-js), and the SDK handles the redirect, the token exchange, and ID-token signature verification for you.

The steps below assume the Vite + React + TypeScript app and the Integrator Free Plan org from the Prerequisites and account setup sections. Each Okta dashboard step lists the exact labels; each code step is one file with prose between blocks.

Step 1: Create an OIDC app integration (Single-Page Application)

In the Admin Console, go to Applications → Applications → Create App Integration. Pick OIDC - OpenID Connect, then Single-Page Application, then Next (Okta SPA/React quickstart).

On the integration settings page, set these fields:

  • Grant type: check Authorization Code and Refresh Token. Authorization Code plus PKCE is Okta's recommended grant for public clients, and Refresh Token enables the offline_access scope you'll request later (Okta Auth Code + PKCE).
  • Sign-in redirect URIs: http://localhost:5173/login/callback. That's the Vite dev origin (port 5173, the default since Vite 3.0) plus the callback path the SDK routes to (Vite PR #8148).
  • Sign-out redirect URIs: http://localhost:5173.
  • Assignments → Controlled access: choose who can use the app (for example, "Allow everyone in your organization to access" or "Limit access to selected groups"). Only assigned users get in (Okta OIDC wizard reference).

Click Save. On the app's General tab, record the Client ID under Client Credentials. A SPA is a public client, so there's no client secret (Okta Auth Code + PKCE). Your issuer is the default custom authorization server, https://<your-org>.okta.com/oauth2/default (Okta authorization servers).

Optional: enable group-based authorization (RBAC)

If you want to authorize routes or UI by the user's Okta groups, add a groups claim so it rides along in the ID token. Configure it on the authorization server, not the app's Sign On tab.

Because this tutorial's issuer is the custom authorization server (/oauth2/default), add the claim at Security → API → Authorization Servers → default → Claims → Add Claim (Okta create claims). Use these field values:

  • Name: groups. The claim key in the token equals this Name verbatim and is case-sensitive, so to read idToken.claims.groups you must name it literally groups.
  • Include in token type: ID Token, set to Always.
  • Value type: Groups.
  • Filter: Matches regex .*.
  • Include in: Any scope (so you don't need a dedicated groups scope on top of ['openid','profile','email','offline_access']).

The app's Sign On tab also has a "Group claims filter," but that field only affects the Org authorization server. An org server can only put a groups claim in an ID token, not an access token, which is another reason this tutorial uses the custom /oauth2/default server.

Note

The claim arrives as idToken.claims.groups, a JSON array of group-name strings (for example, ["Everyone","Admins"]). A .* filter returns every group the user belongs to, including the built-in "Everyone." Scope the filter to the groups you actually authorize against in production rather than shipping .*, since a broad filter bloats every ID token.

Step 2: Configure Trusted Origins / CORS for Vite

Okta blocks cross-origin browser calls unless your dev origin is a Trusted Origin. Go to Security → API → Trusted Origins → Add Origin, set Origin URL to http://localhost:5173, and check both CORS and Redirect (Okta enable CORS). Save.

Warning

A missing Trusted Origin is the most common first error. CORS is required for the cross-origin XHR calls the SDK makes to the token endpoint (/token), the userinfo endpoint, and signOut(). It is not required for the /authorize step, which is a full-page redirect, so sign-in can start fine and then fail at the token exchange with Okta error E0000021 if you skip this (okta-auth-js issue #605).

Step 3: Install the SDK

Install Okta's React SDK, the underlying auth-js library, and React Router. These are the versions Okta's own React quickstart pins, a tested pairing (Okta SPA/React quickstart).

npm install @okta/okta-react@6.9.0 @okta/okta-auth-js@7.8.1 react-router-dom@6

As of mid-2026 the current published latest are @okta/okta-react@6.11.0 (March 16, 2026) and @okta/okta-auth-js@8.0.1 (May 26, 2026) (okta-react npm, okta-auth-js npm). If you adopt @okta/okta-auth-js@8, note two changes: it requires Node 20 or newer, and it stops decoding access tokens by default (the opt-in is decodeAccessTokens: true). Reading user identity from idToken.claims is unaffected and works the same on both 7.8.1 and v8, so every example here uses idToken.claims (okta-auth-js README).

Step 4: Configure the OktaAuth client

Create src/okta/config.ts and export a single OktaAuth instance. Read the issuer and client ID from Vite env vars, and request scopes explicitly. The SDK defaults scopes to ['openid','email'], which omits the profile claims and refresh tokens you want, so set all four (okta-auth-js README).

import { OktaAuth } from '@okta/okta-auth-js'

export const oktaAuth = new OktaAuth({
  issuer: import.meta.env.VITE_OKTA_ISSUER,
  clientId: import.meta.env.VITE_OKTA_CLIENT_ID,
  redirectUri: window.location.origin + '/login/callback',
  scopes: ['openid', 'profile', 'email', 'offline_access'],
})

A few notes on this config. offline_access is what gets you a refresh token, so leaving it out means no silent renewal later. PKCE is on by default for SPAs (pkce: true), so you don't set it. redirectUri is built from window.location.origin so it resolves to http://localhost:5173/login/callback in dev and your real origin in production, and it must match the Sign-in redirect URI from Step 1 exactly (Okta SPA/React quickstart).

Put the values in a .env file at the project root. Vite only exposes vars prefixed with VITE_ to client code (Vite env variables). There's no secret here, so this is safe for a SPA.

VITE_OKTA_ISSUER=https://<your-org>.okta.com/oauth2/default
VITE_OKTA_CLIENT_ID=<your-client-id>

Step 5: Wrap the app with Security, handle the callback, and catch auth errors

@okta/okta-react gives you <Security> (the context provider) and <LoginCallback> (the component that finishes the redirect). Both need to render inside a Router, because restoreOriginalUri calls useNavigate(), and React Router v6 only allows useNavigate() inside a router. So <BrowserRouter> is the outer wrapper.

In a Vite app, put <BrowserRouter> in src/main.tsx around <App />, matching Okta's official v6 sample where the router lives in the entry file (okta-react v6 sample).

import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
)

Now src/App.tsx. Because App renders inside <BrowserRouter>, it can call useNavigate() and define restoreOriginalUri, which the SDK invokes after a successful sign-in to return the user to where they started. <Security> renders inside the router, and the /login/callback route is handled by <LoginCallback>.

import { useNavigate, Routes, Route } from 'react-router-dom'
import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js'
import { Security, LoginCallback } from '@okta/okta-react'
import { oktaAuth } from './okta/config'
import { LoginError } from './okta/LoginError'

export default function App() {
  const navigate = useNavigate()

  const restoreOriginalUri = (_oktaAuth: OktaAuth, originalUri: string) => {
    navigate(toRelativeUrl(originalUri || '/', window.location.origin))
  }

  return (
    <Security oktaAuth={oktaAuth} restoreOriginalUri={restoreOriginalUri}>
      <Routes>
        <Route
          path="/login/callback"
          element={<LoginCallback errorComponent={LoginError} loadingElement={<p>Loading…</p>} />}
        />
      </Routes>
    </Security>
  )
}

Name this component App, not AppWithRouterAccess. The AppWithRouterAccess name comes from the README's React Router v5 example, which uses useHistory(); the live v6 sample uses useNavigate() and names it App (okta-react v6 sample).

The ordering matters. If <Security> or the useNavigate() call ends up outside <BrowserRouter>, React Router v6 throws useNavigate() may be used only in the context of a <Router> component. Keeping <BrowserRouter> as the outer wrapper in main.tsx avoids it.

Give <LoginCallback> an errorComponent so a failed sign-in renders a message instead of a blank page. Create src/okta/LoginError.tsx. The errorComponent type is React.ComponentType<{ error: Error }>, and <LoginCallback> passes it whatever Error the redirect rejected with (okta-react README).

export const LoginError = ({ error }: { error: Error }) => (
  <p role="alert">Sign-in error: {error.message}</p>
)

The most common failure here is a user who authenticates at Okta but isn't assigned to the app integration. Okta redirects back to /login/callback with error=access_denied and error_description=User is not assigned to the client application.. oktaAuth.handleLoginRedirect() rejects with that error, and <LoginCallback> renders your errorComponent with it. The fix is to assign the user or their group on the app's Assignments tab (Okta manage assignments).

A top-level React ErrorBoundary is a useful complementary net for unexpected render-time errors, but it does not catch this one. <LoginCallback> captures the callback rejection into its own state and hands it to errorComponent, so the boundary never sees it. The onAuthResume prop is also separate: it fires only on interaction_required, not on access_denied.

Step 6: Protect routes (React Router 6/7)

The SDK ships a SecureRoute component, but it supports react-router-dom v5 only. On v6 it throws an unsupported-version error (okta-react has done this since 6.4.3) (okta-react CHANGELOG). For v6 and v7 you write a small wrapper instead. Create src/okta/RequiredAuth.tsx, matching Okta's official sample component (which is named RequiredAuth, with the d) (okta-react v6 sample).

import { useEffect } from 'react'
import { useOktaAuth } from '@okta/okta-react'
import { toRelativeUrl } from '@okta/okta-auth-js'
import { Outlet } from 'react-router-dom'

export const RequiredAuth = () => {
  const { oktaAuth, authState } = useOktaAuth()

  useEffect(() => {
    if (!authState) return
    if (!authState.isAuthenticated) {
      const originalUri = toRelativeUrl(window.location.href, window.location.origin)
      oktaAuth.setOriginalUri(originalUri)
      oktaAuth.signInWithRedirect()
    }
  }, [oktaAuth, !!authState, authState?.isAuthenticated])

  if (!authState || !authState.isAuthenticated) {
    return <p>Loading…</p>
  }

  return <Outlet />
}

authState starts as null, so the null-check comes first (okta-react README). When the user isn't authenticated, the wrapper stashes the current path with setOriginalUri (so restoreOriginalUri can return them after sign-in) and kicks off the redirect. When they are authenticated, it renders <Outlet /> for the nested route.

On React Router v7 the wrapper is identical, but you import the router primitives from react-router instead of react-router-dom (React Router v6 to v7 upgrade). Keep this as v7's declarative/library mode (the client-side router in a Vite SPA). Don't reach for v7's framework mode, which adds SSR, loaders, and file-based routes and would change the architecture. As of mid-2026, @okta/okta-react ships no official integration for v7's data router (createBrowserRouter, loaders, actions) or framework mode, so this manual wrapper is the supported pattern. okta-react also still lists react-router-dom as a peer dependency, so a pure react-router v7 install logs a harmless peer-dependency warning that adding react-router-dom alongside react-router silences (okta-react issue #300).

For group-gated routes, add a second wrapper that runs inside an authenticated route and checks the groups claim from Step 1. Create src/okta/RequireGroup.tsx.

import { useOktaAuth } from '@okta/okta-react'
import { Outlet } from 'react-router-dom'

export const RequireGroup = ({ group }: { group: string }) => {
  const { authState } = useOktaAuth()

  if (!authState || !authState.isAuthenticated) {
    return <p>Loading…</p>
  }

  const groups = (authState.idToken?.claims?.groups as string[] | undefined) ?? []

  if (!groups.includes(group)) {
    return <p role="alert">You are not authorized to view this page.</p>
  }

  return <Outlet />
}

The as string[] cast is required. okta-auth-js types custom claims loosely (UserClaims has been a loose generic since v6.0.0, including in 7.8.1), so claims.groups comes through as a broad type that TypeScript won't let you call .includes() on without the cast (okta-auth-js README).

Compose the two wrappers so authentication runs first, then the group check. Nest the RequireGroup route inside the RequiredAuth route inside your <Routes>.

<Route element={<RequiredAuth />}>
  <Route element={<RequireGroup group="Admins" />}>
    <Route path="/admin" element={<AdminPage />} />
  </Route>
</Route>

Step 7: Read the authenticated user and claims

Read the user from useOktaAuth(). The authState object tells you whether the user is signed in (null-check it first), and oktaAuth.getUser() resolves their profile. Read identity from idToken.claims, which is decoded on both 7.8.1 and v8. Don't read from accessToken.claims, since v8 stops decoding access tokens by default (okta-auth-js README).

import { useEffect, useState } from 'react'
import { useOktaAuth } from '@okta/okta-react'

export const Profile = () => {
  const { authState, oktaAuth } = useOktaAuth()
  const [name, setName] = useState<string | undefined>()

  useEffect(() => {
    if (!authState?.isAuthenticated) return
    oktaAuth.getUser().then((info) => setName(info.name))
  }, [authState, oktaAuth])

  if (!authState?.isAuthenticated) {
    return <p>Not signed in.</p>
  }

  const email = authState.idToken?.claims.email
  const groups = (authState.idToken?.claims?.groups as string[] | undefined) ?? []

  return (
    <div>
      <p>
        Signed in as {name} ({email})
      </p>
      {groups.includes('Admins') && <a href="/admin">Admin dashboard</a>}
    </div>
  )
}

oktaAuth.getUser() calls Okta's userinfo endpoint, which is handy for profile fields. For groups, read authState.idToken?.claims?.groups (cast as string[] | undefined), not userinfo. The groups claim you added in Step 1 is on the ID token, and userinfo only carries groups if the claim is also configured on the access token with the right scope granted. The example above gates an "Admin dashboard" link on membership in Admins. For route-level enforcement, use the RequireGroup wrapper from Step 6.

Step 8: Token storage, renewal, and refresh tokens

By default @okta/okta-auth-js stores tokens in a fallback chain: localStorage, then sessionStorage, then cookie (okta-auth-js README). You can switch to in-memory storage and turn on active renewal in the constructor.

import { OktaAuth } from '@okta/okta-auth-js'

export const oktaAuth = new OktaAuth({
  issuer: import.meta.env.VITE_OKTA_ISSUER,
  clientId: import.meta.env.VITE_OKTA_CLIENT_ID,
  redirectUri: window.location.origin + '/login/callback',
  scopes: ['openid', 'profile', 'email', 'offline_access'],
  tokenManager: { storage: 'memory' },
  services: { autoRenew: true },
})

In-memory tokens live in a closure and don't survive a page reload, so the user re-runs the redirect (silently, if their Okta session is still live) on refresh. Active autoRenew needs the service running, so call await oktaAuth.start() once at startup, otherwise renewal is passive (renew-on-read) only (okta-auth-js README).

The offline_access scope from Step 4 is what makes renewal reliable. Okta's SPA default is refresh-token rotation (each refresh returns a new refresh token, with a 30-second grace window for retries), which is preferred over the old silent-iframe approach, since browser ITP and third-party-cookie blocking break iframe renewal (Okta refresh tokens). One knob to skip: expireEarlySeconds is DEV-only, and Okta resets it to 30 seconds outside localhost, so don't build a production flow that depends on a custom value (okta-auth-js README).

Note

No JavaScript-reachable storage is XSS-safe. If an attacker runs script in your page, it can read anything your app can read, including tokens in memory (OWASP HTML5 Security). In-memory is the lowest-risk option for an access token because it isn't persisted, but the strongest pattern keeps the refresh token in an HttpOnly cookie issued by a backend-for-frontend rather than anywhere JS can touch it (Auth0 token storage).

Step 9: Log out (and end the Okta session)

Sign out with oktaAuth.signOut(). It revokes the app's tokens, ends the user's Okta session, and redirects to the Sign-out redirect URI you set in Step 1 (okta-auth-js README).

import { useOktaAuth } from '@okta/okta-react'

export const SignOutButton = () => {
  const { oktaAuth } = useOktaAuth()
  return <button onClick={() => oktaAuth.signOut()}>Log out</button>
}

There's a real distinction between clearing local tokens and ending the Okta session. Just removing tokens from storage logs the user out of your app, but their Okta session stays live, so the next sign-in attempt silently re-authenticates with no prompt. signOut() ends the Okta session too, which is usually what users expect from a logout button. If you only want to drop the app's tokens without touching the Okta session, use oktaAuth.tokenManager.clear() instead (okta-auth-js README).

Note

By default, oktaAuth.signOut() ends the Okta session with a top-level browser redirect to Okta's OIDC logout endpoint (/oauth2/v1/logout, passing your ID token as id_token_hint). Because that's a first-party, top-level navigation, it works even when your app and your Okta org are on different domains, and it is not affected by Intelligent Tracking Prevention (ITP) or third-party-cookie blocking. The cookie caveat applies only to the XHR fallback Okta uses when no ID token is available (closeSession(), which calls the Session API in the background) and to silent iframe operations like token renewal — keep a valid ID token in the token manager and signOut() stays on the redirect path (Okta: sign users out of React, Okta: OIDC Single Logout).

Step 10: Test the OIDC flow end to end

Run through the full flow to confirm everything is wired up:

  1. Start the dev server with npm run dev and open http://localhost:5173.
  2. Visit a protected route. RequiredAuth should redirect you to Okta's hosted sign-in.
  3. Sign in as a user who is assigned to the app. Okta redirects back to /login/callback, and restoreOriginalUri returns you to the route you started on.
  4. Open DevTools and confirm the user renders (name, email) and, if you set up RBAC, that group-gated UI shows for members and hides for non-members.
  5. Click your log out button and confirm you land on http://localhost:5173 and a protected route bounces you to Okta again.

Two quick checks if something breaks. A blank page or redirect loop usually means the Sign-in redirect URI in Okta doesn't exactly match redirectUri in your config. A failure right after the redirect, with a CORS message in the console (Okta error E0000021), means the Trusted Origin from Step 2 is missing or wrong. Deeper symptoms are covered in the Troubleshooting section.

Alternative: the Okta Sign-In Widget (embedded vs redirect)

Everything above uses redirect sign-in: the SDK sends the user to Okta's hosted page and back. That's Okta's recommended model. It's the most secure, Okta maintains the UI, and single sign-on across apps works naturally (Okta redirect vs embedded).

The embedded Okta Sign-In Widget renders the sign-in form inside your own app, so the user never leaves your domain. It's still OIDC under the hood. The trade-off is that you own more of the security maintenance, and there's a hard constraint to know about first.

Caution

The third generation of the Okta Sign-In Widget doesn't support embedded (self-hosted) authentication. Self-hosting requires the second-generation widget, and a self-hosted widget on a domain different from your Okta org is fragile under third-party-cookie deprecation, since the Okta session cookie looks like a third-party cookie to your own page (Okta upgrade the Sign-In Widget, Okta self-hosted widget and 3p cookies). Use redirect sign-in unless you have a specific reason to embed.

If you do embed, mount the widget in a useEffect, exchange the result with the same oktaAuth client, and clean it up on unmount (Okta embedded widget React).

import { useEffect, useRef } from 'react'
import OktaSignIn from '@okta/okta-signin-widget'
import { oktaAuth } from './okta/config'

export const EmbeddedSignIn = () => {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const widget = new OktaSignIn({
      issuer: import.meta.env.VITE_OKTA_ISSUER,
      clientId: import.meta.env.VITE_OKTA_CLIENT_ID,
      redirectUri: window.location.origin + '/login/callback',
      scopes: ['openid', 'profile', 'email', 'offline_access'],
    })

    widget
      .showSignInToGetTokens({ el: ref.current! })
      .then((tokens) => oktaAuth.handleLoginRedirect(tokens))

    return () => widget.remove()
  }, [])

  return <div ref={ref} />
}

The widget.remove() cleanup matters because React 18+ StrictMode double-invokes effects in development, which can otherwise leave a stale widget mounted (React StrictMode #25614). The widget version as of mid-2026 is the 7.46.x line (okta-signin-widget npm).

Walkthrough B: Add Okta SAML to a React app (with a backend)

Use this walkthrough only when SAML is required. If you have the choice, OIDC (Walkthrough A) is simpler for a React SPA. Reach for SAML when an enterprise customer or a security/compliance contract mandates it, or when the Okta org you're integrating with only exposes a SAML app.

The reason SAML changes the architecture is the assertion itself.

Why SAML needs a backend service provider

Okta returns a signed XML document (the assertion), and that document has to be validated on a server. Validation means three things, all server-side: verify the XML-DSig signature against the IdP's certificate, confirm the audience restriction matches your service provider exactly, and check the time conditions (NotBefore / NotOnOrAfter). The service provider (SP) also holds a private key for signing logout requests, and a private key can't live in browser JavaScript where any user can read it.

So a pure SPA can't be the SP. The browser is a relay: it carries the SAML messages, but the validation happens behind it. Your React app delegates authentication to a small backend SP, the backend validates the assertion and issues its own session, and the SPA reads that session.

Here's an abbreviated assertion so "signed XML, validated server-side" is concrete. A real one is larger, but the parts that matter for validation are the subject, the conditions, the attributes, and the signature.

<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_a1b2..." IssueInstant="2026-06-03T17:00:00Z">
  <saml:Issuer>http://www.okta.com/exk1abc...</saml:Issuer>
  <saml:Subject>
    <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">ada@example.com</saml:NameID>
    <!-- SubjectConfirmation with Recipient + NotOnOrAfter omitted -->
  </saml:Subject>
  <saml:Conditions NotBefore="2026-06-03T16:59:00Z" NotOnOrAfter="2026-06-03T17:05:00Z">
    <saml:AudienceRestriction>
      <saml:Audience>https://your-sp.example.com/metadata</saml:Audience>
    </saml:AudienceRestriction>
  </saml:Conditions>
  <saml:AttributeStatement>
    <saml:Attribute Name="email"><saml:AttributeValue>ada@example.com</saml:AttributeValue></saml:Attribute>
    <!-- firstName, lastName, groups attributes omitted -->
  </saml:AttributeStatement>
  <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><!-- SignedInfo + SignatureValue + KeyInfo omitted --></ds:Signature>
</saml:Assertion>

A <ds:Signature> over that XML is the whole security model. If your code reads identity from any element the signature doesn't cover, you've built an auth bypass. That's why the library you pick matters so much (more on the CVEs in Step 7).

Architecture: React SPA + backend SP + Okta IdP

This is the SP-initiated flow, end to end:

  1. The SPA sends the user to the backend's /login route.
  2. The backend builds a SAML AuthnRequest, DEFLATE-compresses it, base64-encodes it (standard base64, not base64url), URL-encodes it, and 302-redirects the browser to Okta with that SAMLRequest.
  3. The user authenticates at Okta.
  4. Okta HTTP-POSTs a signed SAMLResponse to your ACS endpoint, /login/callback. POST is the binding SAML uses for the response, by design.
  5. The backend validates the assertion, then issues a stateless signed JWT in an HttpOnly cookie (the backend-for-frontend, or BFF, pattern).
  6. The backend 302-redirects the browser back to the SPA (a relative /, or a path carried in RelayState).
  7. The SPA calls /me, sees the session, and renders.

Recommended dev setup: same origin. Serve the SPA and the SP under one origin in development using Vite's server.proxy, forwarding /login, /login/callback, and /me to the SP. That keeps the session cookie first-party (HttpOnly; SameSite=Lax, no dev TLS needed), makes the post-ACS redirect a clean relative res.redirect('/'), and mirrors a real single-domain production deploy. The proxy config is in Step 5.

Note

Cross-origin is the harder path, so don't lead with it. If the SPA and SP stay on different origins, the session cookie reaches /me only with SameSite=None; Secure (which forces HTTPS even in dev), CORS Access-Control-Allow-Credentials: true with an explicit (non-*) origin, and a client fetch(..., { credentials: 'include' }). All of that is then subject to third-party-cookie blocking. The same-origin proxy sidesteps every one of these problems.

Step 1: Create a SAML app integration in Okta

In the Admin Console, go to ApplicationsCreate App Integration, pick SAML 2.0, and click Next. Name the app, then fill in the SAML settings:

  • Single sign-on URL: this is the ACS, where Okta POSTs the response. Leave "Use this for Recipient URL and Destination URL" checked. This value must match node-saml's callbackUrl exactly. With the recommended Vite proxy it's your single dev origin's /login/callback (for example http://localhost:5173/login/callback); on the cross-origin path it's the backend's own URL (for example https://localhost:5000/login/callback).
  • Audience URI (SP Entity ID): your SP's identifier, for example https://your-sp.example.com/metadata. node-saml validates the assertion's <saml:Audience> against this.
  • Name ID format: EmailAddress.
  • Application username: Email (so NameID carries the email).

Under Attribute Statements, set Name format to Basic and map short names that line up with node-saml's profile reads:

NameName formatValue
emailBasicuser.email
firstNameBasicuser.firstName
lastNameBasicuser.lastName

If you want role-based access control, add a Group Attribute Statements block. This is a separate section from Attribute Statements, lower on the same page.

  • Name: groups
  • Name format: Unspecified (Basic works too; node-saml keys off the Name attribute only, so the format doesn't change the JS key)
  • Filter: Matches regex, value .*

The .* filter sends every group the user is assigned to. Scope it down in production (a prefix like app- or an exact-match list) so you don't ship oversized assertions and bloat the session cookie.

Finally, open Show Advanced Settings and confirm the signing defaults. New Okta SAML apps default to these, but verify them:

  • Response: Signed
  • Assertion Signature: Signed
  • Signature Algorithm: RSA-SHA256
  • Digest Algorithm: SHA256

Important

The Single sign-on URL, the Audience URI, and the signature algorithm are the three values that cause the most first-run failures. If the ACS URL or the audience don't match your node-saml config character-for-character, validation throws. SHA-256 is correct; SHA-1 is legacy and node-saml will reject a SHA-1 assertion once you've set signatureAlgorithm: 'sha256' (Okta: Add a SAML 2.0 app integration, Okta: Upgrade SAML apps to SHA256).

Step 2: Assign users and retrieve the IdP metadata

Open the Assignments tab and assign the people or groups who should be able to sign in (AssignAssign to People or Assign to Groups). A user who isn't assigned can't complete the flow; Okta stops them with its own error and never POSTs an assertion to your ACS (more on that in Step 4).

Then open the Sign On tab and click View SAML setup instructions (or View IdP metadata). You need either of these:

  • Metadata URL (preferred): a single URL your SP can fetch. It auto-updates when Okta rotates the signing certificate, so you don't have to redeploy a pasted cert. Okta keeps one SP signing certificate at a time, so consuming the metadata URL is the cleaner way to survive a rotation (Okta: Rotate the SAML signing certificate).
  • IdP Single Sign-On URL + Issuer + X.509 certificate: the three discrete values, if you'd rather pin the cert in config.

The walkthrough below uses the discrete values (entryPoint, issuer, idpCert) because they map directly to node-saml options and make the config readable. Both approaches are valid (Okta: Understanding SAML, Okta: Download IdP XML metadata).

Step 3: Build the backend service provider

Install Express, the SAML library, and the session pieces. Pin node-saml to at least 5.1.0, which is the minimum-safe version (it fixes the library's own criticals; see Step 7).

npm install express @node-saml/node-saml@">=5.1.0" jsonwebtoken cookie-parser

Now configure the SAML instance. The defaults in node-saml are tuned for backward compatibility, not security, so several options have to be set explicitly. The cert option is idpCert (it was renamed from cert in v5.0.0, and passing cert now throws). The mandatory options are idpCert, issuer, and callbackUrl (node-saml README, node-saml v5.0.0 release).

import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'

export const saml = new SAML({
  // From Okta's Sign On tab
  entryPoint: process.env.OKTA_SAML_ENTRYPOINT!, // IdP Single Sign-On URL
  issuer: process.env.SP_ENTITY_ID!, // your Audience URI / SP Entity ID
  idpCert: process.env.OKTA_SAML_CERT!, // Okta's X.509 cert (PEM)
  callbackUrl: process.env.SP_ACS_URL!, // must equal Okta's Single sign-on URL
  identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',

  // On by default, but be explicit about what you require
  wantAssertionsSigned: true,
  wantAuthnResponseSigned: true,

  // Defaults are 'sha1' for both. Set sha256 to match Okta and reject SHA-1.
  signatureAlgorithm: 'sha256',
  digestAlgorithm: 'sha256',

  // Default is ValidateInResponseTo.never. always = replay protection.
  validateInResponseTo: ValidateInResponseTo.always,

  // Default is 0. A small window absorbs minor clock drift.
  acceptedClockSkewMs: 5000,
})

A few of those lines are doing real work. signatureAlgorithm and digestAlgorithm default to 'sha1', so without these two lines you'd accept SHA-1 signatures. validateInResponseTo defaults to ValidateInResponseTo.never; setting it to always makes node-saml track the InResponseTo it expects and reject a response that doesn't correlate to a request it issued, which is your replay defense for the SP-initiated flow (node-saml src/types.ts).

With the SP configured, the /login route builds the AuthnRequest and redirects the browser to Okta. getAuthorizeUrlAsync returns the full redirect URL (DEFLATE, base64, URL-encode already handled for you).

import express from 'express'

const app = express()

app.get('/login', async (req, res) => {
  const relayState = '' // optionally a local path to return to after sign-in
  const url = await saml.getAuthorizeUrlAsync(relayState, undefined, {})
  res.redirect(url)
})

Step 4: Validate the assertion, handle failures, and create a session

The ACS route is where the signed XML gets validated and the session gets minted. validatePostResponseAsync throws on every validation failure (bad signature, audience mismatch, expiry, bad InResponseTo), so the whole body goes in a try/catch. The catch logs server-side and returns a generic failure. Never echo err.message to the client; the messages name which check failed and that's a gift to an attacker (OWASP Error Handling Cheat Sheet).

You also need express.urlencoded to parse the POSTed form, and cookie-parser to read the session cookie later.

import jwt from 'jsonwebtoken'
import cookieParser from 'cookie-parser'
import type { Profile } from '@node-saml/node-saml'

app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())

const SESSION_SECRET = process.env.SESSION_SECRET!

app.post('/login/callback', async (req, res) => {
  try {
    const { profile } = await saml.validatePostResponseAsync(req.body)
    if (!profile) {
      return res.status(401).redirect('/login?error=auth_failed')
    }

    // SAML attributes are top-level keys on profile (an index signature),
    // not nested under an `attributes` object.
    const email = profile.nameID

    // node-saml returns a scalar for one group and an array for many,
    // so normalize before anything calls .includes() or .map().
    const raw = profile.groups
    const groups: string[] = Array.isArray(raw)
      ? (raw as string[])
      : raw != null
        ? [String(raw)]
        : []

    const token = jwt.sign(
      {
        sub: email,
        email,
        groups,
        // Persist these for SAML Single Logout (Step 6).
        nameID: profile.nameID,
        nameIDFormat: profile.nameIDFormat,
        sessionIndex: profile.sessionIndex,
      },
      SESSION_SECRET,
      { expiresIn: '1h' },
    )

    res.cookie('session', token, {
      httpOnly: true,
      sameSite: 'lax', // same-origin proxy. Cross-origin needs 'none' + secure.
      secure: process.env.NODE_ENV === 'production',
      maxAge: 60 * 60 * 1000,
    })

    // Bring the browser back from the ACS API URL to the SPA.
    const relay = typeof req.body.RelayState === 'string' ? req.body.RelayState : '/'
    const safePath = relay.startsWith('/') && !relay.startsWith('//') ? relay : '/'
    res.redirect(safePath)
  } catch (err) {
    console.error('SAML validation failed:', err)
    res.status(401).redirect('/login?error=auth_failed')
  }
})

Three details in that handler earn their keep.

The group normalization is mandatory, not defensive style. node-saml stores a single <AttributeValue> as a scalar string and multiple values as an array (verified in processValidlySignedAssertionAsync). Code that calls profile.groups.includes('Admins') directly will silently iterate the characters of a string for any single-group user. The Array.isArray guard also narrows the type, since arbitrary attributes come back typed unknown through the Profile index signature (node-saml src/saml.ts). Group values are the Okta group display names, so includes('Admins') matches the name you see in Okta, and renaming the group there silently breaks the check.

The final res.redirect is easy to forget and breaks the demo when it's missing: without it, the browser is stranded on the ACS API URL after sign-in instead of landing back in the SPA. The safePath check is an open-redirect guard, because RelayState is not integrity-protected and you should never redirect to a value the IdP round-tripped without validating it as a local path first.

Note

The "user authenticated but not assigned to the app" case never reaches this handler. Okta blocks an unassigned user with error E0000004 and POSTs no assertion, so your ACS route doesn't run for it. Fix it in Okta's Assignments tab, not in code.

Step 5: Connect the React SPA to the backend session

The backend exposes /me so the SPA can read the session, plus two middleware: requireAuth verifies the cookie, and requireGroup gates a route by Okta group.

import type { Request, Response, NextFunction } from 'express'

interface SessionUser {
  sub: string
  email: string
  groups: string[]
}

function requireAuth(req: Request, res: Response, next: NextFunction) {
  const token = req.cookies?.session
  if (!token) return res.status(401).json({ error: 'unauthenticated' })
  try {
    req.user = jwt.verify(token, SESSION_SECRET) as SessionUser
    next()
  } catch {
    return res.status(401).json({ error: 'unauthenticated' })
  }
}

function requireGroup(name: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const user = req.user as SessionUser | undefined
    if (!user?.groups?.includes(name)) {
      return res.status(403).json({ error: 'forbidden' })
    }
    next()
  }
}

app.get('/me', requireAuth, (req, res) => {
  const { email, groups } = req.user as SessionUser
  res.json({ email, groups })
})

// Example: a server route only Admins can hit.
app.get('/admin/report', requireAuth, requireGroup('Admins'), (req, res) => {
  res.json({ ok: true })
})

On the SPA side, sign-in is a full-page navigation to /login (a SAML flow can't be an XHR; the browser has to follow redirects to Okta and back). On load, the app fetches /me to hydrate auth state, then protects routes and gates UI by groups.

import { useEffect, useState } from 'react'

interface Me {
  email: string
  groups: string[]
}

export default function App() {
  const [me, setMe] = useState<Me | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch('/me')
      .then((res) => (res.ok ? res.json() : null))
      .then((data: Me | null) => setMe(data))
      .finally(() => setLoading(false))
  }, [])

  if (loading) return <p>Loading…</p>

  if (!me) {
    return <button onClick={() => (window.location.href = '/login')}>Sign in with SSO</button>
  }

  return (
    <main>
      <p>Signed in as {me.email}</p>
      {me.groups.includes('Admins') && <a href="/admin">Admin</a>}
      <button onClick={() => (window.location.href = '/logout')}>Log out</button>
    </main>
  )
}

For the same-origin dev setup, point Vite's proxy at the backend so /login, /login/callback, and /me all forward to the SP. The /login prefix covers /login/callback too. The browser only ever talks to the Vite origin, so the session cookie is first-party.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/login': { target: 'http://localhost:5000', changeOrigin: true },
      '/logout': { target: 'http://localhost:5000', changeOrigin: true },
      '/me': { target: 'http://localhost:5000', changeOrigin: true },
    },
  },
})

With the proxy in place, the SPA's fetch('/me') is a same-origin request and the cookie rides along on the default credentials: 'same-origin'. If you instead run the SPA and SP on different origins, you'd call absolute backend URLs with fetch(..., { credentials: 'include' }) and add the CORS headers from the architecture note above (Vite: server options, MDN: Set-Cookie SameSite).

Step 6: Log out (local session, and optional SAML Single Logout)

Local logout is one route: clear the session cookie and send the user home.

app.get('/logout', (req, res) => {
  res.clearCookie('session')
  res.redirect('/')
})

This ends the session at your app only. The user's Okta session is still live, so clicking "Sign in" again silently re-authenticates with no prompt. That surprises people, so it's worth saying plainly: local logout doesn't touch Okta. Ending the Okta session too is what SAML Single Logout does.

Optional: SAML Single Logout (SLO)

SLO is a separate, best-effort profile in SAML, not part of the core sign-in flow (OASIS states plainly that "the result of the logout process cannot be guaranteed"). Plenty of teams skip it. It also requires generating and managing an SP signing key, so weigh it against the value (OASIS SAML 2.0 Technical Overview §5.3).

On the Okta side, open the SAML app, go to Show Advanced Settings, and:

  • Check "Allow application to initiate Single Logout" (that exact label).
  • Set "Single Logout URL" to your SP's logoutCallbackUrl.
  • Set "SP Issuer" to your issuer.
  • Upload your SP's "Signature Certificate" (the public cert matching your signing key).

Okta requires the LogoutRequest to be signed, which is why the SP needs a signing key and Okta needs its public half.

On the node-saml side, add three options to the config from Step 3: privateKey (your SP signing key in PEM), logoutUrl (Okta's SLO endpoint), and the same signatureAlgorithm: 'sha256'. logoutUrl defaults to entryPoint, so set it explicitly even if they're the same URL.

export const samlWithSlo = new SAML({
  // ...all of the Step 3 options...
  entryPoint: process.env.OKTA_SAML_ENTRYPOINT!,
  issuer: process.env.SP_ENTITY_ID!,
  idpCert: process.env.OKTA_SAML_CERT!,
  callbackUrl: process.env.SP_ACS_URL!,
  signatureAlgorithm: 'sha256',
  digestAlgorithm: 'sha256',
  validateInResponseTo: ValidateInResponseTo.always,

  // SLO additions
  privateKey: process.env.SP_PRIVATE_KEY!, // PEM, matches the cert uploaded to Okta
  logoutUrl: process.env.OKTA_SAML_SLO_URL!, // defaults to entryPoint; set it
  logoutCallbackUrl: process.env.SP_SLO_CALLBACK_URL!,
})

The logout routes build a signed LogoutRequest and then validate Okta's LogoutResponse. getLogoutUrlAsync needs the original login profile to construct the request, so this is where the persisted nameID / nameIDFormat / sessionIndex from Step 4 come back.

app.get('/logout/sso', requireAuth, async (req, res) => {
  try {
    const { nameID, nameIDFormat, sessionIndex } = req.user as SessionUser & {
      nameID: string
      nameIDFormat: string
      sessionIndex?: string
    }
    const user = { nameID, nameIDFormat, sessionIndex } as Profile
    const url = await samlWithSlo.getLogoutUrlAsync(user, '', {})
    res.clearCookie('session')
    res.redirect(url)
  } catch (err) {
    console.error('SLO request failed:', err)
    res.status(500).redirect('/')
  }
})

app.get('/logout/callback', async (req, res) => {
  try {
    await samlWithSlo.validateRedirectAsync(req.query, req.url.split('?')[1] ?? '')
    res.clearCookie('session')
    res.redirect('/')
  } catch (err) {
    console.error('SLO response validation failed:', err)
    res.status(401).redirect('/')
  }
})

Note

The hidden cost of SLO is real. getLogoutUrlAsync needs the login profile's nameID, nameIDFormat, and sessionIndex, so you have to persist them (the Step 4 JWT does this). Add the SP signing-key generation and rotation, the extra Okta config, and the per-session state, and SLO is another line item you own forever. It's a common reason teams move to a managed platform that handles logout fan-out for them (node-saml src/saml.ts).

Step 7: SAML security essentials

Self-hosting an SP means you own the security model. The OWASP SAML Security Cheat Sheet is the checklist; the short version is:

  • Validate the signature against a pinned certificate. Use the IdP cert you configured (idpCert), and never trust an inline KeyInfo in the response. node-saml does this by default with idpCert set; the failure mode is loading a cert dynamically from the document.
  • Enforce an exact audience restriction. The assertion's <saml:Audience> must equal your SP Entity ID character-for-character. A loose match is a real vulnerability.
  • Defend against replay. validateInResponseTo: ValidateInResponseTo.always plus a tight acceptedClockSkewMs (single-digit seconds) closes the replay and clock-skew windows.
  • Parse XML safely. XML Signature Wrapping (XSW) and XML External Entity (XXE) attacks both target SAML parsers. Keep DTDs disabled, which node-saml's parser does, and keep the library patched so XSW fixes land.
  • Rotate certificates deliberately. Okta keeps one SP signing cert at a time, so consuming the metadata URL (Step 2) is the lowest-friction way to survive a rotation.
  • Keep dependencies patched. This is the one that bites, because the criticals are recent and severe.

(OWASP SAML Security Cheat Sheet, OWASP XXE Prevention Cheat Sheet)

The patching point isn't abstract. As of mid-2026, here are the SAML library criticals that shaped this walkthrough's version pins:

CVELibraryCVSSFixed in
CVE-2025-54419@node-saml/node-saml10.0 (v3.1, NVD)5.1.0
CVE-2025-54369@node-saml/node-saml9.3 (v4.0, GHSA)5.1.0
CVE-2025-29775xml-crypto (via node-saml)9.3 (v4.0, GHSA)6.0.1 / 3.2.1 / 2.1.6
CVE-2025-47949samlify9.9 (v4.0, GHSA)2.10.0
CVE-2026-46490samlify8.7 (v4.0, GHSA)2.13.0

The first two are node-saml's own. CVE-2025-54419 is a signature-verification flaw scored CVSS 10.0, and CVE-2025-54369 is an authentication bypass, both affecting versions at or below 5.0.1. That's why >=5.1.0 is the minimum-safe pin, not just the latest tag. CVE-2025-29775 (part of the "SAMLStorm" set) is in xml-crypto and is exploitable through node-saml; node-saml 5.1.0 (July 21, 2025) already pins xml-crypto ^6.1.2 (npm registry: @node-saml/node-saml), which is patched. The last two rows are about samlify, a different SAML library, included so that if you evaluate alternatives you know its history: CVE-2025-47949 (a signature-wrapping critical) was fixed in 2.10.0, but samlify then disclosed a second high-severity flaw — an assertion-injection privilege escalation (CVE-2026-46490 / GHSA-34r5-q4jw-r36m, disclosed May 2026, CVSS 8.7) affecting versions before 2.13.0 — so samlify's current minimum-safe floor is >=2.13.0 (npm latest 2.13.1), not 2.10.0 (node-saml advisory GHSA-4mxg-3p6v-xgq3, WorkOS: SAMLStorm).

Caution

These are your responsibility when you self-host the SP. A managed auth platform absorbs SAML signature validation, XSW/XXE hardening, and the dependency-patch treadmill on its side, which is one of the strongest practical reasons to broker Okta through a managed platform instead of running your own SP (Option C).

Step 8: Test the SAML flow end to end

Run the verification in order. This is the SP-initiated flow, so it parallels the OIDC test in Walkthrough A.

  1. Start both dev servers (Vite and the Express SP). Load the Vite origin and confirm the proxy forwards: hitting /me should reach the backend, not 404 on the SPA.
  2. Click "Sign in with SSO". The /login route returns a 302 to Okta carrying a SAMLRequest.
  3. Authenticate at Okta.
  4. Okta HTTP-POSTs the SAMLResponse to your ACS (/login/callback).
  5. The backend validates the assertion, sets the session cookie, and 302-redirects to the SPA.
  6. /me returns 200 with the user.
  7. A protected route renders, and the session survives a page reload.

What to inspect at each hop:

  • Network tab: the 302s, the decoded SAMLRequest and SAMLResponse, the Set-Cookie header on the callback response, and the Cookie header the browser sends on /me.
  • DevTools → Application → Cookies: confirm the session cookie exists on your dev origin.
  • SAML-tracer (a Firefox/Chrome extension): decode the AuthnRequest and SAMLResponse to read Issuer, Audience, Recipient, and Conditions directly.

The three most common first failures, in order: an Audience or Recipient (ACS) mismatch (Okta's Audience URI or Single sign-on URL doesn't match your node-saml audience / callbackUrl), the session cookie not reaching the SPA (the cross-origin trap; switch to the same-origin proxy), and clock skew (NotBefore / NotOnOrAfter outside your acceptedClockSkewMs) (ScaleKit: SAML debugging, WorkOS: common SAML errors).

Option C: Broker Okta through a managed auth platform

A managed auth platform is the better path the moment SAML, multiple customer identity providers, per-tenant routing, or SCIM enters the picture. The platform acts as the service provider, your customers' Okta orgs become "enterprise connections" you configure in a dashboard, and your React app talks only to the platform's SDK. You write no backend SP, validate no XML, and rotate no certificates.

Walkthroughs A and B both add your own Okta org as the app's IdP. That's the single-tenant case. Multi-tenant enterprise SSO, where each B2B customer brings their own Okta or IdP, is a different problem, and it's the one managed platforms are built to solve.

When direct integration stops being worth it

Hand-rolling OIDC against your own Okta org is fine, and Walkthrough A is the recommended path for that. The calculus changes when any of these become true:

  • You need both SAML and OIDC, because different customers mandate different protocols.
  • You have multiple customer IdPs, not just your own org.
  • You need per-tenant routing: customer A's users go to customer A's Okta, customer B's to customer B's.
  • You need SCIM provisioning, so deactivating a user in the IdP deactivates them in your app.
  • You'd rather not operate a service provider at all: standing up an SP, patching the SAML dependency CVEs (the node-saml, xml-crypto, and samlify criticals from Walkthrough B, Step 7), and rotating signing certificates on a schedule.

Any one of those is a reason to look at a managed platform. Two or more, and self-hosting an SP per customer rarely pays for itself.

How a managed platform changes the React integration

The architecture flips. With a self-hosted SP (Walkthrough B), your backend is the service provider: it holds the SP private key, validates the signed assertion, and mints the session. With a managed platform, the platform is the service provider. It owns the SP duties, and Okta is registered as an enterprise connection inside it.

That collapses the React side back to the simplicity of the OIDC path. Your SPA uses the platform's SDK to start sign-in and read session state, the same shape as a hosted OIDC flow. No backend SP, no XML handling, no certificate rotation, regardless of whether the underlying customer connection is SAML or OIDC. The platform absorbs the protocol difference so your app doesn't see it.

The options

Clerk, Auth0, and WorkOS all broker Okta as an enterprise connection. They're aimed at different shapes of problem, so compare them on protocol coverage, the multi-tenant model, the pricing model, and developer experience rather than a single score. All pricing below is as of mid-2026 and moves often, so confirm current numbers on each vendor's pricing page.

ClerkAuth0WorkOS
Okta SAMLNamed "Okta Workforce" provider (docs)Okta as a SAML IdP (docs)Dedicated "Okta SAML" connection (docs)
Okta OIDCCustom OIDC Provider path (docs)Named "Okta Workforce" connection (OIDC) (docs)OIDC connection supported
Multi-tenant modelConnection scoped to an Organization; email domain is the routing key (docs)Connections enabled per Organization (docs)Connection per customer environment
SCIM provisioningDirectory Sync, included with the connection (docs)Supported (free plan and up, since Feb 2026)Directory Sync, priced per connection
Pricing modelPer-connection, metered tiers; 1 included on paid plans1 free connection, then per-connection add-onPer-connection, volume tiers
Bundled user managementYes, full auth and user managementYesAuthKit, free to 1M MAU

The pricing models differ enough that "cheapest" depends entirely on your connection count and user volume:

  • Clerk. Pro ($25/mo) and Business ($300/mo) each include 1 enterprise connection and 50,000 monthly retained users (MRU; Clerk meters retained users rather than MAU). Additional connections are metered: $75/mo each for connections 2 to 15, $60/mo for 16 to 100, $30/mo for 101 to 500, and $15/mo beyond 500 (Clerk pricing). Development instances get enterprise connections for free, capped at 25 (Clerk: enterprise connections overview).
  • Auth0 (part of Okta since 2021). Since its February 2026 B2B plan update, Auth0's free plan includes one enterprise connection, Self-Service SSO, and SCIM provisioning, so a basic Okta integration can start on the free plan (Auth0: B2B plans upgraded). Beyond that, B2B Essentials is $150/mo (3 enterprise connections, 500 MAU) and B2B Professional is $800/mo (5 connections, 500 MAU), with additional connections $100/mo each, capped at 30 total (Auth0 pricing).
  • WorkOS. SSO connections are $125/mo each for 1 to 15, $100/mo for 16 to 30, and $80/mo for 31 to 50. AuthKit, its user-management layer, is free up to 1 million MAU (WorkOS pricing).

At a low connection count and moderate usage, Clerk's included connection and 50,000 retained users tend to come in lower; WorkOS gets competitive when you have many connections and want AuthKit's free MAU tier; Auth0 fits teams already standardized on Okta's ecosystem, since Auth0 has been part of Okta since the roughly $6.5 billion acquisition closed in May 2021 (Auth0: Okta acquisition close). Run your own numbers; the break-even shifts with both inputs.

Where Clerk fits

Clerk is the platform we build, so here's the honest scope of what it does for Okta, with every claim linked.

Clerk supports Okta SAML through a named Okta Workforce provider: you select it, Clerk gives you an ACS URL and an Audience URI (SP Entity ID), and your Okta admin pastes them into a SAML app (Clerk: Okta Workforce SAML connection). Clerk supports Okta OIDC through its Custom OIDC Provider path: you supply Okta's discovery endpoint, client ID, and secret (Clerk: custom OIDC provider). Clerk performs the service-provider duties on its side, so your React app never validates an assertion.

Note

Clerk has a named Okta provider for SAML ("Okta Workforce"), but not for OIDC. Okta OIDC goes through the generic Custom OIDC Provider path (discovery endpoint + client ID + client secret), not a dedicated "Okta" OIDC button. If you're looking for a named Okta OIDC provider in the dashboard, that's why you won't find one.

For multi-tenant routing, Clerk scopes each enterprise connection to an Organization and uses the email domain as the routing key, so a user at acme.com lands on Acme's Okta and a user at globex.com lands on Globex's (Clerk: per-tenant enterprise SSO). For provisioning, Clerk offers SCIM through Directory Sync, which reached general availability on April 16, 2026, with group-to-role mapping and custom attribute mapping following on May 21, 2026 (Clerk: Directory Sync, Clerk changelog: Directory Sync GA). The net result: a Vite React SPA can add enterprise Okta SSO using Clerk's React SDK (@clerk/react) with no backend service provider.

Clerk doesn't call Okta on every request, which differs from the per-request model people often assume. After Okta authenticates the user, Clerk mints its own short-lived session JWT (roughly 60 seconds), and per-request authorization is a local signature verification. The SDK refreshes that token in the background, roughly every 50 seconds (Clerk: how Clerk works). Clerk federates the initial sign-in and then runs its own session.

The caveats, stated plainly so you can plan around them:

  • Enterprise connections require a paid plan in production. Development instances get them free but capped at 25 connections (Clerk: enterprise connections overview).
  • EASIE does not cover Okta. Clerk's EASIE flow supports Google Workspace and Microsoft Entra ID only, so it is not an Okta path. Okta goes through the SAML or Custom-OIDC enterprise connections above, not EASIE.
  • Okta OIDC is the Custom OIDC Provider path, not a named provider (see the note above).
  • Per-tenant org linking and custom roles use an add-on. A standalone enterprise connection is included on the paid plans above, but linking connections to Clerk Organizations (the per-tenant routing described earlier) and custom roles or role-sets require Clerk's B2B Authentication add-on ($100/mo, $85/mo billed annually). SCIM provisioning stays included with the connection; only group-to-role mapping reaches into the add-on (Clerk pricing).

OIDC vs SAML vs managed platform: side-by-side

Three paths, one decision. OIDC direct is the lightest if you control the IdP and only speak OIDC. SAML direct is the heaviest, because you build and secure a service provider. A managed platform is light to integrate and the only one of the three that scales cleanly to many customer IdPs.

Capability and effort matrix

CapabilityOIDC (direct)SAML (direct)Managed platform
Runs in the browser only?
Backend required?
Refresh / renewal modelRefresh-token rotation in the SDKBackend session cookie you issue and expirePlatform-managed session
Authorization (group/role claims)groups claim in the ID token (idToken.claims.groups)Group attribute statement; backend reads/normalizes profile.groupsPlatform roles/orgs plus claims
Multi-tenant (many customer IdPs)?Per-tenant plumbing you build
SCIM provisioning?
Certificate / CVE maintenance burdenLow (SDK verifies tokens against the IdP's JWKS)High (you patch SAML libraries and rotate certs)Platform owns it
Time to first production loginFastestSlowestFast once configured
Best-fit triggerYou control the IdP and speak OIDCA customer or contract mandates SAMLMultiple IdPs, both protocols, or SCIM

A note on the multi-tenant cells: OIDC and SAML direct can technically serve more than one IdP, but only if you build the per-tenant routing, store per-tenant config, and manage each connection yourself. That work is exactly what the managed column hands off, which is why it gets the clean checkmark and the direct paths don't.

Time to first production login

OIDC direct is fastest: configure the Okta app, drop in @okta/okta-react, and you can have a production login working in well under a day because there's no server to build. A managed platform is close behind: most of the time goes into creating the connection and pointing your app at the platform SDK, not into writing auth code. SAML direct is the slowest by a wide margin, because "first login" includes building the service provider, hardening it against XML signature wrapping and XXE, and wiring up session management before you've authenticated a single user.

Ongoing maintenance

This is where the three paths separate the most. With SAML direct, you own all of it: rotating the SP signing certificate, onboarding each new customer IdP by hand, refreshing IdP metadata when certs change, and patching the SAML dependency CVEs (the node-saml, xml-crypto, and samlify criticals are recent and severe, see Walkthrough B, Step 7). With OIDC direct, maintenance is light, since the SDK verifies tokens against Okta's published JWKS and there's no SP certificate to rotate. With a managed platform, the platform owns certificate rotation, IdP onboarding, metadata refresh, and SAML library patching on its side, which is the single most practical reason teams stop running their own SP.

Troubleshooting common Okta + React issues

Most Okta + React failures trace back to a handful of mismatches: a redirect URI, a missing Trusted Origin, the wrong React Router pattern, an app assignment, or a cross-origin cookie. Each issue below leads with the symptom, then the cause, then the fix.

Redirect URI mismatch

The symptom is a blank page, a redirect loop, or an Okta error page right after sign-in instead of a return to your app. The cause is that the redirect URI your app sends does not exactly match a Sign-in redirect URI registered on the Okta app integration. Okta matches the full string: scheme, host, port, and path all have to line up. For a Vite SPA the value is http://localhost:5173/login/callback (note http, not https, on localhost, and port 5173). Add that exact string under the app's General → Sign-in redirect URIs, and set redirectUri in your OktaAuth config to the same value. A trailing slash, a different port, or 127.0.0.1 instead of localhost all count as a mismatch (Okta Auth Code + PKCE).

CORS or Trusted Origin errors on Vite localhost

The symptom is that sign-in starts fine, the redirect to Okta works, and then the return fails with a browser CORS error in the console and Okta error E0000021. The cause is that your dev origin is not registered as a Trusted Origin, so the browser blocks the SDK's cross-origin XHR call to the token endpoint. The /authorize step is a full-page navigation and never triggers CORS, which is why sign-in appears to start before it breaks at the /token exchange. The fix is to go to Security → API → Trusted Origins → Add Origin, set the Origin URL to http://localhost:5173, and check both CORS and Redirect. The same Trusted Origin is also required for oktaAuth.signOut() to work (Okta enable CORS, okta-auth-js issue #605).

Token expired, or silent renewal not firing

The symptom is the user getting logged out unexpectedly, or authState going stale after the access token's lifetime. There are three usual causes. First, active autoRenew only runs when the SDK service is started, so call await oktaAuth.start() once at startup; without it, renewal is passive (renew-on-read) only. Second, refresh tokens are only issued when you request the offline_access scope at sign-in, so confirm your scopes array includes it (['openid','profile','email','offline_access']). Third, a client clock that is skewed from real time makes the SDK think valid tokens are already expired, so check the machine's clock and NTP sync (okta-auth-js README, Okta refresh tokens).

React Router version mismatch (SecureRoute throws on v6/v7)

The symptom is an "unsupported version" error from @okta/okta-react the moment a route using SecureRoute renders. The cause is that SecureRoute only supports react-router-dom v5, and okta-react has thrown on v6 since version 6.4.3. The fix on React Router v6 or v7 is to drop SecureRoute and write a small RequiredAuth wrapper instead: call useOktaAuth(), null-check authState, redirect unauthenticated users with oktaAuth.setOriginalUri(...) plus oktaAuth.signInWithRedirect(), and render <Outlet /> when the user is authenticated. This is the exact pattern in Okta's official v6 sample (okta-react CHANGELOG, okta-react README).

useNavigate() may be used only in the context of a <Router> component.

The symptom is that exact runtime error on load, before any sign-in happens. The cause is that something calling useNavigate() is rendering outside the router. With okta-react this almost always means <Security> and its restoreOriginalUri callback (which calls useNavigate()) are mounted outside <BrowserRouter>. The fix is to make <BrowserRouter> the outer wrapper. In a Vite app, render <BrowserRouter> around <App /> in src/main.tsx, and define restoreOriginalUri plus <Security> inside the App component so they run within the router context. Name that component App, not AppWithRouterAccess (the AppWithRouterAccess sample is the older v5 pattern that uses useHistory()).

Sign-in fails with "User is not assigned to the client application" or access_denied

The symptom is that authentication itself succeeds but the user is bounced back with an error instead of a session. The cause is that the user (or a group they belong to) is not assigned to the Okta app integration. The fix is to assign them: open the app, go to the Assignments tab, and use Assign → Assign to People or Assign to Groups. How the error surfaces depends on the protocol. For OIDC, Okta redirects back with error=access_denied and error_description=User is not assigned to the client application.; oktaAuth.handleLoginRedirect() rejects, and <LoginCallback> renders its errorComponent with that Error. For SAML, Okta shows error E0000004 and POSTs no assertion to your ACS at all, so handle a missing assertion as an auth failure (Okta manage assignments).

Signing out still silently signs the user back in

The symptom is that after "logging out," the next sign-in completes with no prompt, as if logout did nothing. This is expected behavior, not a bug. The cause is that a local logout only clears your app's own session (tokens or a session cookie); the user's Okta session is still live, so the next sign-in is satisfied silently by Okta. The fix depends on what you want. To end the app session only, that is already what is happening. To also end the Okta session, use oktaAuth.signOut() on the OIDC path (it revokes the app tokens and ends the Okta session), or SAML Single Logout on the SAML path (a separate, best-effort profile that signs the user out at Okta too) (okta-auth-js README).

Okta groups claim missing from the ID token

The symptom is that idToken.claims.groups is undefined even though you added a groups claim in Okta. The usual cause is configuring the claim in the wrong place. If your issuer is the custom authorization server (https://<your-org>.okta.com/oauth2/default), the app's Sign On tab "Group claims filter" does not apply, because that field only affects the Org authorization server. The fix is to add the claim under Security → API → Authorization Servers → default → Claims → Add Claim. Two details matter: name the claim exactly groups (the claim key equals the Name verbatim and is case-sensitive, so idToken.claims.groups only resolves if you literally named it groups), and set "Include in" to a granted scope or to Any scope so the claim ships with your existing scopes (Okta create claims).

SAML: InvalidNameIDPolicy, audience mismatch, or clock skew

The symptom is that the assertion reaches your ACS but node-saml rejects it with a Name ID, audience, or timing error. There are three common causes, each with its own fix. InvalidNameIDPolicy means Okta's Name ID format does not match what your SP requested; align Okta's Name ID format (commonly EmailAddress) with your node-saml identifierFormat. An audience mismatch means the assertion's <saml:Audience> does not equal your SP Entity ID character-for-character; make Okta's Audience URI and node-saml audience byte-identical. Clock skew means NotBefore or NotOnOrAfter falls outside tolerance; sync the server clock with NTP and set a small acceptedClockSkewMs (single-digit seconds) rather than a large window (ScaleKit: SAML debugging, WorkOS: common SAML errors).

SAML: signature validation failures and certificate rotation

The symptom is that previously-working sign-ins start failing signature validation, often right after an Okta cert rotation. The cause is a stale pinned certificate: Okta keeps one SP signing certificate at a time, so when it rotates, a hard-coded idpCert no longer matches the signature. The fix is to validate against current IdP metadata. Point your SP at Okta's Metadata URL (it auto-updates on rotation) instead of pasting a cert that you then have to redeploy. The other half of "signature problems" is an out-of-date library, so pin @node-saml/node-saml to >=5.1.0 and confirm it pulls xml-crypto >=6.0.1; older versions carry critical signature-verification CVEs (Okta: rotate the SAML signing certificate, CVE-2025-54419).

SAML: profile.groups is a string, or the group check misbehaves

The symptom is a group check that works for users in several groups but breaks for a user in exactly one, sometimes by silently iterating over the characters of a string. The cause is that @node-saml/node-saml returns a multi-valued SAML attribute as a scalar string when there is one value and as an array when there are several. So profile.groups is a string for a single-group user and a string[] for a multi-group user, and calling .includes() or .map() on the scalar misbehaves. The fix is to normalize before you use it:

const raw = profile.groups
const groups: string[] = Array.isArray(raw) ? raw : raw != null ? [String(raw)] : []

Array.isArray() doubles as the type guard here, since arbitrary SAML attributes come through node-saml's index signature typed as unknown. Group values are Okta group names, so renaming a group in Okta silently breaks any groups.includes('Admins') check (node-saml README).

SAML: debugging the assertion with SAML-tracer

The symptom is that something in the assertion is wrong (Issuer, Audience, Recipient, or Conditions) but the raw SAML messages are base64-encoded in the network traffic and hard to read. The fix is the SAML-tracer browser extension for Firefox and Chrome, which decodes the base64 SAMLRequest and SAMLResponse inline so you can read the Issuer, Audience, Recipient, and <saml:Conditions> directly while you click through the flow. If you can't install an extension, the fallback is to open DevTools → Network, find the form POST to your ACS, copy the SAMLResponse form field, and base64-decode it by hand (SAML-tracer).

The symptom is that the assertion validates and the backend sets a session cookie, but the SPA's follow-up requests (like /me) come back unauthenticated. The cause is the cross-origin cookie trap: when the Vite SPA and the Express SP run on different origins, the browser treats the SP's cookie as third-party and won't attach it. The cleaner fix is to put both on one origin in development using the Vite dev proxy, forwarding the SP routes to the backend; the cookie is then first-party (SameSite=Lax, no dev TLS) and the post-ACS redirect is a clean relative path. If you must stay cross-origin, the cookie reaches the SPA only with SameSite=None; Secure (which forces HTTPS even in dev), CORS Access-Control-Allow-Credentials: true with an explicit, non-wildcard origin, and a client fetch(..., { credentials: 'include' }) (Vite server.proxy, MDN: Set-Cookie SameSite).

Frequently asked questions (FAQ)

Short, self-contained answers to the questions developers and AI agents ask most often about adding Okta SSO to a React app.