# Okta SDK swaps by framework (https://docs.idra.zone/docs/migrate-okta-sdk-frameworks)

What changes in a React, Express, Next.js or Flask app that uses Okta's libraries, moving to IdRa. Each case was run in a browser against a local IdRa.

Find your framework below. Each section gives the package version we tested, whether changing the issuer and client ID is enough, every other edit, and the code before and after.

For the calls these libraries make underneath, see [Move an app that uses Okta's SDKs](https://docs.idra.zone/docs/migrate-okta-sdk). For the move as a whole, see [Migrate from Okta](https://docs.idra.zone/docs/migrate-from-okta). Starting a new app? Use a [quickstart](https://docs.idra.zone/docs/quickstarts) instead.

**What this was and wasn't tested on**

  We had no Okta tenant, so **no "before" code here has run against Okta**. It's adapted from Okta's samples and docs, and it was only run against IdRa, to see what breaks. All runs were local, against a seeded IdRa over `http`, on 2026-10-31. They weren't run against idra.team. See [What this was tested on](#what-this-was-tested-on).

## Summary

| Framework | Tested with | Issuer and client ID alone? | Every other edit |
| --- | --- | --- | --- |
| [React SPA](#react-spa-oktaokta-react) | `@okta/okta-react` 6.11.0, `@okta/okta-auth-js` 8.0.1 | No. Sign-in works, then every token expiry reloads the page | Add `offline_access`. Read the email from `getUser()`, not the ID token. Register the sign-out URI as the bare origin |
| [Express](#express-oktaoidc-middleware) | `@okta/oidc-middleware` 6.0.2 | No. Sign-in is refused | No code. Turn off **Require PKCE as additional verification**, and use an `https` redirect URI that isn't on `localhost`, even in development |
| [Next.js](#nextjs-authjs-okta-provider) | `next-auth` 4.24.15 | No. Sign-in is refused | Add `checks: ["pkce", "state"]`. Add a `profile` that reads UserInfo |
| [Flask](#flask-okta-jwt-verifier) | `okta-jwt-verifier` 0.5.0, with the flow from Okta's Flask sample | No. Sign-in is refused | Read endpoints from discovery. Add PKCE. Stop verifying the access token locally. Install `cryptography` |

We didn't run Okta's Spring Boot starter, because no JDK was available, so it isn't covered here.

Three facts about IdRa cause most of these edits:

- **PKCE is on by default for a Web Application**, and it can't be turned off while the app has a `localhost`, `127.0.0.1` or `[::1]` redirect URI. A library that doesn't send it is refused at sign-in.
- **The ID token has no `email` or `name`.** They come from UserInfo. See [what the app receives](https://docs.idra.zone/docs/quickstarts/express#what-the-app-receives).
- **Access tokens are opaque** unless the sign-in [asks for an API resource](https://docs.idra.zone/docs/migrate-okta-sdk#request-an-api-resource), so they can't be verified locally.

The issuer in every case is `https://<your-org>.idra.team/api/auth`. Create the app under **Applications** as in the matching quickstart, and copy the **Issuer** and **Client ID** from its **Sign On** tab.

## React SPA: @okta/okta-react

**Tested with** `@okta/okta-react` 6.11.0, `@okta/okta-auth-js` 8.0.1, React 18.3.1, `react-router-dom` 5.3.4 (Okta's sample uses `SecureRoute`, which needs version 5), bundled with `bun build`. New app: [React SPA quickstart](https://docs.idra.zone/docs/quickstarts/react-spa).

**Are the issuer and client ID enough? No.** With only those changed, sign-in, `SecureRoute`, `LoginCallback` and `signOut` all work. Then two things go wrong:

1. **`authState.idToken.claims.email` is empty**, with no error, because the ID token has no email.
2. **When the access token expires, the page leaves and comes back.** Without a refresh token, the SDK renews with `getWithoutPrompt` in a hidden iframe. IdRa [doesn't renew that way](https://docs.idra.zone/docs/migrate-okta-sdk#renew-with-a-refresh-token), with or without a session, and answers the iframe with an error message and no token. The SDK removes the expired tokens, and `SecureRoute` then redirects the whole page to IdRa. With a live IdRa session the page comes back with new tokens and no login page. Without one, the person sees the login page. Anything unsaved on the page is lost, and access tokens last an hour, so an issuer-only swap can look fine in a quick test and start this an hour later. In our runs this took at most 3 hidden requests per expiry, none of which loaded your app in the iframe. We didn't run a page outside `SecureRoute`.

   The SDK reports `AuthSdkError: The request does not match client configuration`, not `login_required`, if you log it. This doesn't mean your configuration is wrong. The SDK compares the message's origin with your issuer up to `/oauth2/`, which for IdRa keeps `/api/auth`, so no message can match.

**Every edit:**

| Where | Edit | Why |
| --- | --- | --- |
| `scopes` | Add `offline_access` | Renewal uses the refresh token, with no iframe and no page reload. **Don't skip this one** |
| Reading the profile | `oktaAuth.getUser()` instead of `authState.idToken.claims` | The ID token has no email or name |
| **Sign-out redirect URIs** on the **Sign On** tab | Register the bare origin with no trailing slash, such as `https://app.example.com` | `okta-auth-js` sends that when `postLogoutRedirectUri` isn't set, and matching is exact |

After these edits, with the refresh token revoked, an expired tab opened no iframes. It redirected to IdRa: back with new tokens if the IdRa session was live, or to the login page if not.

`signInWithCredentials`, `session.exists` and `getWithoutPrompt` stay refused. If your app calls them, see [Sign in by redirect](https://docs.idra.zone/docs/migrate-okta-sdk#sign-in-by-redirect) and [Replace session.exists](https://docs.idra.zone/docs/migrate-okta-sdk#replace-sessionexists). We didn't run Okta's Sign-In Widget. IdRa answers 404 at Okta's `/api/v1/authn`, `/idp/idx` and `/oauth2/v1/interact` paths, so plan to replace an embedded widget with sign-in by redirect.

Before:

```jsx title="before.jsx"
import { OktaAuth, toRelativeUrl } from "@okta/okta-auth-js";
import { LoginCallback, SecureRoute, Security, useOktaAuth } from "@okta/okta-react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Switch, useHistory } from "react-router-dom";
import { issuer, clientId } from "./env.js";

const oktaAuth = new OktaAuth({
  issuer,
  clientId,
  redirectUri: `${location.origin}/login/callback`,
  scopes: ["openid", "profile", "email"],
});

function Profile() {
  const { authState, oktaAuth } = useOktaAuth();
  return (
    <>
      <p id="email">Email: {authState?.idToken?.claims.email}</p>
      <button onClick={() => oktaAuth.signOut()}>Sign out</button>
    </>
  );
}

function App() {
  const history = useHistory();
  const restoreOriginalUri = (_, originalUri) =>
    history.replace(toRelativeUrl(originalUri || "/", location.origin));
  return (
    <Security oktaAuth={oktaAuth} restoreOriginalUri={restoreOriginalUri}>
      <Switch>
        <Route path="/login/callback" component={LoginCallback} />
        <SecureRoute path="/profile" component={Profile} />
        <Route path="/">Home</Route>
      </Switch>
    </Security>
  );
}

createRoot(document.querySelector("#root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
);
```

After:

```jsx title="after.jsx"
import { OktaAuth, toRelativeUrl } from "@okta/okta-auth-js";
import { LoginCallback, SecureRoute, Security, useOktaAuth } from "@okta/okta-react";
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Switch, useHistory } from "react-router-dom";
import { issuer, clientId } from "./env.js";

const oktaAuth = new OktaAuth({
  issuer,
  clientId,
  redirectUri: `${location.origin}/login/callback`,
  // offline_access asks for the refresh token that replaces the hidden iframe.
  scopes: ["openid", "profile", "email", "offline_access"],
});

function Profile() {
  const { oktaAuth } = useOktaAuth();
  // The ID token has no email or name: they come from UserInfo.
  const [user, setUser] = useState();
  useEffect(() => {
    oktaAuth.getUser().then(setUser);
  }, [oktaAuth]);
  return (
    <>
      <p id="email">Email: {user?.email}</p>
      <button onClick={() => oktaAuth.signOut()}>Sign out</button>
    </>
  );
}

function App() {
  const history = useHistory();
  const restoreOriginalUri = (_, originalUri) =>
    history.replace(toRelativeUrl(originalUri || "/", location.origin));
  return (
    <Security oktaAuth={oktaAuth} restoreOriginalUri={restoreOriginalUri}>
      <Switch>
        <Route path="/login/callback" component={LoginCallback} />
        <SecureRoute path="/profile" component={Profile} />
        <Route path="/">Home</Route>
      </Switch>
    </Security>
  );
}

createRoot(document.querySelector("#root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
);
```

Both files import `issuer` and `clientId` from an `env.js` of your own.

## Express: @okta/oidc-middleware

**Tested with** `@okta/oidc-middleware` 6.0.2, Express 5.3.0, `express-session` 1.19.0. New app: [Express quickstart](https://docs.idra.zone/docs/quickstarts/express). It uses `openid-client` directly, with PKCE.

**Are the issuer and client ID enough? No.** The middleware can't send PKCE: `usePKCE: false` is fixed in its source, with no option to change it. Against a default **Web Application**, sign-in comes back to your callback with `OPError: invalid_request (pkce is required for this client)`.

**The code doesn't change. The app's settings do:**

| Where | Edit | Why |
| --- | --- | --- |
| **Require PKCE as additional verification** on the **Sign On** tab | Turn it off | The middleware can't send PKCE |
| **Sign-in redirect URIs** | `https://<your-host>/authorization-code/callback`, on a host that isn't `localhost`, `127.0.0.1` or `[::1]` | With one of those hosts, IdRa refuses to turn PKCE off: `Loopback redirect URIs require PKCE`. `https` on those hosts is refused too |
| **Sign-out redirect URIs** | Your `appBaseUrl`, with its trailing slash | That's where `POST /logout` returns |

**This has a cost.** The app loses PKCE, and it can't run on `http://localhost` against IdRa, so development needs an `https` host of its own. Our run used a self-signed certificate on `https://okta-express.localhost:4853`. If you'd rather keep PKCE, replace the middleware with `openid-client`, as in the quickstart.

The default `POST /logout` works: it revokes the tokens, with no `revokeError`, ends the IdRa session and returns to the app. A custom login page that posts a `sessionToken` depends on `signInWithCredentials`, which [stays refused](https://docs.idra.zone/docs/migrate-okta-sdk#sign-in-by-redirect). We read that from the middleware's source and didn't run it.

The environment changes:

```sh title=".env before"
ISSUER=https://{yourOktaDomain}/oauth2/default
CLIENT_ID=<Okta client ID>
CLIENT_SECRET=<Okta client secret>
APP_URL=http://localhost:3000
```

```sh title=".env after"
ISSUER=https://<your-org>.idra.team/api/auth
CLIENT_ID=<IdRa client ID>
CLIENT_SECRET=<IdRa client secret>
APP_URL=https://<your-host>
```

The code, the same before and after. Your Okta app won't have the `disableHttpsCheck` line: we added it to run against a local `http` IdRa, and it does nothing for an `https` issuer:

```js title="server.js"
const { ExpressOIDC } = require("@okta/oidc-middleware");
const express = require("express");
const session = require("express-session");

const { ISSUER, CLIENT_ID, CLIENT_SECRET, APP_URL, SESSION_SECRET, PORT } = process.env;

const oidc = new ExpressOIDC({
  issuer: ISSUER,
  client_id: CLIENT_ID,
  client_secret: CLIENT_SECRET,
  appBaseUrl: APP_URL,
  scope: "openid profile email",
  // Only for an IdRa you run locally on http. Never needed for an https issuer.
  ...(ISSUER.startsWith("http:") && { testing: { disableHttpsCheck: true } }),
});

const app = express();
app.use(session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false }));
app.use(oidc.router);

app.get("/", (req, res) => res.send("<h1>Home</h1>"));
app.get("/profile", oidc.ensureAuthenticated(), (req, res) => {
  res.send(`<p id="email">Email: ${req.userContext.userinfo.email}</p>
    <form method="post" action="/logout"><button>Sign out</button></form>`);
});

oidc.on("ready", () => app.listen(PORT, "127.0.0.1"));
oidc.on("error", (err) => console.error("oidc error:", err));
```

## Next.js: Auth.js Okta provider

**Tested with** `next-auth` 4.24.15, the package's `latest` tag, on Next.js 16.4.3 with the App Router. Auth.js 5 is still a beta and wasn't run. New app: [Next.js quickstart](https://docs.idra.zone/docs/quickstarts/nextjs).

**Are the issuer and client ID enough? No.** The provider sends `state` but no PKCE, so sign-in is refused. The sign-in page says `Try signing in with a different account.`, and the server log has `pkce is required for this client`.

**Every edit:**

| Where | Edit | Why |
| --- | --- | --- |
| Provider options | `checks: ["pkce", "state"]` | IdRa requires PKCE by default |
| Provider options | An async `profile` that reads UserInfo | With PKCE alone, sign-in works and `session.user.email` is empty: the provider reads the ID token, which has no email. `session.user.name` falls back to the email address |
| Provider options | `name: "IdRa"` | Optional: the sign-in button says **Sign in with Okta** otherwise |

Don't set `idToken: false` to get UserInfo. Sign-in then fails with `id_token detected in the response, you must use client.callback() instead of client.oauthCallback()`.

The sign-in redirect URI stays `/api/auth/callback/okta`, because the provider's ID is still `okta`. Register the full URL, such as `https://app.example.com/api/auth/callback/okta`.

`signOut` ends the app's session only, as it does with Okta's provider. The IdRa session stays, so the next sign-in shows no login page. To end it too, send the person to the `end_session_endpoint` from discovery. We didn't run that here; the [Express quickstart](https://docs.idra.zone/docs/quickstarts/express) shows the same call.

Before:

```js title="before.js"
import OktaProvider from "next-auth/providers/okta";

export const authOptions = {
  providers: [
    OktaProvider({
      clientId: process.env.OKTA_CLIENT_ID,
      clientSecret: process.env.OKTA_CLIENT_SECRET,
      issuer: process.env.OKTA_ISSUER,
    }),
  ],
};
```

After:

```js title="after.js"
import OktaProvider from "next-auth/providers/okta";

export const authOptions = {
  providers: [
    OktaProvider({
      clientId: process.env.OKTA_CLIENT_ID,
      clientSecret: process.env.OKTA_CLIENT_SECRET,
      issuer: process.env.OKTA_ISSUER,
      name: "IdRa", // the label on the sign-in button
      // IdRa requires PKCE by default. The provider's default is state only.
      checks: ["pkce", "state"],
      // The ID token has no email or name: they come from UserInfo.
      async profile(claims, tokens) {
        const res = await fetch(`${process.env.OKTA_ISSUER}/oauth2/userinfo`, {
          headers: { authorization: `Bearer ${tokens.access_token}` },
        });
        if (!res.ok) throw new Error(`UserInfo: HTTP ${res.status}`);
        const { name, email } = await res.json();
        return { id: claims.sub, name, email, image: null };
      },
    }),
  ],
};
```

## Flask: okta-jwt-verifier

**Tested with** `okta-jwt-verifier` 0.5.0, Flask 3.1.3, `requests` 2.34.2, `cryptography` 50.0.2, Python 3.14. Okta has no Flask SDK, so "before" is the flow from [Okta's sample](https://github.com/okta/samples-python-flask/tree/master/okta-hosted-login), `okta-hosted-login`, brought up to current versions with `flask-login` left out. New app: [Flask quickstart](https://docs.idra.zone/docs/quickstarts/python), which uses Authlib.

**Are the issuer and client ID enough? No.** We removed each failure in turn, and each one exposed the next:

1. `auth_uri` in the sample's `client_secrets.json` is written as `<issuer>/v1/authorize`. On IdRa that's a 404.
2. With the URLs from discovery, sign-in comes back with `error_description=pkce is required for this client`. The page says `The code was not returned or is not accessible`.
3. With PKCE turned off as well, the callback answers `Access token is invalid`. The token is opaque, so the verifier fails with `Not enough segments`, and the sample swallows the reason.

**Every edit:**

| Where | Edit | Why |
| --- | --- | --- |
| `client_secrets.json` | Remove `auth_uri`, `token_uri` and `userinfo_uri`, and read them from discovery | The `<issuer>/v1/*` paths don't exist on IdRa |
| `/login`, `/authorization-code/callback` | Send PKCE | IdRa requires it by default. Keeps `http://localhost` working in development |
| `/authorization-code/callback` | Remove `is_access_token_valid` | An opaque token can't be verified locally. UserInfo refuses a bad one |
| `/login`, `/authorization-code/callback` | Random `state` and `nonce` per sign-in, with `state` checked | Not needed by IdRa: the sample's fixed values aren't safe to copy |
| `requirements.txt` | Add `cryptography` | `okta-jwt-verifier` 0.5.0 needs it for RS256 but doesn't install it: without it, every token fails with `RS256 requires 'cryptography' to be installed.` |

`okta-jwt-verifier` refuses `http` issuers and has no flag to allow them. To run against a local `http` IdRa, our harness patched its issuer check from outside the app. This isn't needed for an `https` issuer, and it wasn't run against one. The checks on `aud` and `nonce` in `IDTokenVerifier` pass unchanged.

The sample's `/logout` ends the app's session only. The IdRa session stays, so the next sign-in shows no login page.

Before:

```python title="before.py"
import asyncio
import json
import os

import requests
from flask import Flask, redirect, request, session, url_for
from okta_jwt_verifier import AccessTokenVerifier, IDTokenVerifier

with open("client_secrets.json") as f:
    config = json.load(f)

app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]

APP_STATE = "ApplicationState"
NONCE = "SampleNonce"

def is_access_token_valid(token):
    verifier = AccessTokenVerifier(issuer=config["issuer"], audience="api://default")
    try:
        asyncio.run(verifier.verify(token))
        return True
    except Exception:
        return False

def is_id_token_valid(token):
    verifier = IDTokenVerifier(issuer=config["issuer"], client_id=config["client_id"], audience="api://default")
    try:
        asyncio.run(verifier.verify(token, nonce=NONCE))
        return True
    except Exception:
        return False

@app.route("/login")
def login():
    query = {
        "client_id": config["client_id"],
        "redirect_uri": config["redirect_uri"],
        "scope": "openid email profile",
        "state": APP_STATE,
        "nonce": NONCE,
        "response_type": "code",
        "response_mode": "query",
    }
    return redirect(f"{config['auth_uri']}?{requests.compat.urlencode(query)}")

@app.route("/authorization-code/callback")
def callback():
    code = request.args.get("code")
    if not code:
        return "The code was not returned or is not accessible", 403
    exchange = requests.post(
        config["token_uri"],
        data={"grant_type": "authorization_code", "code": code, "redirect_uri": request.base_url},
        auth=(config["client_id"], config["client_secret"]),
    ).json()
    if not exchange.get("token_type"):
        return "Unsupported token type. Should be 'Bearer'.", 403
    access_token = exchange["access_token"]
    if not is_access_token_valid(access_token):
        return "Access token is invalid", 403
    if not is_id_token_valid(exchange["id_token"]):
        return "ID token is invalid", 403
    userinfo = requests.get(config["userinfo_uri"], headers={"Authorization": f"Bearer {access_token}"}).json()
    session["user"] = {"name": userinfo["given_name"], "email": userinfo["email"]}
    return redirect(url_for("profile"))

@app.route("/")
def home():
    return "<h1>Home</h1>"

@app.route("/profile")
def profile():
    if "user" not in session:
        return redirect(url_for("login"))
    return f"""<p id="email">Email: {session['user']['email']}</p>
      <form method="post" action="/logout"><button>Sign out</button></form>"""

@app.route("/logout", methods=["POST"])
def logout():
    session.clear()
    return redirect(url_for("home"))
```

After:

```python title="after.py"
import asyncio
import base64
import hashlib
import json
import os
import secrets

import requests
from flask import Flask, abort, redirect, request, session, url_for
from okta_jwt_verifier import IDTokenVerifier

with open("client_secrets.json") as f:
    config = json.load(f)
# Endpoints come from discovery: IdRa's aren't under <issuer>/v1.
endpoints = requests.get(f"{config['issuer']}/.well-known/openid-configuration").json()

app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]

def is_id_token_valid(token, nonce):
    verifier = IDTokenVerifier(issuer=config["issuer"], client_id=config["client_id"], audience="api://default")
    try:
        asyncio.run(verifier.verify(token, nonce=nonce))
        return True
    except Exception:
        return False

@app.route("/login")
def login():
    # IdRa requires PKCE by default. The sample's fixed state and nonce become per sign-in.
    verifier = secrets.token_urlsafe(32)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    session["flow"] = {"verifier": verifier, "state": secrets.token_urlsafe(16), "nonce": secrets.token_urlsafe(16)}
    query = {
        "client_id": config["client_id"],
        "redirect_uri": config["redirect_uri"],
        "scope": "openid email profile",
        "state": session["flow"]["state"],
        "nonce": session["flow"]["nonce"],
        "response_type": "code",
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
    return redirect(f"{endpoints['authorization_endpoint']}?{requests.compat.urlencode(query)}")

@app.route("/authorization-code/callback")
def callback():
    flow = session.pop("flow", None)
    if not flow or request.args.get("state") != flow["state"]:
        abort(403)
    code = request.args.get("code")
    if not code:
        return "The code was not returned or is not accessible", 403
    exchange = requests.post(
        endpoints["token_endpoint"],
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": config["redirect_uri"],
            "code_verifier": flow["verifier"],
        },
        auth=(config["client_id"], config["client_secret"]),
    ).json()
    if not exchange.get("token_type"):
        return "Unsupported token type. Should be 'Bearer'.", 403
    # The access token is opaque here, so there's nothing to verify locally. UserInfo rejects a bad one.
    if not is_id_token_valid(exchange["id_token"], flow["nonce"]):
        return "ID token is invalid", 403
    userinfo = requests.get(
        endpoints["userinfo_endpoint"], headers={"Authorization": f"Bearer {exchange['access_token']}"}
    ).json()
    session["user"] = {"name": userinfo["given_name"], "email": userinfo["email"]}
    return redirect(url_for("profile"))

@app.route("/")
def home():
    return "<h1>Home</h1>"

@app.route("/profile")
def profile():
    if "user" not in session:
        return redirect(url_for("login"))
    return f"""<p id="email">Email: {session['user']['email']}</p>
      <form method="post" action="/logout"><button>Sign out</button></form>"""

@app.route("/logout", methods=["POST"])
def logout():
    session.clear()
    return redirect(url_for("home"))
```

## What this was tested on

Each framework ran on 2026-10-31 in headless Chromium driven by Playwright 1.64.1, on Node.js 24.22.1, against a seeded in-memory IdRa on `http://localhost`. The person signed in with a password and TOTP. The React, Next.js and Flask apps ran on `http://127.0.0.1`, a different site from IdRa. The apps that needed PKCE off ran on `https://*.localhost` with a self-signed certificate. Token expiry was simulated: on the React app by moving the stored expiry back and reloading, and by moving the page's clock. The test fails if the code blocks on this page differ from the code it runs.

**Not run:** a real Okta tenant (so no "before" has ever signed in anywhere), an `https` issuer, the hosted idra.team service, other browsers, Okta's Spring Boot starter, Auth.js 5, Okta's Sign-In Widget, and React Router 6 or later. The hidden-request count above is from one tab on one machine. The case where the iframe receives the session cookie, an app on the same registrable domain as IdRa, was run only as `localhost` against `localhost` on another port: the answer was the same error, with no code.