v3.x upgrades openid-client (v4 → v6) and jose (v2 → v6) to their latest major versions, bringing improved security, performance, and standards compliance.
Node.js Version: Now requires ^20.19.0 || ^22.12.0 || >= 23.0.0 (previously Node.js 14+)
Module Support: Works with BOTH CommonJS and ESM apps
| Change | Who is affected | Action required |
|---|---|---|
| Node.js version | Everyone | Upgrade Node.js |
httpAgent config |
Apps using httpAgent for proxies |
Replace with customFetch |
clientAssertionSigningAlg config now required |
Apps using clientAssertionSigningKey with a PEM, Buffer, or KeyObject |
Add clientAssertionSigningAlg explicitly |
ES256K / EdDSA removed |
Apps using clientAssertionSigningAlg: 'ES256K' or 'EdDSA' |
Rename EdDSA to Ed25519, no replacement for ES256K |
afterCallback behavior change |
Apps reading req.oidc inside afterCallback to inspect the previous session |
Read previous state before the callback flow starts |
clientAssertionSigningKey type |
TypeScript apps with explicit type annotations on clientAssertionSigningKey |
Update imported types |
v3 depends on openid-client v6 and jose v6, which are ESM-only packages. Node.js added require(ESM) support in:
- v20.19.0 (backported to the v20.x LTS line)
- v22.12.0 (backported to the v22.x LTS line)
- v23.0.0+ (included by default)
| Version | Minimum Node.js |
|---|---|
| v2.x | Node.js 14+ |
| v3.x | ^20.19.0 || ^22.12.0 || >= 23.0.0 |
There is no workaround — you must upgrade Node.js.
The httpAgent option was specific to got, the HTTP client used in v2. v3 uses the native fetch API instead, and httpAgent is no longer supported. Passing it will throw at startup:
TypeError: "httpAgent" is not allowed
The replacement is customFetch, a new config option that accepts a fetch-compatible function. The SDK wraps it to inject the required User-Agent and telemetry headers before making requests.
Before (v2):
const { ProxyAgent } = require('proxy-agent');
app.use(
auth({
httpAgent: new ProxyAgent('http://proxy.example.com:8080'),
}),
);After (v3):
const { ProxyAgent, fetch: undiciFetch } = require('undici');
const dispatcher = new ProxyAgent('http://proxy.example.com:8080');
app.use(
auth({
customFetch: (url, options) => undiciFetch(url, { ...options, dispatcher }),
}),
);See EXAMPLES.md — Use a proxy for OIDC requests for the full example.
Previously, if clientAssertionSigningAlg was omitted, the SDK silently defaulted to RS256. This default has been removed. If your clientAssertionSigningKey is a PEM string, Buffer, KeyObject, or a JWK without an alg property, you must now specify the algorithm explicitly. Omitting it will throw at startup:
TypeError: "clientAssertionSigningAlg" is required when "clientAssertionSigningKey" is a PEM, Buffer, KeyObject, or a JWK without an "alg" property
Before (v2):
app.use(
auth({
clientAssertionSigningKey: '-----BEGIN PRIVATE KEY-----\n...',
// clientAssertionSigningAlg was optional, defaulted to RS256
}),
);After (v3):
app.use(
auth({
clientAssertionSigningKey: '-----BEGIN PRIVATE KEY-----\n...',
clientAssertionSigningAlg: 'RS256', // now required for PEM keys
}),
);Not affected: CryptoKey instances and JWK objects that include an alg property carry the algorithm themselves — clientAssertionSigningAlg remains optional for those.
These two values are no longer accepted for clientAssertionSigningAlg. Passing either will throw a validation error at startup.
| v2 value | v3 replacement |
|---|---|
EdDSA |
Ed25519 |
ES256K |
No replacement, not supported by openid-client v6 |
Before (v2):
app.use(
auth({
clientAssertionSigningAlg: 'EdDSA',
}),
);After (v3):
app.use(
auth({
clientAssertionSigningAlg: 'Ed25519',
}),
);In v2, req.oidc inside afterCallback reflected the previous session state (the user who was logged in before this authentication completed). In v3, req.oidc reflects the incoming user's new tokens.
This only affects you if your afterCallback reads req.oidc.user, req.oidc.isAuthenticated(), req.oidc.accessToken, or req.oidc.idTokenClaims to inspect the prior session.
Before (v2):
app.use(
auth({
async afterCallback(req, res, session) {
// req.oidc.user was the PREVIOUS user (before this login)
const previousUser = req.oidc.user;
return session;
},
}),
);After (v3):
If you need the previous session state, disable the built-in callback route and handle it yourself. This gives you a window after req.oidc is attached (the auth middleware always runs its session setup) but before the callback processing replaces the session:
app.use(
auth({
routes: { callback: false }, // disable the built-in /callback route
async afterCallback(req, res, session) {
// req.oidc.user is now the INCOMING user (new tokens)
// use res.locals.previousUser for the prior state
const previousUser = res.locals.previousUser;
return session;
},
}),
);
// req.oidc is available here — auth() has already run its session setup for this request
app.get('/callback', (req, res) => {
res.locals.previousUser = req.oidc.user; // capture the previous session before it is replaced
res.oidc.callback(); // proceed with OIDC callback processing
});The session argument passed to afterCallback is unchanged — it still contains the new tokens from the current authentication.
Fixed in v3.2.1 — this was a regression introduced in v3.0.0 and is no longer an issue. No migration needed.
v3.0.0 switched from on-headers to a res.end wrapper for writing the session cookie, which caused the cookie to be silently dropped on any response that flushed headers before res.end() — including res.write(), res.flushHeaders(), res.sendFile(), and stream.pipe(res). This affected streaming SSR and similar patterns.
As of v3.2.1, on-headers has been restored. The session cookie is written at writeHead time, matching v2 behaviour exactly. All streaming response patterns work correctly again.
The TypeScript type for clientAssertionSigningKey has been updated to reflect the jose v2 → v6 rename and the addition of Web Crypto CryptoKey support. Runtime behavior is unchanged — all previously supported key forms still work.
| v2 type | v3 type |
|---|---|
KeyInput | KeyObject | JSONWebKey (from jose v2) |
KeyObject | CryptoKey | JWK | string | Buffer (jose v6 / crypto) |
KeyObjectis unchangedKeyInput(jose v2) is replaced by the explicitstring(PEM) andBuffertypes it representedJSONWebKey(jose v2) is renamed toJWKin jose v6CryptoKeyis new — Web Crypto key support added in v3
If you have explicit TypeScript annotations importing KeyInput or JSONWebKey from jose, replace them with string/Buffer and JWK respectively.
All configuration options work exactly as before (except httpAgent, which is replaced by customFetch).
Note for local development: If your
issuerBaseURLuseshttp://(e.g. a local OIDC provider), the SDK automatically enables insecure requests for that issuer. No additional configuration is needed.
app.use(
auth({
authRequired: false,
auth0Logout: true,
baseURL: 'https://example.com',
clientID: 'YOUR_CLIENT_ID',
issuerBaseURL: 'https://YOUR_DOMAIN',
secret: 'LONG_RANDOM_STRING',
idpLogout: true,
idTokenSigningAlg: 'RS256',
clientAuthMethod: 'client_secret_post',
pushedAuthorizationRequests: true,
// ... all other options
}),
);All middleware functions work identically.
const { auth, requiresAuth } = require('express-openid-connect');
app.use(auth(config));
app.get('/admin', requiresAuth(), (req, res) => {
res.send('Admin page');
});The entire req.oidc API remains unchanged.
app.get('/profile', async (req, res) => {
const user = req.oidc.user;
const claims = req.oidc.idTokenClaims;
const isAuthenticated = req.oidc.isAuthenticated();
const idToken = req.oidc.idToken;
const accessToken = req.oidc.accessToken;
const refreshToken = req.oidc.refreshToken;
const userInfo = await req.oidc.fetchUserInfo();
res.oidc.login({});
res.oidc.logout({});
});Custom route configuration remains unchanged.
app.use(
auth({
routes: {
login: '/custom/login',
logout: '/custom/logout',
callback: '/custom/callback',
postLogoutRedirect: '/custom/post-logout',
},
}),
);Session configuration, custom stores, and lifecycle hooks all work the same.
app.use(
auth({
session: {
rolling: true,
rollingDuration: 86400,
absoluteDuration: 86400 * 7,
store: customStore,
},
}),
);All client authentication methods continue to work.
app.use(auth({ clientAuthMethod: 'client_secret_basic' }));
app.use(auth({ clientAuthMethod: 'client_secret_post' }));
app.use(auth({ clientAuthMethod: 'client_secret_jwt' }));
app.use(auth({ clientAuthMethod: 'private_key_jwt' }));
app.use(auth({ clientAuthMethod: 'none' }));