# Adding Okta SSO to a React app: SAML and OIDC walkthrough - Part 2

> Part 2 of 3. Start with [Adding Okta SSO to a React app: SAML and OIDC walkthrough](https://clerk.com/articles/adding-okta-sso-to-a-react-app-saml-and-oidc-walkthrough.md).

**How do I finish Okta OIDC logout and add SAML SSO to a React app?**

Finish the OIDC path by wiring `oktaAuth.signOut()`, testing redirects, and choosing whether the embedded Okta Sign-In Widget is worth maintaining. If SAML is required, add a Node or Express service provider that validates Okta's signed assertion server-side and issues an app session for the React SPA. This part covers OIDC logout and testing, the widget alternative, and the SAML backend through security essentials.

### 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](https://github.com/okta/okta-auth-js/blob/master/README.md)).

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

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

Clearing local tokens only logs the user out of your app; the Okta session stays live, so the next sign-in can silently re-authenticate. `signOut()` ends the Okta session too. If you only want to drop the app's tokens, use `oktaAuth.tokenManager.clear()` instead ([okta-auth-js README](https://github.com/okta/okta-auth-js/blob/master/README.md)).

> By default, `oktaAuth.signOut()` does a top-level redirect to Okta's OIDC logout endpoint (`/oauth2/v1/logout`, with the ID token as `id_token_hint`), so it's first-party and unaffected 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()`) and to silent iframe operations like token renewal ([Okta: sign users out of React](https://developer.okta.com/docs/guides/sign-users-out/react/main/), [Okta: OIDC Single Logout](https://developer.okta.com/docs/guides/single-logout/openidconnect/main/)).

### 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 CORS error right after the redirect means the Trusted Origin you configured in Part 1 is missing or wrong; Okta's origin-validation error is `E0000212` ([Okta API error codes](https://developer.okta.com/docs/reference/error-codes/)). 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](https://developer.okta.com/docs/concepts/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.

> The third generation of the Okta Sign-In Widget doesn't support embedded authentication. Self-hosting requires the second-generation widget, and it is fragile under third-party-cookie deprecation when your app and Okta org use different domains ([Okta upgrade the Sign-In Widget](https://developer.okta.com/docs/guides/oie-upgrade-sign-in-widget/main/), [Okta self-hosted widget and 3p cookies](https://developer.okta.com/blog/2024/04/08/third-party-cookies-okta-sign-in-widget)). 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](https://developer.okta.com/docs/guides/sign-in-to-spa-embedded-widget/react/main/)).

```tsx
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 docs](https://react.dev/reference/react/StrictMode)). The widget version as of mid-2026 is the 7.47.x line ([okta-signin-widget npm](https://registry.npmjs.org/@okta/okta-signin-widget)).

## 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 just a relay: it carries the SAML messages, but a small backend SP validates the assertion, 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, conditions, attributes, and signature.

```xml
<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>
```

The `<ds:Signature>` over that XML is the whole security model: read identity from any element the signature doesn't cover and you've built an auth bypass. That's why the library you pick matters (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 single-domain production deploy. The proxy config is in Step 5.

> Cross-origin is the harder path. If the SPA and SP stay on different origins, the session cookie reaches `/me` only with `SameSite=None; Secure`, credentialed CORS with an explicit origin, and `fetch(..., { credentials: 'include' })`. The same-origin proxy sidesteps all of that.

### Step 1: Create a SAML app integration in Okta

In the Admin Console, go to **Applications** → **Create 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:

| Name        | Name format | Value            |
| ----------- | ----------- | ---------------- |
| `email`     | Basic       | `user.email`     |
| `firstName` | Basic       | `user.firstName` |
| `lastName`  | Basic       | `user.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

> 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. For the signature algorithm, keep Okta on RSA-SHA256 — SHA-1 is legacy — so Okta signs its response with SHA-256; node-saml then validates that signature against the certificate you pin in Step 3 ([Okta: Add a SAML 2.0 app integration](https://developer.okta.com/docs/guides/add-private-app/saml2/main/), [Okta: Upgrade SAML apps to SHA256](https://developer.okta.com/docs/guides/updating-saml-cert/main/)).

### 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 (**Assign** → **Assign 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 fetches. It always serves Okta's current IdP signing certificate (the one your SP validates against), so it auto-updates on rotation and you never redeploy a pasted cert ([Okta: Rotate the SAML signing certificate](https://support.okta.com/help/s/article/how-to-rotate-signing-certificate-for-custom-saml-applications?language=en_US)).
- **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](https://developer.okta.com/docs/concepts/saml/), [Okta: Download IdP XML metadata](https://support.okta.com/help/s/article/Location-to-download-Okta-IDP-XML-metadata-for-a-SAML-app-in-the-new-Admin-User-Interface?language=en_US)).

### 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).

```bash
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` (renamed from `cert` in v5.0.0; construction requires `idpCert` and throws `idpCert is required` if it's missing, so the old `cert` key no longer works). The mandatory options are `idpCert`, `issuer`, and `callbackUrl` ([node-saml README](https://github.com/node-saml/node-saml), [node-saml v5.0.0 release](https://github.com/node-saml/node-saml/releases)).

```ts
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'. These set how the SP signs its own messages
  // (the SLO LogoutRequest in Step 6, plus SP metadata); sha256 matches Okta.
  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 earn their keep. `signatureAlgorithm` and `digestAlgorithm` set how your SP signs its own outbound messages (the SLO `LogoutRequest` and SP metadata), so `'sha256'` replaces the legacy `'sha1'` default; they don't gate Okta's inbound signature, which is validated against the pinned `idpCert` regardless. `validateInResponseTo: always` (the default is `never`) makes node-saml reject any response that doesn't correlate to a request it issued — your replay defense for the SP-initiated flow ([node-saml `src/types.ts`](https://raw.githubusercontent.com/node-saml/node-saml/master/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).

```ts
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](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html)).

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

```ts
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')
  }
})
```

Two details earn their keep. Group normalization is mandatory: node-saml stores one `<AttributeValue>` as a scalar string and multiple as an array, so `profile.groups.includes('Admins')` misbehaves for a single-group user; the `Array.isArray` guard also narrows the `unknown` attribute type ([node-saml `src/saml.ts`](https://raw.githubusercontent.com/node-saml/node-saml/master/src/saml.ts)). And the `safePath` check on the final `res.redirect` prevents an open redirect through untrusted `RelayState`.

> The "user authenticated but not assigned to the app" case never reaches this handler. Okta shows the user an error page reading **"User is not assigned to this application"** 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.

```ts
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 })
})
```

Express doesn't type `req.user` out of the box, so the middleware above assumes you've augmented its `Request` interface once — a `declare global` block adding `user?: SessionUser` — so the `req.user` writes and reads type-check.

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

```tsx
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` (which covers `/login/callback`) and `/me` forward to the SP. The browser only ever talks to the Vite origin, so the session cookie stays first-party.

```ts
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 same-origin and the cookie rides on the default `credentials: 'same-origin'`. Cross-origin deployments need absolute backend URLs, `fetch(..., { credentials: 'include' })`, and the CORS headers from the architecture note above ([Vite: server options](https://vite.dev/config/server-options), [MDN: Set-Cookie SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/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.

```ts
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. 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 SAML profile, not part of the core sign-in flow. OASIS states that "the result of the logout process cannot be guaranteed," and SLO also requires an SP signing key, so many teams skip it ([OASIS SAML 2.0 Technical Overview §5.3](https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html)).

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.

```ts
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.

```ts
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('/')
  }
})
```

> SLO has an operating cost. `getLogoutUrlAsync` needs the login profile's `nameID`, `nameIDFormat`, and `sessionIndex`, so you persist them — and with SP signing-key rotation, extra Okta config, and per-session state, SLO becomes another reason teams choose a managed platform ([node-saml `src/saml.ts`](https://raw.githubusercontent.com/node-saml/node-saml/master/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 — 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 (node-saml's parser does) and the library patched.
- **Rotate certificates deliberately.** The metadata URL always serves Okta's current IdP signing cert, so consuming it (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](https://cheatsheetseries.owasp.org/cheatsheets/SAML_Security_Cheat_Sheet.html), [OWASP XXE Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html))

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

| CVE                                                                 | Library                      | CVSS             | Fixed in              |
| ------------------------------------------------------------------- | ---------------------------- | ---------------- | --------------------- |
| [CVE-2025-54419](https://nvd.nist.gov/vuln/detail/CVE-2025-54419)   | `@node-saml/node-saml`       | 10.0 (v3.1, NVD) | 5.1.0                 |
| [CVE-2025-54369](https://github.com/advisories/GHSA-m837-g268-mmv7) | `@node-saml/node-saml`       | 9.3 (v4.0, GHSA) | 5.1.0                 |
| [CVE-2025-29775](https://github.com/advisories/GHSA-x3m8-899r-f7c3) | `xml-crypto` (via node-saml) | 9.3 (v4.0, GHSA) | 6.0.1 / 3.2.1 / 2.1.6 |
| [CVE-2025-47949](https://github.com/advisories/GHSA-r683-v43c-6xqv) | `samlify`                    | 9.9 (v4.0, GHSA) | 2.10.0                |
| [CVE-2026-46490](https://github.com/advisories/GHSA-34r5-q4jw-r36m) | `samlify`                    | 8.7 (v4.0, GHSA) | 2.13.0                |

The first two are node-saml's own — a CVSS 10.0 signature-verification flaw and an authentication bypass, both at or below 5.0.1 — which is why `>=5.1.0` is the minimum-safe pin; that release also pins patched `xml-crypto ^6.1.2`, closing the SAMLStorm flaw (CVE-2025-29775) ([node-saml advisory GHSA-4mxg-3p6v-xgq3](https://github.com/node-saml/node-saml/security/advisories/GHSA-4mxg-3p6v-xgq3), [WorkOS: SAMLStorm](https://workos.com/blog/samlstorm)). The samlify rows show alternatives need the same scrutiny: its floor is `>=2.13.0` (npm latest 2.13.1), not 2.10.0, because privilege-escalation CVE-2026-46490 is fixed only in 2.13.0 ([samlify advisory GHSA-34r5-q4jw-r36m](https://github.com/advisories/GHSA-34r5-q4jw-r36m)).

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

Part 2 completes OIDC logout/testing and the SAML service-provider build. Continue to Part 3 for SAML verification, managed-platform decisions, and troubleshooting.

## In this series

1. [Adding Okta SSO to a React app: SAML and OIDC walkthrough](https://clerk.com/articles/adding-okta-sso-to-a-react-app-saml-and-oidc-walkthrough.md)
2. **Adding Okta SSO to a React app: SAML and OIDC walkthrough - Part 2** (you are here)
3. [Adding Okta SSO to a React app: SAML and OIDC walkthrough - Part 3](https://clerk.com/articles/adding-okta-sso-to-a-react-app-saml-and-oidc-walkthrough-3.md)
