OAuth 2.1 Provider Framework for Cloudflare Workers
This is a TypeScript library that implements the provider side of the OAuth 2.1 protocol with PKCE support. The library is intended to be used on Cloudflare Workers.
Benefits of this library
- The library acts as a wrapper around your Worker code, which adds authorization for your API endpoints.
- All token management is handled automatically.
- Your API handler is written like a regular fetch handler, but receives the already-authenticated user details as a parameter. No need to perform any checks of your own.
- The library is agnostic to how you manage and authenticate users.
- The library is agnostic to how you build your UI. Your authorization flow can be implemented using whatever UI framework you use for everything else.
- The library's storage does not store any secrets, only hashes of them.
Usage
A Worker that uses the library might look like this:
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { WorkerEntrypoint } from 'cloudflare:workers';
export default new OAuthProvider({
apiRoute: [
'/api/',
'https://api.example.com/',
],
apiHandler: ApiHandler,
defaultHandler: defaultHandler,
authorizeEndpoint: 'https://example.com/authorize',
tokenEndpoint: 'https://example.com/oauth/token',
clientRegistrationEndpoint: 'https://example.com/oauth/register',
scopesSupported: ['document.read', 'document.write', 'profile'],
allowImplicitFlow: false,
allowPlainPKCE: true,
disallowPublicClientRegistration: false,
refreshTokenTTL: 2592000,
accessTokenTTL: 3600,
clientRegistrationTTL: 7776000,
allowTokenExchangeGrant: false,
enterpriseManagedAuthorization: undefined,
clientIdMetadataDocumentEnabled: false,
});
const defaultHandler = {
async fetch(request: Request, env, ctx) {
let url = new URL(request.url);
if (url.pathname == '/authorize') {
let oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request);
let clientInfo = await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId);
let { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReqInfo,
userId: '1234',
metadata: { label: 'foo' },
scope: ['document.read', 'document.write'],
props: {
userId: 1234,
username: 'Bob',
},
});
return Response.redirect(redirectTo, 302);
}
return new Response('Not found', { status: 404 });
},
};
class ApiHandler extends WorkerEntrypoint {
fetch(request: Request) {
let url = new URL(request.url);
if (url.pathname == '/api/whoami') {
return new Response(`You are authenticated as: ${this.ctx.props.username}`);
}
return new Response('Not found', { status: 404 });
}
}
By default, completeAuthorization() revokes existing grants for the same user and client after storing the new
grant. This prevents stale tokens from continuing to use old props after a user re-authorizes. Set
revokeExistingGrants: false only if your application intentionally allows multiple concurrent grants for the same
user and client.
For users with many grants, revokeExistingGrantsBatchSize controls the KV page size used while scanning existing
grants for revocation. It defaults to 50, must be a positive integer, and is capped at Cloudflare KV's maximum page
size of 1000.
This implementation requires that your worker is configured with a Workers KV namespace binding called OAUTH_KV, which is used to store token information. See the file storage-schema.md for details on the schema of this namespace.
The env.OAUTH_PROVIDER object available to the fetch handlers provides some methods to query the storage, including:
- Create, list, modify, and delete client_id registrations (in addition to
lookupClient(), already shown in the example code).
- List all active authorization grants for a particular user.
- Revoke (delete) an authorization grant.
- Purge expired and orphaned data from the KV namespace.
Note that deleteClient() cascades: it revokes all grants (and their associated tokens) for the deleted client across all users.
See the OAuthHelpers interface definition for full API details.
Token Exchange Callback
This library allows you to update the props value during token exchanges by configuring a callback function. This is useful for scenarios where the application needs to perform additional processing when tokens are issued or refreshed.
For example, if your application is also a client to some other OAuth API, you might want to perform an equivalent upstream token exchange and store the result in the props. The callback can be used to update the props for both the grant record and specific access tokens.
To use this feature, provide a tokenExchangeCallback in your OAuthProvider options:
new OAuthProvider({
tokenExchangeCallback: async (options) => {
if (options.grantType === 'authorization_code') {
const upstreamTokens = await exchangeUpstreamToken(options.props.someCode);
return {
accessTokenProps: {
...options.props,
upstreamAccessToken: upstreamTokens.access_token,
},
newProps: {
...options.props,
upstreamRefreshToken: upstreamTokens.refresh_token,
},
};
}
if (options.grantType === 'refresh_token') {
const upstreamTokens = await refreshUpstreamToken(options.props.upstreamRefreshToken);
return {
accessTokenProps: {
...options.props,
upstreamAccessToken: upstreamTokens.access_token,
},
newProps: {
...options.props,
upstreamRefreshToken: upstreamTokens.refresh_token || options.props.upstreamRefreshToken,
},
accessTokenTTL: upstreamTokens.expires_in,
};
}
},
});
The callback can:
- Return both
accessTokenProps and newProps to update both
- Return only
accessTokenProps to update just the current access token
- Return only
newProps to update both the grant and access token (the access token inherits these props)
- Return
accessTokenTTL to override the default TTL for this specific access token
- Return
refreshTokenTTL to override the default TTL for this specific refresh token
- Return nothing to keep the original props unchanged
The accessTokenTTL override is particularly useful when the application is also an OAuth client to another service and wants to match its access token TTL to the upstream access token TTL. This helps prevent situations where the downstream token is still valid but the upstream token has expired.
The props values are end-to-end encrypted, so they can safely contain sensitive information.
Reporting errors from the callback
Throw OAuthError from tokenExchangeCallback to return a structured OAuth /token error ({ error, error_description }) instead of a generic 500:
import { OAuthError, OAuthProvider } from '@cloudflare/workers-oauth-provider';
new OAuthProvider({
tokenExchangeCallback: async (options) => {
if (options.grantType === 'refresh_token') {
return { newProps: await refreshUpstream(options.props) };
}
},
});
async function refreshUpstream(props) {
const res = await fetch();
if (res.status === 401) {
throw new OAuthError('invalid_grant', {
description: 'upstream refresh token is invalid',
});
}
if (res.status === 429) {
throw new OAuthError('temporarily_unavailable', {
description: 'upstream rate limited',
statusCode: 429,
headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
});
}
return await res.json();
}
OAuthError(code, options) takes:
code — OAuth error code returned in the error field. This may be a standard code (OAuthTokenErrorCode) or an application-defined string.
options.description — human-readable text returned in error_description.
options.statusCode — HTTP status code (default 400).
options.headers — additional response headers, such as Retry-After for transient failures. There is no implicit Retry-After default for callback-thrown errors.
Only OAuthError from this package is converted into a structured /token response. Plain errors, plain objects with a code field, and app-local error classes continue to surface as 500s so unexpected failures stay visible. Import OAuthError from @cloudflare/workers-oauth-provider rather than copying or re-implementing it.
Enterprise-Managed Authorization (Experimental)
Accepts ID-JAG assertions at /token per the MCP Enterprise-Managed Authorization extension. The enterprise IdP issues an ID-JAG JWT and the MCP client exchanges it here for an opaque access token.
new OAuthProvider({
resourceMetadata: { resource: 'https://mcp.example.com/mcp' },
enterpriseManagedAuthorization: {
trustedIssuers: async ({ iss }) =>
iss === 'https://idp.example.com'
? { issuer: iss, jwksUri: 'https://idp.example.com/.well-known/jwks.json', algorithms: ['RS256'] }
: null,
async mapClaims({ claims, requestedScope }) {
return {
userId: `enterprise-${claims.sub}`,
scope: requestedScope,
metadata: { enterpriseIssuer: claims.iss, enterpriseSubject: claims.sub },
props: { enterprise: true, subject: claims.sub, email: claims.email },
};
},
},
});
Setup:
- Configure your IdP as an ID-JAG issuer with this worker's origin as the resource and a public JWKS endpoint.
- Set
resourceMetadata.resource to the MCP endpoint URL (required when EMA is enabled).
- Implement
trustedIssuers as a resolver — for multi-tenant deployments it can read env / clientInfo to look up per-tenant IdP config without redeploying.
The AS enforces resolved.issuer === iss (confused-deputy guard) and validates ID-JAG typ, signature, audience, client binding, resource, exp / iat / nbf, max lifetime, and jti replay. Refresh tokens are not issued for this grant — the ID-JAG itself is the renewable assertion.
Public clients
By default the EMA grant requires client authentication, so public clients (token_endpoint_auth_method: 'none') are rejected. Set allowPublicClients: true to also accept them:
enterpriseManagedAuthorization: {
allowPublicClients: true,
}
This is useful for clients registered via a Client ID Metadata Document (CIMD), which are always public and therefore cannot present a client secret. With this enabled, trust rests on the IdP-issued, signature-verified, short-lived, single-use ID-JAG assertion (audience-, resource-, and client-bound) rather than on a separately presented client secret. Leave it unset (default false) to keep the spec-default behavior of requiring client authentication.
Experimental — the MCP extension is still a draft.
Custom Error Responses
By using the onError option, you can emit notifications or take other actions when an error response was to be emitted:
new OAuthProvider({
onError({ code, description, status, headers }) {
Sentry.captureMessage();
},
});
By returning a Response you can also override what the OAuthProvider returns to your users:
new OAuthProvider({
onError({ code, description, status, headers }) {
if (code === 'unsupported_grant_type') {
return new Response('...', { status, headers });
}
},
});
By default, the onError callback is set to ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`).
KV Namespace Cleanup
The library uses KV TTLs to automatically expire access tokens, refresh tokens (grants), and dynamically registered clients. As defense-in-depth, the library also provides a purgeExpiredData() method that cleans up orphaned and expired records. This is designed to be called from a Cron Trigger (scheduled handler):
const oauthProvider = new OAuthProvider({
});
export default {
fetch(request, env, ctx) {
return oauthProvider.fetch(request, env, ctx);
},
async scheduled(event, env, ctx) {
const result = await oauthProvider.purgeExpiredData(env, { batchSize: 100 });
console.log(`Checked ${result.grantsChecked} grants, purged ${result.grantsPurged}`);
},
};
The method processes records in configurable batches (default: 50) to stay within Cloudflare's subrequest limits. It performs two sweep phases:
- Grant sweep: Removes orphaned grants (whose client no longer exists) and expired grants.
- Token sweep: Removes orphaned tokens (whose grant no longer exists).
Call it repeatedly via a cron trigger — deleted records disappear from KV, so subsequent invocations naturally process fresh records without needing a persisted cursor. The result.done field indicates whether the full key space was scanned in this invocation.
Protected Resource Metadata (RFC 9728)
The library automatically serves a /.well-known/oauth-protected-resource endpoint. By default, it uses the request origin as the resource identifier and the token endpoint's origin as the authorization server. You can customize this with the resourceMetadata option:
new OAuthProvider({
resourceMetadata: {
resource: 'https://api.example.com',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['read', 'write'],
bearer_methods_supported: ['header'],
resource_name: 'My API',
},
});
Standards Compliance
This library implements the following OAuth and MCP specifications:
These are the specifications required by the MCP authorization specification.
Implementation Notes
End-to-end encryption
This library stores records about authorization tokens in KV. The storage schema is carefully designed such that a complete leak of the storage only reveals mundane metadata about what has been granted. In particular:
- Secrets (including access tokens, refresh tokens, authorization codes, and client secrets) are stored only by hash. Hence, such secrets cannot be derived from the storage alone.
- The
props associated with a grant (which are passed back to the application when API requests are performed) are stored encrypted with the secret token as key material. Hence, the contents of props are impossible to derive from storage unless a valid token is provided.
Note that the userId and the metadata associated with each grant are not encrypted, because the purpose of these values is to allow grants to be enumerated for audit and revocation purposes. However, these values are completely opaque to the library. An application is free to omit them or apply its own encryption to them before passing them into the library, if it desires.
Single-use refresh tokens?
OAuth 2.1 requires that refresh tokens are either "cryptographically bound" to the client, or are single-use. This library currently does not implement any cryptographic binding, thus seemingly requiring single-use tokens. Under this requirement, every token refresh request invalidates the old refresh token and issues a new one.
This requirement is seemingly fundamentally flawed as it assumes that every refresh request will complete with no errors. In the real world, a transient network error, machine failure, or software fault could mean that the client fails to store the new refresh token after a refresh request. In this case, the client would be permanently unable to make any further requests, as the only token it has is no longer valid.
This library implements a compromise: At any particular time, a grant may have two valid refresh tokens. When the client uses one of them, the other one is invalidated, and a new one is generated and returned. Thus, if the client correctly uses the new refresh token each time, then older refresh tokens are continuously invalidated. But if a transient failure prevents the client from updating its token, it can always retry the request with the token it used previously.
Client ID Metadata Document (CIMD) Support
This library supports Client ID Metadata Documents, which allow clients to use HTTPS URLs as their client_id. When a client presents an HTTPS URL with a non-root path as its client_id, the library will fetch and validate the metadata document from that URL.
Enabling CIMD
CIMD support is opt-in and requires two things:
- Set
clientIdMetadataDocumentEnabled: true in your OAuthProvider options:
new OAuthProvider({
clientIdMetadataDocumentEnabled: true,
});
- Add the
global_fetch_strictly_public compatibility flag to your wrangler.jsonc:
{
"compatibility_flags": ["global_fetch_strictly_public"],
}
The compatibility flag is required for SSRF (Server-Side Request Forgery) protection. Due to a legacy quirk, fetch() requests to URLs within your zone's domain are sent directly to the origin server, bypassing Cloudflare. The global_fetch_strictly_public flag disables this behavior. See Cloudflare's documentation for more details.
When CIMD is not enabled (the default), URL-formatted client_id values fall through to standard KV lookup. When enabled, if fetching the metadata document fails, the library logs a warning and returns an invalid_client error, allowing MCP clients to recover by falling back to Dynamic Client Registration.
The OAuth metadata endpoint reports client_id_metadata_document_supported: true only when both the option is enabled and the compatibility flag is present.
Written using Claude
This library (including the schema documentation) was largely written with the help of Claude, the AI model by Anthropic. Claude's output was thoroughly reviewed by Cloudflare engineers with careful attention paid to security and compliance with standards. Many improvements were made on the initial output, mostly again by prompting Claude (and reviewing the results). Check out the commit history to see how Claude was prompted and what code it produced.
"NOOOOOOOO!!!! You can't just use an LLM to write an auth library!"
"haha gpus go brrr"
In all seriousness, two months ago (January 2025), I (@kentonv) would have agreed. I was an AI skeptic. I thought LLMs were glorified Markov chain generators that didn't actually understand code and couldn't produce anything novel. I started this project on a lark, fully expecting the AI to produce terrible code for me to laugh at. And then, uh... the code actually looked pretty good. Not perfect, but I just told the AI to fix things, and it did. I was shocked.
To emphasize, this is not "vibe coded". Every line was thoroughly reviewed and cross-referenced with relevant RFCs, by security experts with previous experience with those RFCs. I was trying to validate my skepticism. I ended up proving myself wrong.
Again, please check out the commit history -- especially early commits -- to understand how this went.