mrkeyoor.com_
Thu 06 Aug 15:42 UTC
npmSecurityupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5v6 has been stable and additive since late 2024, with 6.8.4 in April 2026, but the v5 to v6 change replaced the entire class-based surface with functions and no migration tool, and plenty of tutorials still teach new Issuer(...) which no longer exists
Docs4/5Generated API reference for every function, interface and error with runnable examples, plus a repository of complete flow examples with diffs from the base OIDC case; what is missing is a narrative guide connecting a code exchange to an actual session in a web framework
Maintenance5/5Pushed 2026-08-04 with 0 open issues and 0 open PRs, an explicit supported-versions table, a published security policy, and certification against the Basic, FAPI 1.0 and FAPI 2.0 relying-party profiles; the risk is that this is one maintainer funded by sponsorship
Ecosystem4/5About 12.6M downloads a week and the client underneath many Node auth stacks, with a first-party Passport strategy and distribution on npm, JSR and GitHub, though most application developers meet it indirectly through Auth.js or a framework wrapper

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
Skip it if

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

PackageRegistryPick it when
oauth4webapinpmYou want the same author's lower-level primitives with no Configuration abstraction and even fewer opinions
next-authnpmYou want sessions, cookies, callbacks and provider presets in a Next.js app instead of raw protocol calls
@auth/corenpmYou want the Auth.js session and provider machinery in a framework other than Next.js