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.
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.
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
- You just want a login button on a Next.js or Express app: this is a protocol toolkit, not an auth solution, and Auth.js or openid-client will get you there in a fraction of the code
- You cannot store per-request state server side: PKCE code_verifier, state, and nonce all have to survive the redirect in a session you manage, and getting that wrong is a real vulnerability rather than a bug report
- Your build is CommonJS: the package is ESM only, Node 20 is the stated baseline, and require() only works on Node builds where require(esm) is enabled
- You want convenience: there is no token cache, no automatic refresh, no discovery caching, and no cookie handling; every one of those is your code
- You need a team-sized bus factor: this is essentially one maintainer, and the security-critical position it occupies makes that worth thinking about even though the release cadence is excellent
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 redirectThe 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 } = resultvalidateAuthResponse 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_tokenNothing 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 clientsClient 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
| Package | Registry | Pick it when |
|---|---|---|
| openid-client | npm | You are on Node only and want the same author with a higher-level client object that tracks issuer metadata for you. |
| arctic | npm | You need social login against a fixed list of providers and want per-provider classes instead of raw protocol calls. |
| jose | npm | You only need to sign or verify JWTs and JWKS, with no OAuth endpoints involved at all. |