Skip to main content
Articles

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

Author: Roy Anger
Published: (last updated )

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

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

Note

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, 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 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). 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 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, 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 docs). The widget version as of mid-2026 is the 7.47.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 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.

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

Note

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 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. 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, 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 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).
  • 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 (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, 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'. 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).

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

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). And the safePath check on the final res.redirect prevents an open redirect through untrusted RelayState.

Note

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.

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.

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.

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

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

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

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, 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 — 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, WorkOS: 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).

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

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