@cloudflare/workers-oauth-provider adds OAuth 2.1 authorization to HTTP APIs and remote MCP servers running on Cloudflare Workers.
npm install @cloudflare/workers-oauth-providerThe Worker needs a KV namespace bound as OAUTH_KV:
To enable Client ID Metadata Documents, also add Cloudflare's SSRF protection compatibility flag:
{
"compatibility_flags": ["global_fetch_strictly_public"],
}See Client registration for the matching provider option.
The provider accepts either plain ExportedHandler objects or classes extending WorkerEntrypoint. This example uses both.
import {
AuthorizationError,
OAuthProvider,
type AuthRequest,
type OAuthHelpers,
} from '@cloudflare/workers-oauth-provider';
import { WorkerEntrypoint } from 'cloudflare:workers';
interface AuthProps {
userId: string;
displayName: string;
}
interface Env {
OAUTH_KV: KVNamespace;
OAUTH_PROVIDER: OAuthHelpers;
}
class McpApiHandler extends WorkerEntrypoint<Env, AuthProps> {
fetch(request: Request): Response {
return Response.json({
authenticated: true,
userId: this.ctx.props.userId,
displayName: this.ctx.props.displayName,
});
}
}
const defaultHandler: ExportedHandler<Env> = {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname !== '/authorize') {
return new Response('Not found', { status: 404 });
}
// This parses the OAuth parameters and validates the client, redirect URI,
// response type, resource indicators, and configured PKCE restrictions.
let oauthRequest: AuthRequest;
try {
oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
} catch (error) {
if (!(error instanceof AuthorizationError)) throw error;
if (!error.redirectUri) {
// Unknown clients and invalid redirects must be rendered locally.
return new Response(error.description, { status: 400 });
}
const redirect = new URL(error.redirectUri);
redirect.searchParams.set('error', error.code);
redirect.searchParams.set('error_description', error.description);
if (error.state) redirect.searchParams.set('state', error.state);
if (error.issuer) redirect.searchParams.set('iss', error.issuer);
return Response.redirect(redirect, 302);
}
const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
if (!client) {
return new Response('Unknown OAuth client', { status: 400 });
}
// Authenticate the user and obtain consent here. Do not automatically
// approve a request in production. This example assumes those steps have
// produced the following user and scope values.
const user = { id: 'user-123', displayName: 'Ada' };
const grantedScopes = oauthRequest.scope.filter((scope) => scope === 'mcp:read');
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
request: oauthRequest,
userId: user.id,
metadata: { clientName: client.clientName },
scope: grantedScopes,
props: {
userId: user.id,
displayName: user.displayName,
},
});
return Response.redirect(redirectTo, 302);
},
};
export default new OAuthProvider<Env>({
apiRoute: '/mcp',
apiHandler: McpApiHandler,
defaultHandler,
authorizeEndpoint: '/authorize',
tokenEndpoint: '/oauth/token',
scopesSupported: ['mcp:read'],
resourceMetadata: {
resource: 'https://mcp.example.com/mcp',
authorization_servers: ['https://mcp.example.com'],
scopes_supported: ['mcp:read'],
resource_name: 'Example MCP server',
},
// Preferred for clients with no pre-existing relationship.
// Also requires global_fetch_strictly_public in wrangler.jsonc.
clientIdMetadataDocumentEnabled: true,
// Optional compatibility fallback. MCP 2026 deprecates DCR for new clients.
clientRegistrationEndpoint: '/oauth/register',
});apiRoute and apiHandler protect one or more route prefixes with a single handler. Use apiHandlers when different prefixes need different handlers.
Before calling a protected handler, the provider reads the bearer token, rejects missing, invalid, or expired credentials, checks its audience, and exposes the authenticated application data through ctx.props. The handler does not need to parse or validate the token, but it must still enforce application permissions such as scope, ownership, and tenancy.
Requests outside the protected route prefixes go to defaultHandler. In the example above, that handler owns /authorize.
OAuthAuthorizationServer separates the authorization-server role from each protected-resource role while keeping them composable. Declare every canonical resource in resources, then call protectResource() once for each MCP resource hosted by the same Worker. Each call returns a fetch handler that serves only that resource's RFC 9728 metadata, Bearer challenges, and protected API. A declared resource that is never passed to protectResource() is hosted by another Worker or service.
The following optional example (npm install hono) binds one Worker to three custom domains and uses Hono's hostname-aware path function to route without a hostname switch. The application owns the interactive /authorize route; authorizationServer.fetch() owns discovery, token, revocation, and optional registration endpoints. The original Request is forwarded as c.req.raw, so authorization-server and protected-resource URL validation still sees the real origin.
import { Hono } from 'hono';
import { OAuthAuthorizationServer } from '@cloudflare/workers-oauth-provider';
const AUTH_ISSUER = 'https://auth.example.com';
const CALENDAR_RESOURCE = 'https://calendar.example.com/mcp';
const DRIVE_RESOURCE = 'https://drive.example.com/mcp';
interface Env {
OAUTH_KV: KVNamespace;
}
interface AuthProps {
userId: string;
scopes: string[];
}
const authorizationServer = new OAuthAuthorizationServer<Env>({
issuer: AUTH_ISSUER,
resources: [CALENDAR_RESOURCE, DRIVE_RESOURCE],
authorizeEndpoint: '/authorize',
tokenEndpoint: '/oauth/token',
clientRegistrationEndpoint: '/oauth/register',
scopesSupported: ['calendar:read', 'drive:read'],
});
const calendar = authorizationServer.protectResource<AuthProps>({
resourceMetadata: {
resource: CALENDAR_RESOURCE,
scopes_supported: ['calendar:read'],
resource_name: 'Calendar MCP',
},
handler: {
async fetch(_request, _env, ctx) {
if (!ctx.props.scopes.includes('calendar:read')) return new Response('Forbidden', { status: 403 });
return Response.json({ userId: ctx.props.userId, server: 'calendar' });
},
},
});
const drive = authorizationServer.protectResource<AuthProps>({
resourceMetadata: {
resource: DRIVE_RESOURCE,
scopes_supported: ['drive:read'],
resource_name: 'Drive MCP',
},
handler: {
async fetch(_request, _env, ctx) {
if (!ctx.props.scopes.includes('drive:read')) return new Response('Forbidden', { status: 403 });
return Response.json({ userId: ctx.props.userId, server: 'drive' });
},
},
});
const app = new Hono<{ Bindings: Env }>({
getPath(request) {
const url = new URL(request.url);
return `/${url.hostname}${url.pathname}`;
},
});
// Authenticate the user and obtain consent here. Production code should render
// AuthorizationError safely as shown in the quick start.
app.get('/auth.example.com/authorize', async (c) => {
const oauth = authorizationServer.getOAuthApi(c.env);
const request = await oauth.parseAuthRequest(c.req.raw);
const { redirectTo } = await oauth.completeAuthorization({
request,
userId: 'user-123',
metadata: {},
scope: request.scope,
props: { userId: 'user-123', scopes: request.scope },
});
return c.redirect(redirectTo);
});
app.all('/auth.example.com/*', (c) => authorizationServer.fetch(c.req.raw, c.env, c.executionCtx));
app.all('/calendar.example.com/*', (c) => calendar.fetch(c.req.raw, c.env, c.executionCtx));
app.all('/drive.example.com/*', (c) => drive.fetch(c.req.raw, c.env, c.executionCtx));
export default app;Route all three custom domains to that Worker:
{
"workers_dev": false,
"routes": [
{ "pattern": "auth.example.com", "custom_domain": true },
{ "pattern": "calendar.example.com", "custom_domain": true },
{ "pattern": "drive.example.com", "custom_domain": true },
],
}authorizationServer.fetch() serves only the AS role at auth.example.com; the handles returned by protectResource() serve only their registered resource. Authorization server metadata advertises both canonical identifiers in protected_resources, while Calendar and Drive publish independent protected resource metadata that points back to https://auth.example.com.
The registry is fixed at construction. resources must name every audience the server issues tokens for, and defaultResource, legacyGrantResource, resource(), and protectResource() are all checked against it during module initialization, so a misspelled identifier fails before the first request. Call protectResource() before the first request for each resource hosted in this Worker.
List a resource in resources without calling protectResource() when the authorization server issues tokens for a resource it does not host. The standalone resource Worker can use createOAuthResourceServer() to publish its own RFC 9728 metadata, issue Bearer challenges, enforce the canonical audience, and expose validated application data as ctx.props:
import { createOAuthResourceServer, type ValidatedAccessToken } from '@cloudflare/workers-oauth-provider';
const AUTH_ISSUER = 'https://auth.example.com';
const CALENDAR_RESOURCE = 'https://calendar.example.com/mcp';
interface AuthProps {
userId: string;
scopes: string[];
}
interface CalendarEnv {
AUTHORIZATION_SERVER: {
validateToken(token: string): Promise<ValidatedAccessToken<{ userId: string }> | null>;
};
}
export default createOAuthResourceServer<CalendarEnv, AuthProps>({
resourceMetadata: {
resource: CALENDAR_RESOURCE,
authorization_servers: [AUTH_ISSUER],
scopes_supported: ['calendar:read'],
resource_name: 'Calendar MCP',
},
async validateToken({ token, env }) {
const validation = await env.AUTHORIZATION_SERVER.validateToken(token);
if (!validation) return null;
return {
audience: validation.audience,
expiresAt: validation.expiresAt,
props: {
...validation.props,
scopes: validation.scope,
},
};
},
handler: {
async fetch(_request, _env, ctx) {
if (!ctx.props.scopes.includes('calendar:read')) {
return new Response('Forbidden', { status: 403 });
}
return Response.json({ userId: ctx.props.userId });
},
},
});The validateToken callback is deliberately transport-independent. For Workers, a private Service Binding can expose a resource-specific method backed by the resource handle's validateToken():
import { WorkerEntrypoint } from 'cloudflare:workers';
const calendar = authorizationServer.resource(CALENDAR_RESOURCE);
export class CalendarTokenValidator extends WorkerEntrypoint<Env> {
validateToken(token: string) {
return calendar.validateToken(token, this.env);
}
}Bind the Calendar Worker to CalendarTokenValidator; expose a separate Drive entrypoint built from authorizationServer.resource(DRIVE_RESOURCE). Fixing the resource on the authorization-server side prevents one resource Worker from asking to validate tokens for another audience. createOAuthResourceServer() also rejects a successful callback result whose audience is not its configured canonical resource and returns 503 when validation infrastructure throws. It passes only the validator's props to the handler, so the validator must copy or derive every scope and identity field the handler needs, as above, or enforce authorization itself. The package does not create a public token-introspection or JWT-validation endpoint; applications choose and secure the callback transport.
The existing OAuthProvider constructor remains supported. It is the concise combined AS-and-resource API used by the quick start and is appropriate when one Worker protects one canonical resource. Existing applications do not need to move to OAuthAuthorizationServer to upgrade.
An MCP client discovers authorization in two stages, following the MCP authorization server discovery rules.
For an MCP endpoint at https://mcp.example.com/mcp:
-
The client sends an unauthenticated request to
/mcp. -
The provider returns
401 Unauthorizedwith a challenge similar to:WWW-Authenticate: Bearer realm="OAuth", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp" -
The client fetches the protected resource metadata:
https://mcp.example.com/.well-known/oauth-protected-resource/mcp -
That document identifies one or more authorization server issuers through
authorization_servers. -
The client fetches RFC 8414 authorization server metadata from the selected issuer. In the single-origin quick start that is:
https://mcp.example.com/.well-known/oauth-authorization-server -
The metadata tells the client where to authorize, exchange tokens, and register if registration is enabled.
Protected resource metadata and authorization server metadata serve different roles:
- Protected resource metadata describes the MCP server and identifies its authorization servers.
- Authorization server metadata describes OAuth endpoints and capabilities such as PKCE and CIMD.
Every protected resource needs its own resourceMetadata.resource. Configure each canonical HTTPS identifier with a lowercase scheme and host (plain http is accepted only on a loopback host, for wrangler dev):
resourceMetadata: {
resource: 'https://mcp.example.com/mcp',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['files:read'],
bearer_methods_supported: ['header'],
resource_name: 'Files MCP server',
}For the example above, an unauthenticated request to the exact canonical URL receives a Bearer challenge pointing to:
https://mcp.example.com/.well-known/oauth-protected-resource/mcp
That document returns the configured canonical resource. The discovery URL is built from the canonical resource: an origin uses /.well-known/oauth-protected-resource, and a path and query are inserted after the well-known prefix.
A canonical path is the base audience for its path-boundary descendants: a token for https://mcp.example.com/mcp is accepted at /mcp/tools, and a challenge at /mcp/tools advertises the one canonical document for /mcp, as RFC 9728 §5.1 permits. A request on another origin, or one that the canonical resource does not cover, gets a challenge without resource_metadata. Every protected route must be the canonical resource path or a descendant of it; the provider rejects any other apiRoute or apiHandlers key at construction, because a token could never validate there.
authorization_servers may contain more than one issuer. Each value must use canonical HTTPS issuer spelling: lowercase scheme and host, with no userinfo, default port, dot segments, query, or fragment. As with resources, http is accepted only on a loopback host. OAuth issuer comparison is exact. The MCP client chooses an authorization server and must keep credentials and tokens separate for each issuer. A resource registered with OAuthAuthorizationServer.protectResource() defaults this list to that server's configured issuer; the standalone createOAuthResourceServer() requires it explicitly.
The provider publishes RFC 8414 metadata containing:
issuerauthorization_endpointtoken_endpointprotected_resources, containing the authorization server's registered canonical resourcesregistration_endpoint, when DCR is enabled- supported response and grant types
- token endpoint authentication methods
- PKCE methods
- revocation endpoint
- RFC 9207 issuer support
- CIMD support when it is enabled and safe to use
The package serves RFC 8414 metadata rather than OpenID Connect discovery. MCP authorization servers need to provide at least one of those mechanisms, so RFC 8414 is sufficient.
Your authorizeEndpoint belongs to the application's defaultHandler because user authentication and consent are application-specific. The provider is not an identity provider.
A typical flow has three steps:
- Call
parseAuthRequest(request)to validate the client, redirect URI, response type, resource, and PKCE restrictions. - Authenticate the user, show consent, and decide which scopes to grant.
- Call
completeAuthorization()and redirect to its returnedredirectToURL.
parseAuthRequest() throws an exported AuthorizationError for expected request validation failures. Its optional redirectUri is present only after the client and exact registered redirect URI have been validated. Without it, render the error locally and never redirect. With it, the application can safely construct an OAuth error redirect using the error's code, description, original state, and RFC 9207 issuer, as shown in the quick start.
completeAuthorization() repeats response-type validation before writing a grant or revoking existing grants. Validation errors from reconstructed requests are also typed as AuthorizationError, but applications should not construct redirects from untrusted reconstructed values; the redirect context is attached only by parseAuthRequest().
completeAuthorization() stores a new grant and, by default, revokes existing grants for the same user, client, and resource after the new grant is safely stored. A grant for another registered resource is a separate authorization and is not revoked. Set revokeExistingGrants: false only when the application intentionally allows concurrent grants within the same resource.
For Client ID Metadata Document clients, whose client_id is the metadata URL shared by every installation, default revocation is additionally scoped to grants created from the same redirect URI, so one installation's re-authorization does not revoke another's. Grants created before the redirect URI was recorded are never auto-revoked by CIMD clients.
For users with many grants, revokeExistingGrantsBatchSize controls the KV page size used during that scan. It defaults to 50 and is capped at KV's maximum page size of 1000.
RFC 9207 issuer identification is always enabled. Authorization server metadata advertises authorization_response_iss_parameter_supported: true, and successful authorization responses include iss automatically.
parseAuthRequest() returns the expected issuer. If the application creates a terminal OAuth error redirect, include that value:
const oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
const redirect = new URL(oauthRequest.redirectUri);
redirect.searchParams.set('error', 'access_denied');
redirect.searchParams.set('state', oauthRequest.state);
if (oauthRequest.issuer) redirect.searchParams.set('iss', oauthRequest.issuer);
return Response.redirect(redirect.toString(), 302);Intermediate identity-provider redirects and local HTML error pages do not need the OAuth iss parameter.
MCP client registration defines three ways for a client to obtain a client ID. Clients that support all three prefer pre-registration, then CIMD, then DCR.
Use OAuthHelpers.createClient() to create clients through application or administrative code. These clients are stored in KV and are not subject to clientRegistrationTTL.
CIMD lets a client use an HTTPS URL with a non-root path as its client_id. That URL serves a JSON metadata document describing the client and its redirect URIs.
Enable it in both places:
new OAuthProvider({
// Other options...
clientIdMetadataDocumentEnabled: true,
});{
"compatibility_flags": ["global_fetch_strictly_public"],
}The compatibility flag prevents outbound CIMD fetches from using legacy same-zone origin routing, which is necessary for SSRF protection. The provider advertises client_id_metadata_document_supported: true only when both settings are present. CIMD fetches also use the cache option of fetch, which requires a compatibility date of 2024-11-11 or later (or the cache_option_enabled compatibility flag).
CIMD validation follows draft-ietf-oauth-client-id-metadata-document-00 — the revision pinned by the MCP 2026-07-28 authorization spec — and includes:
- An HTTPS Client Identifier URL with a path component and no userinfo, fragment, or dot path segments.
- A document
client_idexactly matching its URL. - Non-empty
client_nameandredirect_urisfields, as MCP requires, with unsafe redirect schemes rejected at ingestion. - Exact authorization-request redirect URI validation, with RFC 8252 loopback port handling.
- A 5 KB response size limit and a 10 second timeout covering both headers and body.
- Valid UTF-8 JSON object syntax and safe URI schemes for client metadata fields.
- No embedded client secrets or private JWK material.
Validated documents are cached according to their Cache-Control headers, capped at 7 days. Error responses and invalid documents are never cached, and a cached document that stops validating is evicted and re-resolved from origin within the same request.
CIMD token endpoint authentication is negotiated from token_endpoint_auth_method and the OpenID RP Metadata Choices field token_endpoint_auth_methods_supported. The provider currently implements only none: a client may prefer private_key_jwt while also offering none, in which case the provider selects none and applies public-client PKCE requirements. A client that offers only private_key_jwt is rejected until assertion validation is implemented.
When a CIMD document cannot be fetched or validated, the token endpoint returns a generic invalid_client response and reports diagnostics through onError.internal. OAuthHelpers methods that resolve a CIMD client throw the exported CimdFetchError, allowing applications to distinguish an upstream metadata failure from a client that does not exist. See Advanced configuration for an example.
Set clientRegistrationEndpoint to enable RFC 7591 Dynamic Client Registration:
clientRegistrationEndpoint: '/oauth/register';MCP 2026-07-28 deprecates DCR for new implementations in favor of CIMD. The endpoint remains useful for compatibility with clients that do not support CIMD.
Registration accepts only authentication methods, grants, and response types implemented by the configured provider, and rejects inconsistent grant/response combinations before storage. Choice-valued token_endpoint_auth_methods_supported input is negotiated to one effective token_endpoint_auth_method; grant and response registrations remain strict. Omitted metadata uses the RFC 7591 defaults: client_secret_basic, grant_types: ["authorization_code"], and response_types: ["code"]. The token endpoint enforces each client's registered grant types with unauthorized_client; refresh_token is implied by authorization_code, and a client must register urn:ietf:params:oauth:grant-type:token-exchange to use token exchange.
The effective token_endpoint_auth_method returned by registration is enforced exactly. When both authentication metadata fields are omitted, no explicit-method marker is stored and the client may use either client_secret_basic or client_secret_post, provided the same stored secret validates. Client records written by earlier releases have no marker and receive the same compatibility. This never crosses between none and a secret method and does not apply to CIMD clients.
Calling OAuthHelpers.updateClient() with tokenEndpointAuthMethod adds the marker; unrelated updates leave it unchanged.
Related options:
clientRegistrationTTLcontrols the lifetime of dynamically registered clients. The default is 90 days.disallowPublicClientRegistrationrejects DCR clients usingtoken_endpoint_auth_method: "none".clientRegistrationCallbackcan allow or reject registration based on application policy.
Clients created by OAuthHelpers.createClient() are not affected by the DCR TTL or public-registration restriction.
Public clients must use PKCE with authorization code flow. PKCE challenges use only S256 by default. Confidential clients may still omit PKCE.
Legacy deployments with clients that cannot use S256 can opt back into plain PKCE:
allowPlainPKCE: true;allowImplicitFlow defaults to false; leave it disabled for MCP and other new OAuth deployments.
The provider owns tokenEndpoint. It exchanges authorization codes for tokens, refreshes access tokens, and handles RFC 7009 revocation. Refresh tokens rotate on use. The immediately previous token remains valid until its replacement is first used, allowing a client to retry after losing a refresh response.
An authorization server may register one or more protected resources. Each resource has one canonical resourceMetadata.resource: an absolute HTTPS URI without a fragment, with lowercase https and a lowercase host, and an RFC 3986-safe producer serialization. Userinfo, default ports, dot-segment paths, and an empty path before a query are rejected because Request would rewrite them before RFC 9728 comparison. A bare origin is the only empty-path exception; use / before a query. Query components are supported but discouraged by RFC 9728.
For local development, http is accepted for resources, authorization_servers, the explicit OAuthAuthorizationServer issuer, and absolute endpoint URLs only when the host is a loopback address (localhost, 127.0.0.0/8, ::1), so wrangler dev works at http://localhost:8787. Any other host must use https: Workers are always served over https, and OAuth 2.1 requires it. A local MCP client's loopback redirect URI is unaffected by this rule; it is governed by the RFC 8252 loopback handling described under client registration.
Every authorization grant and access token is bound to exactly one registered resource. A central authorization server can therefore issue separate Calendar and Drive tokens from one KV namespace, but it never turns those into one multi-audience bearer token. Completing a new authorization for Drive does not replace the same user and client's Calendar grant.
Conforming MCP clients are required to send resource in authorization and token requests. Resource selection and compatibility work as follows:
- When the authorization server has one registered resource, that sole resource is selected if an authorization request omits
resource. This preserves existingOAuthProviderbehavior. - When it has multiple registered resources, an authorization request must identify exactly one of them. Set
defaultResourceonOAuthAuthorizationServeronly when older clients that omitresourceshould be routed to a deliberate compatibility default. - An authorization-code or refresh-token request may omit
resource; the server inherits the resource already stored on the grant. If present, it must match that grant and cannot retarget it. - Malformed, unknown, or multi-valued resource input returns
invalid_targetbefore code consumption, callbacks, refresh rotation, or storage writes.
ASCII case differences in the URI scheme and host are accepted, but port, path, query, trailing slash, and array cardinality remain strict. The authorization server always stores and returns the configured lowercase scheme-and-host spelling. The token response includes the selected resource, and the access-token audience contains that resource alone.
Token exchange cannot change the resource. Both the subject-token audience and any explicit requested resource must resolve to the same registered canonical value. A token is exchanged by the client its grant was issued to unless tokenExchangeCallback returns allowCrossClientExchange: true. Internally and externally validated tokens are accepted at a protected route only when their audience matches that route's resource.
Path-aware API validation uses path-boundary prefix matching. A canonical audience for https://example.com/mcp covers /mcp and /mcp/tools, but not /mcp-other. A canonical trailing slash remains significant.
The existing combined OAuthProvider configuration has one resourceMetadata.resource. That sole resource automatically acts as both the omitted-authorization default and the migration destination for grants created before resource binding, so existing single-resource clients can continue without adding a resource parameter.
For a multi-resource OAuthAuthorizationServer, defaultResource and legacyGrantResource solve different compatibility problems:
defaultResourceselects the resource for a new authorization request that omitsresource.legacyGrantResourceis the server-controlled migration destination for an old stored grant or access token that has no resource. A client-supplied token-request parameter cannot choose or change this destination. It is deployment policy rather than an issuance-time claim, so changing it re-targets every surviving unbound record; keep it fixed for the migration window.
Both values must name a declared resource and are checked at construction. If a multi-resource server omits legacyGrantResource, an old unbound grant cannot be migrated safely. A stored grant already bound to a registered resource keeps that resource, and a stored 0.x array that contains the registered resource resolves to it. A grant bound only to unregistered values fails its refresh with invalid_grant, which conformant clients answer by starting a new authorization.
Previously issued access tokens with no audience keep working until they expire. They are treated as bound to the server-selected migration resource (the sole resource, or legacyGrantResource), and refresh binds the grant and returns a bound replacement token. A multi-resource server without legacyGrantResource has no safe destination, so it rejects such tokens and their refresh grants must be reauthorized. Multiple resources can share the same authorization server, provider implementation, and KV namespace; separate storage is an optional deployment boundary, not a resource-binding requirement.
The 1.0 API removes resourceMatchOriginOnly, and a configuration that still sets it fails at construction. Canonical matching with scheme/host case tolerance replaces it.
scopesSupported is published only in authorization server metadata. Configure each protected resource's resourceMetadata.scopes_supported explicitly with the minimal scopes required for its basic functionality and baseline Bearer challenges.
The application decides which requested scopes to grant through completeAuthorization({ scope }). Token and refresh requests can only narrow those scopes.
The provider does not expose a standard effective-token authorization context to API handlers or enforce operation-level scope policy. Protected resource metadata supplies baseline scope guidance in Bearer challenges. Advanced integrations can provide operation-specific step-up guidance through external-token validation.
The package also supports:
- External API keys and bearer credentials through
resolveExternalTokenas an advanced compatibility feature. - Updating encrypted props, token scope, and token lifetimes with
tokenExchangeCallback. - OAuth 2.0 Token Exchange when
allowTokenExchangeGrantis enabled. - Structured callback errors through the exported
OAuthErrorandExternalTokenErrorclasses. - Custom error observation or responses through
onError. - Experimental MCP Enterprise-Managed Authorization using ID-JAG assertions.
- One authorization server with multiple same-Worker or separately routed MCP resources.
- Multiple protected handlers through
apiHandlers. - Configurable access token, refresh token, and DCR client lifetimes.
See Advanced configuration for examples and security notes.
Sensitive values are not stored in plaintext:
- Access tokens, refresh tokens, authorization codes, and client secrets are stored only by hash.
propsare encrypted with AES-GCM using key material wrapped by the corresponding secret token.- Grant
userIdandmetadataare not encrypted because applications use them to enumerate and revoke grants. Treat those fields as storage-visible metadata.
See storage-schema.md for the complete KV layout.
KV TTLs remove expiring records automatically. purgeExpiredData() provides a manual sweep for orphaned or expired grants and tokens:
const provider = new OAuthProvider({
// Options...
});
export default {
fetch(request, env, ctx) {
return provider.fetch(request, env, ctx);
},
async scheduled(_event, env) {
const result = await provider.purgeExpiredData(env, { batchSize: 100 });
console.log(result);
},
};The default batch size is 50. result.done reports whether both key spaces were scanned completely during that invocation.
Deleting a client through OAuthHelpers.deleteClient() also revokes its grants and associated tokens across users.
The existing OAuthProvider combined configuration uses these options:
| Option | Purpose | Default |
|---|---|---|
apiRoute and apiHandler |
Protect one or more route prefixes with one handler | Use these or apiHandlers |
apiHandlers |
Map protected route prefixes to different handlers | Use this or apiRoute plus apiHandler |
defaultHandler |
Handle authorization UI and other unprotected routes | Required |
authorizeEndpoint |
Application-owned authorization and consent endpoint | Required |
tokenEndpoint |
Provider-owned token and revocation endpoint | Required |
clientRegistrationEndpoint |
Enable RFC 7591 DCR | Disabled |
scopesSupported |
Publish authorization server scopes | Omitted |
resourceMetadata.resource |
Canonical HTTPS resource and token audience | Required |
clientIdMetadataDocumentEnabled |
Enable CIMD lookup and advertisement | false |
allowPlainPKCE |
Permit the legacy plain PKCE method | false |
allowImplicitFlow |
Enable implicit token responses | false |
disallowPublicClientRegistration |
Reject public clients at DCR | false |
clientRegistrationCallback |
Apply application policy before storing a DCR client | None |
allowTokenExchangeGrant |
Enable RFC 8693 | false |
tokenExchangeCallback |
Update props, scopes, or lifetimes during token exchange | None |
resolveExternalToken |
Validate external bearer credentials (advanced) | None |
enterpriseManagedAuthorization |
Enable experimental ID-JAG grant support | Disabled |
onError |
Observe or replace OAuth error responses | Logs a warning |
The functional role API adds these surfaces without removing OAuthProvider:
| Surface | Purpose |
|---|---|
new OAuthAuthorizationServer({ issuer, resources, … }) |
Create the AS role with a canonical RFC 8414 issuer and its fixed resource registry |
protectResource({ resourceMetadata, handler }) |
Host one declared resource in this Worker, returning its fetch surface |
resource(uri) |
Obtain a handle for one declared resource, with validateToken(token, env) and protect() |
defaultResource |
Select a deliberate default for new authorization requests that omit it |
legacyGrantResource |
Select the server-controlled migration target for old unbound grants |
getOAuthApi(env) |
Obtain OAuth helpers for an application-owned authorization route |
createOAuthResourceServer({ … }) |
Create a standalone resource role around an application validation callback |
Consult the exported OAuthProviderOptions, OAuthAuthorizationServerOptions, resource-server callback interfaces, and JSDoc in src/oauth-provider.ts for the complete typed API.
Handlers receive env.OAUTH_PROVIDER, which implements OAuthHelpers. It can:
- Parse authorization requests and complete authorization.
- Look up, create, list, update, and delete clients.
- List and revoke grants for a user.
- Inspect internally issued tokens with
unwrapToken(). - Exchange access tokens when RFC 8693 is enabled.
- Purge expired and orphaned KV data.
getOAuthApi(options, env) provides the same helper API outside a fetch handler, including RPC methods and other Worker entrypoints.
The package implements or supports the relevant portions of:
- MCP authorization, 2026-07-28
- OAuth 2.1, draft-ietf-oauth-v2-1-13
- OAuth 2.0 Bearer Token Usage, RFC 6750
- OAuth 2.0 Token Revocation, RFC 7009
- OAuth 2.0 Dynamic Client Registration, RFC 7591
- Proof Key for Code Exchange, RFC 7636
- OAuth 2.0 Authorization Server Metadata, RFC 8414
- OAuth 2.0 Token Exchange, RFC 8693
- Resource Indicators for OAuth 2.0, RFC 8707
- OAuth 2.0 Authorization Server Issuer Identification, RFC 9207
- OAuth 2.0 Protected Resource Metadata, RFC 9728
- OAuth Client ID Metadata Documents
- OpenID Connect RP Metadata Choices 1.0
- MCP Enterprise-Managed Authorization, with experimental package support
Node 24 or newer is required.
npm install
npm run build
npm run check
npm run prettierChanges that affect behavior or the public API need a Changeset. See AGENTS.md for repository conventions and SECURITY.md for vulnerability reporting.
Kenton Varda's original account of how this library was created is preserved in HISTORY.md.
{ "kv_namespaces": [ { "binding": "OAUTH_KV", "id": "YOUR_KV_NAMESPACE_ID", }, ], }