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

oauth4webapi

oauth4webapi is a low-level OAuth 2 and OpenID Connect client toolkit built only on Web platform APIs: fetch, WebCrypto, URL, and Request/Response. It gives you one function per protocol step, always in pairs, so you call discoveryRequest then processDiscoveryResponse, authorizationCodeGrantRequest then processAuthorizationCodeResponse. It does not manage sessions, cookies, or redirects; you write that glue. Because it avoids Node-specific APIs, the same code runs in browsers, Deno, Bun, Cloudflare Workers, Electron, and Node. The author has certified it against the Basic, FAPI 1.0, and FAPI 2.0 Relying Party conformance profiles.

Verdict

The right pick when you are implementing OAuth correctly on the edge or need FAPI-grade features, and the wrong pick when you wanted authentication rather than a protocol library. Budget for writing the session and redirect plumbing yourself.

API stability4/5v3 has been the only supported line for a while and minor releases have stayed additive, but the 2-to-3 jump reworked client authentication into ClientSecretPost and PrivateKeyJwt factory functions, so most older examples on the internet no longer compile.
Docs4/5Every export has a generated reference page with the RFC it implements, and there are runnable examples per grant plus diffs showing exactly what changes for OIDC, PAR, DPoP, and FAPI; there is no prose guide, so first-time readers assemble the flow from examples.
Maintenance5/5Pushed within days of this review, zero open issues on GitHub, a published security policy, and OpenID certification for the Basic, FAPI 1.0, and FAPI 2.0 relying party profiles.
Ecosystem3/5Zero dependencies and distribution on npm, JSR, jsDelivr, and GitHub, but almost nothing is built on top of it: the surrounding tooling lives around Auth.js and openid-client instead, so integration code is yours to write.

Use it if

  • You need OAuth in a runtime that is not Node: Cloudflare Workers, Deno, Bun, or the browser, where libraries built on node:crypto do not load
  • You need the parts most client libraries skip: DPoP, Pushed Authorization Requests, JAR, JARM, CIBA, Dynamic Client Registration, or FAPI 2.0 profiles
  • You are the resource server and want validateJwtAccessToken to check iss, aud, exp, the JWKS signature, and the DPoP proof for RFC 9068 tokens
  • You want an audited protocol layer with zero dependencies and a tree-shakeable ESM build instead of a framework with an opinion about your session store
Skip it if

Setup reality

npm i oauth4webapi and there is nothing else to install: no dependencies, TypeScript types in the package, no build step. The cost lands in your application code instead. The module is ESM only with Node 20 as the baseline, so a CommonJS project needs a Node version where require(esm) is enabled. A working authorization code flow means writing two route handlers, generating a code_verifier and state or nonce on the first one, and reading them back out of a server-side session on the second. Discovery runs on every call unless you add caching through the customFetch symbol option, and allowInsecureRequests is now deprecated, which makes plain http issuers in local development awkward. Errors arrive as typed classes such as ResponseBodyError and WWWAuthenticateChallengeError, so plan on a real error branch rather than a try/catch that logs.

Patterns

Discover authorization server metadatadiscover-issuer

import * as oauth from 'oauth4webapi'

const issuer = new URL('https://as.example.com')

const as = await oauth
  .discoveryRequest(issuer, { algorithm: 'oidc' })
  .then((response) => oauth.processDiscoveryResponse(issuer, response))

algorithm: 'oidc' hits /.well-known/openid-configuration and 'oauth2' hits /.well-known/oauth-authorization-server. It defaults to oidc, which is why plain OAuth 2 servers return 404 if you forget the option.

Build an authorization URL with PKCEauthorization-url

const client: oauth.Client = { client_id }

const code_verifier = oauth.generateRandomCodeVerifier()
const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier)
const state = oauth.generateRandomState()

const url = new URL(as.authorization_endpoint!)
url.searchParams.set('client_id', client_id)
url.searchParams.set('redirect_uri', redirect_uri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid email')
url.searchParams.set('code_challenge', code_challenge)
url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', state)

// persist code_verifier and state in the user session, then redirect

The library does not store anything for you. If code_verifier and state are not in a server-side session keyed to this browser, the callback cannot be validated and you have shipped a CSRF hole.

Validate the callback and exchange the codecode-exchange

const clientAuth = oauth.ClientSecretPost(client_secret)

const params = oauth.validateAuthResponse(as, client, currentUrl, state)

const response = await oauth.authorizationCodeGrantRequest(
  as,
  client,
  clientAuth,
  params,
  redirect_uri,
  code_verifier,
)

const result = await oauth.processAuthorizationCodeResponse(as, client, response)
const { access_token, refresh_token } = result

validateAuthResponse throws AuthorizationResponseError when the server returned error=access_denied, so treat that separately from a network failure. Pass oauth.skipStateCheck only when you genuinely sent no state.

Require an ID token and read UserInfooidc-claims

const nonce = oauth.generateRandomNonce() // set on the authorization URL

const result = await oauth.processAuthorizationCodeResponse(as, client, response, {
  expectedNonce: nonce,
  requireIdToken: true,
})

const claims = oauth.getValidatedIdTokenClaims(result)!
const { sub } = claims

const uiResponse = await oauth.userInfoRequest(as, client, result.access_token)
const profile = await oauth.processUserInfoResponse(as, client, sub, uiResponse)

processUserInfoResponse takes the sub from the ID token and rejects a UserInfo body whose sub does not match, which is a check most hand-rolled OIDC clients forget.

Refresh an access tokenrefresh-token

const response = await oauth.refreshTokenGrantRequest(as, client, clientAuth, refresh_token)

const result = await oauth.processRefreshTokenResponse(as, client, response)

// rotating servers return a new refresh_token; persist it or the next refresh fails
const next_refresh_token = result.refresh_token ?? refresh_token

Nothing refreshes automatically. You check expires_in yourself, and with refresh token rotation you must store the new value before the old one is invalidated.

Machine-to-machine client credentials grantclient-credentials

const parameters = new URLSearchParams({ scope: 'api:read' })

const response = await oauth.clientCredentialsGrantRequest(as, client, clientAuth, parameters)

const { access_token, expires_in } = await oauth.processClientCredentialsResponse(
  as,
  client,
  response,
)

There is no token cache in the library, so a naive service calls the token endpoint on every outbound request. Cache the token until expires_in minus a safety margin.

Authenticate the client with a private key instead of a secretprivate-key-jwt

const { privateKey, publicKey } = await oauth.generateKeyPair('ES256', { extractable: true })

const clientAuth = oauth.PrivateKeyJwt(privateKey)

// other options: oauth.ClientSecretBasic, oauth.ClientSecretJwt,
// oauth.TlsClientAuth, oauth.None for public clients

Client authentication moved from a client metadata field to these factory functions in v3, which is the single most common reason a v2 snippet fails to type-check under v3.

Bind tokens to a key with DPoPdpop

const DPoPKeys = await oauth.generateKeyPair('ES256')
const DPoP = oauth.DPoP(client, DPoPKeys)

let response = await oauth.clientCredentialsGrantRequest(as, client, clientAuth, params, {
  DPoP,
})

if (oauth.isDPoPNonceError(await oauth.processClientCredentialsResponse(as, client, response).catch((e) => e))) {
  // the handle stored the server nonce; retry once
  response = await oauth.clientCredentialsGrantRequest(as, client, clientAuth, params, { DPoP })
}

Servers routinely reject the first DPoP request with use_dpop_nonce. The DPoP handle records the nonce from that response, so the retry succeeds; without a retry branch DPoP looks broken.

Call a protected APIcall-resource

try {
  const response = await oauth.protectedResourceRequest(
    access_token,
    'GET',
    new URL('https://rs.example.com/api/me'),
    new Headers({ accept: 'application/json' }),
  )
  const data = await response.json()
} catch (err) {
  if (err instanceof oauth.WWWAuthenticateChallengeError) {
    // token expired or insufficient_scope; inspect err.cause
  }
  throw err
}

A WWW-Authenticate challenge is thrown rather than returned, so a plain await without a catch turns an expired token into an unhandled rejection.

Introspect and revoke a tokenintrospect-revoke

const iResponse = await oauth.introspectionRequest(as, client, clientAuth, token)
const info = await oauth.processIntrospectionResponse(as, client, iResponse)
if (!info.active) throw new Error('token is not active')

const rResponse = await oauth.revocationRequest(as, client, clientAuth, token, {
  additionalParameters: { token_type_hint: 'refresh_token' },
})
await oauth.processRevocationResponse(rResponse)

processRevocationResponse resolves with undefined on success, so there is nothing to assert on. Revoking a refresh token usually kills the whole grant, including access tokens issued from it.

Validate a JWT access token on the resource servervalidate-access-token

// inside your API handler, given the incoming Request
const claims = await oauth.validateJwtAccessToken(as, request, 'https://rs.example.com')

if (!claims.scope?.split(' ').includes('api:write')) {
  return new Response(null, { status: 403 })
}

It checks iss, aud, exp and the JWKS signature only. Scope, sub, jti, and client_id are explicitly left to you, and it does not validate a resource-server-issued DPoP nonce.

Cache metadata and JWKS with customFetchcustom-fetch

const cache = new Map<string, Response>()

const as = await oauth
  .discoveryRequest(issuer, {
    [oauth.customFetch]: async (url, init) => {
      const key = String(url)
      const hit = cache.get(key)
      if (hit) return hit.clone()
      const res = await fetch(url, init)
      if (res.ok) cache.set(key, res.clone())
      return res
    },
  })
  .then((response) => oauth.processDiscoveryResponse(issuer, response))

Discovery and JWKS are refetched on every call by default, which on a hot login path means two extra round trips per sign-in. Clone the Response before caching, because a body can only be read once.

Alternatives

PackageRegistryPick it when
openid-clientnpmYou are on Node only and want the same author with a higher-level client object that tracks issuer metadata for you.
arcticnpmYou need social login against a fixed list of providers and want per-provider classes instead of raw protocol calls.
josenpmYou only need to sign or verify JWTs and JWKS, with no OAuth endpoints involved at all.