Okta SDK swaps by framework
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. For the move as a whole, see Migrate from Okta. Starting a new app? Use a quickstart 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.
Summary
| Framework | Tested with | Issuer and client ID alone? | Every other edit |
|---|---|---|---|
| React SPA | @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 | @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 | next-auth 4.24.15 | No. Sign-in is refused | Add checks: ["pkce", "state"]. Add a profile that reads UserInfo |
| Flask | 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.1or[::1]redirect URI. A library that doesn't send it is refused at sign-in. - The ID token has no
emailorname. They come from UserInfo. See what the app receives. - Access tokens are opaque unless the sign-in asks for 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.
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:
-
authState.idToken.claims.emailis empty, with no error, because the ID token has no email. -
When the access token expires, the page leaves and comes back. Without a refresh token, the SDK renews with
getWithoutPromptin a hidden iframe. IdRa doesn't renew that way, with or without a session, and answers the iframe with an error message and no token. The SDK removes the expired tokens, andSecureRoutethen 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 outsideSecureRoute.The SDK reports
AuthSdkError: The request does not match client configuration, notlogin_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 and Replace session.exists. 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:
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:
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. 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. We read that from the middleware's source and didn't run it.
The environment changes:
ISSUER=https://{yourOktaDomain}/oauth2/default
CLIENT_ID=<Okta client ID>
CLIENT_SECRET=<Okta client secret>
APP_URL=http://localhost:3000ISSUER=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:
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.
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 shows the same call.
Before:
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:
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, okta-hosted-login, brought up to current versions with flask-login left out. New app: Flask quickstart, which uses Authlib.
Are the issuer and client ID enough? No. We removed each failure in turn, and each one exposed the next:
auth_uriin the sample'sclient_secrets.jsonis written as<issuer>/v1/authorize. On IdRa that's a 404.- With the URLs from discovery, sign-in comes back with
error_description=pkce is required for this client. The page saysThe code was not returned or is not accessible. - With PKCE turned off as well, the callback answers
Access token is invalid. The token is opaque, so the verifier fails withNot 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:
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:
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.