
Adding Okta SSO to a React app: SAML and OIDC walkthrough
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.
This series 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.
This is Part 1 of the series: protocol choice, shared Okta setup, and browser-native OIDC through token storage and renewal.
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).
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
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.)
- No: use OIDC (Authorization Code + PKCE) via
- 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-samlat 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:
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/defaultpath 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
defaultauthorization 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_accessscope 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 readidToken.claims.groupsyou must name it literallygroups. - 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
groupsscope 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.
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.
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@6As 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 v7.0.0 changelog). 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).
Part 1 leaves the React app with the recommended Okta OIDC foundation. Continue to Part 2 for OIDC logout, testing, the embedded widget alternative, and SAML with a backend service provider.
In this series
- Adding Okta SSO to a React app: SAML and OIDC walkthrough (you are here)
- Adding Okta SSO to a React app: SAML and OIDC walkthrough - Part 2
- Adding Okta SSO to a React app: SAML and OIDC walkthrough - Part 3