openid-client
openid-client is a spec-faithful OAuth 2.0 and OpenID Connect relying-party client for JavaScript. You point it at an authorization server's issuer URL, it fetches the discovery document and returns a Configuration object, and from there module-level functions do the protocol work: build an authorization URL with PKCE, exchange the code, refresh, introspect, revoke, call UserInfo, and build the logout URL. It covers the awkward extensions too, including PAR, JAR, JARM, DPoP, CIBA, the device flow and dynamic client registration. Its author has run it through OpenID certification for the Basic, FAPI 1.0 Advanced and FAPI 2.0 relying-party profiles. It is built on Web Crypto and fetch rather than Node internals, so the same code runs on Node, Deno, Bun, Cloudflare Workers, Electron and browsers.
The most correct OpenID Connect client on npm, and the right pick when you are talking to a real identity provider and the details of PKCE, DPoP or FAPI actually matter. If what you wanted was a login button and a session cookie, this is a protocol library and Auth.js is the shorter path.
Use it if
- You are integrating with a real OpenID Provider (Keycloak, Auth0, Okta, Entra ID, Ping) and want the state, nonce, PKCE, issuer and ID token signature checks done correctly rather than approximated
- You need extensions that hand-rolled clients skip: DPoP sender-constrained tokens with automatic nonce retry, pushed authorization requests, JWT-secured authorization requests and responses, or CIBA
- You are in a regulated setting where FAPI 1.0 Advanced or FAPI 2.0 conformance matters and you would rather point at a certification than argue about your own implementation
- You deploy outside plain Node, since the library targets Web Crypto and fetch and runs unchanged on Workers, Deno, Bun and Electron
- You already use Passport and want a strategy that is maintained alongside the protocol code, which the openid-client/passport export provides
- You want authentication rather than a protocol client. There is no session, no cookie, no user table, no CSRF layer and no login UI here; wiring code exchange to a logged-in user is entirely your job. Auth.js or NextAuth gets you to a session in an afternoon
- You are upgrading from v5. Version 6 is a rewrite: the Issuer and Client classes are gone, replaced by a Configuration object and free functions, with no codemod. Every v5 blog post, StackOverflow answer and internal wrapper you own has to be rewritten
- You need CommonJS on an older Node. The package is ESM only, and require() of it works only where Node enables require(esm) by default, meaning ^20.19.0, ^22.12.0 or 23 and up
- Your identity provider is sloppy. The library refuses plain HTTP endpoints unless you call the deliberately deprecated allowInsecureRequests, validates the issuer identifier, and rejects non-conforming Response objects from a custom fetch. That strictness is the point, but it turns a broken internal IdP into your problem
- You do not want to read specs. Function names map to RFC concepts rather than to product features, so without knowing what nonce, PKCE, resource indicators and token_type: dpop mean you will be reading RFC 9449 rather than a quickstart
- You need vendor support behind the dependency. It is a single-maintainer project funded by sponsorship, and while the response record is excellent, that is one person for a library sitting on your login path
Setup reality
npm install openid-client brings two dependencies, both from the same author: jose for JWT work and oauth4webapi for the protocol primitives. No native code, about 25 KB gzipped. The real constraints are runtime shape rather than install: the package is ESM only with exports for the root and openid-client/passport, Node 20 is the baseline, and CommonJS consumers need a Node version where require(esm) is on by default. Everything runs through global fetch and Web Crypto, so a runtime missing either will not work, and corporate proxies or mTLS mean setting config[client.customFetch] to an undici or ky wrapper. Discovery only accepts HTTPS issuers, and against a local Keycloak on http you must pass execute: [client.allowInsecureRequests] to discovery. PKCE state must survive the redirect, so you need the code_verifier and state stored in a session before you send the user away.
Patterns
Discover the server and build a Configurationdiscover-configuration
import * as client from 'openid-client'
const config = await client.discovery(
new URL('https://id.example.com'),
process.env.CLIENT_ID,
process.env.CLIENT_SECRET
)
// local dev against a non-TLS provider only:
// await client.discovery(server, clientId, secret, undefined, {
// execute: [client.allowInsecureRequests]
// })Pass the issuer identifier, not the /.well-known/openid-configuration URL. Passing the document URL directly skips issuer validation, which the docs explicitly discourage. Build the Configuration once at startup and reuse it; discovery is a network call.
Send the user to the provider with PKCEbuild-authorization-url
const code_verifier = client.randomPKCECodeVerifier()
const code_challenge = await client.calculatePKCECodeChallenge(code_verifier)
const parameters = {
redirect_uri: 'https://app.example.com/callback',
scope: 'openid email profile',
code_challenge,
code_challenge_method: 'S256'
}
let state
if (!config.serverMetadata().supportsPKCE()) {
state = client.randomState()
parameters.state = state
}
req.session.code_verifier = code_verifier
req.session.state = state
res.redirect(client.buildAuthorizationUrl(config, parameters).href)Generate a fresh verifier and state for every redirect and store both server-side before redirecting. The supportsPKCE() check adds state only when the server does not advertise PKCE, because PKCE alone already binds the request.
Exchange the authorization code for tokenshandle-callback
const currentUrl = new URL(req.originalUrl, 'https://app.example.com')
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: req.session.code_verifier,
expectedState: req.session.state,
expectedNonce: req.session.nonce
})
const claims = tokens.claims()
req.session.user = { sub: claims.sub, email: claims.email }Pass the full callback URL including the query string, not just the code. claims() on the response returns the verified ID token payload, so you never have to decode a JWT by hand. Clear the verifier, state and nonce from the session immediately after.
Distinguish the failure modeshandle-errors
try {
const tokens = await client.authorizationCodeGrant(config, currentUrl, checks)
} catch (err) {
if (err instanceof client.AuthorizationResponseError) {
// user hit Deny, or the AS rejected the request
return res.status(400).send(err.error) // e.g. 'access_denied'
}
if (err instanceof client.ResponseBodyError) {
// token endpoint returned an OAuth error body
return res.status(400).send(err.error)
}
throw err
}AuthorizationResponseError carries the redirect query parameters on cause and the OAuth code on error, so never string-match the message. A WWWAuthenticateChallengeError from a resource request usually means the access token expired or is missing a scope.
Refresh an expired access tokenrefresh-tokens
const refreshed = await client.refreshTokenGrant(
config,
storedRefreshToken,
{ scope: 'openid email', resource: 'https://api.example.com' }
)
await store.save(userId, {
access_token: refreshed.access_token,
refresh_token: refreshed.refresh_token ?? storedRefreshToken,
expires_at: Date.now() + (refreshed.expires_in ?? 0) * 1000
})Many providers rotate refresh tokens, so persist the new one when it comes back and keep the old one when it does not. With rotation on, replaying a used refresh token can invalidate the whole grant chain, so do not refresh from two workers at once.
Get a machine-to-machine tokenclient-credentials
const tokens = await client.clientCredentialsGrant(config, {
scope: 'reports:read',
resource: 'https://api.example.com'
})
const res = await fetch('https://api.example.com/reports', {
headers: { authorization: `Bearer ${tokens.access_token}` }
})There is no user and no ID token in this flow, so calling claims() returns undefined. Cache the token until shortly before expires_in rather than requesting one per outbound call; the token endpoint is rate limited on most providers.
Read UserInfo and call protected resourcesfetch-userinfo
const claims = tokens.claims()
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub)
const resource = await client.fetchProtectedResource(
config,
tokens.access_token,
new URL('https://api.example.com/me'),
'GET'
)The third argument to fetchUserInfo is the expected sub and it is mandatory: pass the one from the ID token claims so a mixed-up response is caught. client.skipSubjectCheck exists but is deprecated for a reason. fetchProtectedResource matters mainly under DPoP, where it attaches the proof for you.
Log the user out at the providerrp-initiated-logout
const redirectTo = client.buildEndSessionUrl(config, {
post_logout_redirect_uri: 'https://app.example.com/goodbye',
id_token_hint: storedIdToken
})
req.session.destroy(() => res.redirect(redirectTo.href))This throws if the discovery document has no end_session_endpoint, which several providers omit. Destroy your own session regardless; a provider logout does not clear your cookie, and your cookie surviving is what actually keeps the user logged in.
Authenticate the client with a key instead of a secretprivate-key-jwt
const key = await crypto.subtle.importKey(
'pkcs8',
pkcs8Bytes,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign']
)
const config = await client.discovery(
new URL('https://id.example.com'),
clientId,
{ token_endpoint_auth_method: 'private_key_jwt' },
client.PrivateKeyJwt(key)
)The fourth argument to discovery selects the client authentication method; the default is ClientSecretPost. ClientSecretBasic, ClientSecretJwt, TlsClientAuth and None are the other built-ins. FAPI profiles require private_key_jwt or mTLS, so this is the usual starting point there.
Bind tokens to a key with DPoPdpop-sender-constrained
const keyPair = await client.randomDPoPKeyPair()
const DPoP = client.getDPoPHandle(config, keyPair)
const tokens = await client.authorizationCodeGrant(
config,
currentUrl,
{ pkceCodeVerifier: verifier },
undefined,
{ DPoP }
)
const res = await client.fetchProtectedResource(
config,
tokens.access_token,
new URL('https://api.example.com/me'),
'GET',
undefined,
undefined,
{ DPoP }
)Reuse one handle across the token request and every resource request for that session; it tracks server-issued nonces and retries automatically when the server demands a fresh one. Whether the token ends up bound is the server's choice, so check that token_type came back as dpop before assuming it.
Log in a device with no browserdevice-authorization
const handle = await client.initiateDeviceAuthorization(config, {
scope: 'openid profile'
})
console.log(`Go to ${handle.verification_uri} and enter ${handle.user_code}`)
const tokens = await client.pollDeviceAuthorizationGrant(config, handle)
console.log(tokens.claims().sub)pollDeviceAuthorizationGrant blocks until the user finishes or the code expires, honouring the interval the server asked for. Show verification_uri_complete as a QR code when the server provides it, since it embeds the code.
Plug it into Passportpassport-strategy
import * as client from 'openid-client'
import { Strategy, type VerifyFunction } from 'openid-client/passport'
import passport from 'passport'
const config = await client.discovery(server, clientId, clientSecret)
const verify: VerifyFunction = (tokens, verified) => {
verified(null, tokens.claims())
}
passport.use(new Strategy({ config, scope: 'openid email', callbackURL }, verify))
app.get('/login', passport.authenticate(strategyName))The strategy lives at the openid-client/passport subpath, not the root export. It needs express-session mounted before it because it stores the PKCE verifier and state there, and passport.authenticate('session') has to run first on every request.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| oauth4webapi | npm | You want the same author's lower-level primitives with no Configuration abstraction and even fewer opinions |
| next-auth | npm | You want sessions, cookies, callbacks and provider presets in a Next.js app instead of raw protocol calls |
| @auth/core | npm | You want the Auth.js session and provider machinery in a framework other than Next.js |