
Remote MCP Server OAuth on Cloudflare Workers
How Screenies handles MCP OAuth discovery, verifies JWTs without an HTTP self-fetch, and keeps tool access tied to workspace permissions.
On this page
- Give the resource and issuer separate URLs
- Make discovery work before debugging login
- Verify keys locally when the provider shares the Worker
- Keep client registration compatible without freezing the protocol
- Reuse app permissions after authentication
- Check each boundary with a separate request
- What this connection exposes in Screenies
- FAQ
A remote MCP server using OAuth needs discoverable authorization endpoints and a token check before it runs tools. On Cloudflare Workers, our Screenies implementation also needed an in-process signing-key lookup: the HTTP request back to our own Worker failed, even after the user had signed in.
This is an implementation account for developers adding MCP to an existing web app. Screenies runs the authorization provider and the protected /mcp endpoint in the same Worker, using Better Auth for OAuth and jose for JWT verification. Two integration fixes mattered: serving the discovery paths clients actually requested, and removing the network request between our token verifier and our own auth handler. The application still checks workspace membership after it verifies the token. A valid token identifies the caller; each tool must decide which records that caller can access.
For the user-facing setup, the Claude Code screenshot workflow covers connecting an account and running a set. Here, we'll trace what has to work behind that connection.
Give the resource and issuer separate URLs
Start by writing down the identifiers your server will use. Our protected resource is https://screenies.app/mcp. Our authorization server's issuer is https://screenies.app/api/auth. These values share a host but serve different roles, and swapping them produces a token that the verifier should reject.
| Value | Screenies value | Purpose |
|---|---|---|
| Resource and token audience | https://screenies.app/mcp |
Identifies the service the token can access |
| Token issuer | https://screenies.app/api/auth |
Identifies the authorization server |
| Public signing keys | https://screenies.app/api/auth/jwks |
Lets a verifier check JWT signatures |
The MCP authorization specification requires resource-specific token validation. Don't accept a token just because its signature is valid. Check its issuer, intended audience, and expiration before reading the subject as an authenticated user ID.
In our auth configuration, jwt() provides signing keys and mcp() sets the resource, login page, and consent page. Those plugins live beside the app's existing database adapter and Google sign-in configuration. We didn't create a second user account system for agents.
Make discovery work before debugging login
An unauthenticated MCP request gets a 401 with this header in our implementation:
WWW-Authenticate: Bearer resource_metadata="https://screenies.app/.well-known/oauth-protected-resource/mcp"
That address returns the resource identifier and its authorization server. The client can then fetch the issuer's metadata to find the authorization and token endpoints. It needs this information before it can open the right login flow. A working /login page doesn't prove the discovery step works. Check the JSON response itself, including its status and content type, before debugging the browser callback.
Our provider sits under /api/auth, while discovery also uses root-level paths. The server entry handles /.well-known/oauth-protected-resource/mcp and /.well-known/oauth-authorization-server/api/auth, along with the corresponding root aliases. It first passes the original request to the auth handler. If that returns 404, it retries in-process with the /api/auth prefix. The fix was to bridge the requested paths to the provider, without maintaining a separate copy of its metadata.
You can inspect the public documents without signing in:
curl -i https://screenies.app/.well-known/oauth-protected-resource/mcp
curl -i https://screenies.app/.well-known/oauth-authorization-server/api/auth
On September 7, 2026, those endpoints returned the /mcp resource and /api/auth issuer shown above. The issuer document also advertised S256 PKCE, a registration endpoint, and Client ID Metadata Document support. That confirms public discovery; it doesn't test a complete sign-in or tool call.
Verify keys locally when the provider shares the Worker
Our first token verifier used a helper that retrieved JWKS over HTTP. In our deployment, that meant the Worker fetched its own public host to reach /api/auth/jwks. The request failed and MCP calls returned server errors. Changing the user's credentials couldn't fix the problem because the failure was in our key lookup.
The replacement calls auth.handler(new Request(jwksUrl)) directly. This returns the provider's key response inside the current request, without making a network subrequest to the same Worker. We check that the response succeeded, pass its JSON into createLocalJWKSet, and use that key set with jwtVerify. The verifier receives the expected issuer and audience from server configuration. A failed verification returns no claims, and the route sends the authentication challenge instead of dispatching a tool.
This approach depends on the authorization provider being available in the same process. If your provider runs elsewhere, its supported remote key-discovery flow is a different deployment case. Keep that distinction when adapting an example: the local call solves our same-Worker lookup failure, not every JWKS error.
Our implementation retrieves the key set for each verification. It doesn't add a separate application cache. If you add one, account for key rotation and unknown signing-key IDs before choosing a cache lifetime.
Keep client registration compatible without freezing the protocol
The initial integration enabled dynamic client registration so a connecting client could obtain an ID. The current auth configuration also includes @better-auth/cimd with the mcp-2026-07-28 metadata profile. It keeps DCR enabled explicitly for compatibility.
This matters when reading older examples. The July 2026 MCP specification recommends Client ID Metadata Documents and retains DCR as a deprecated compatibility option. DCR support alone isn't a complete description of current client registration. The Better Auth MCP guide explains the plugin combination and the controls that expose the registration endpoint.
Runtime choice matters here too. The CIMD package's Node transport uses Node networking facilities; our Worker supplies a platform-fetch transport and refuses redirects. Treat the metadata-fetch security contract as part of the integration review, including destination validation. A runtime-specific transport needs to meet that contract, not merely return a document successfully. This article describes our auth integration, not a claim that the whole MCP endpoint conforms to every requirement of the latest protocol.
Reuse app permissions after authentication
Once the route has verified claims, it uses the token subject as the user ID passed to tools. Tools then call the same requireApp and requireWorkspace helpers used by the dashboard. This preserves the existing membership checks when an agent supplies an app ID. Accepting arbitrary IDs after a successful login would let authentication bypass the application's access rules.
Consent and revocation need equally specific behavior. Our consent page presents the registered client name and returns the provider's signed consent query. Revoking a connection removes consent, revokes refresh tokens, and deletes opaque access tokens. Already-issued JWT access tokens can remain valid until they expire, within an hour in this implementation. Don't describe that behavior to users as immediate cancellation of every token.
There was also a build concern: provider initialization can seed OAuth resource records in the database. Our server avoids constructing auth for public requests without cookies when the path doesn't need it. MCP, API, and discovery requests still initialize auth. That keeps public prerendering from requiring a database connection just to produce a page.
Check each boundary with a separate request
Test discovery and access separately so a successful login doesn't hide a failed permission check:
- Send an unauthenticated MCP POST. Confirm the
401advertises a reachable resource metadata URL. - Fetch both metadata documents. Confirm their resource, issuer, and endpoint values agree with your configuration.
- Complete a supported client's sign-in and consent flow. Then call a read-only tool on an app that belongs to the user.
- Repeat with expired credentials and a token intended for another resource. Neither should reach tool execution.
- Try an app outside that user's workspace. A valid token must not bypass the membership check.
- Revoke the client connection. Test refresh behavior separately from any access token that has not yet expired.
These are release checks to run on your deployment. The public endpoint checks above don't establish the results of the authenticated cases.
What this connection exposes in Screenies
Screenies uses this connection to let an agent operate on the user's existing screenshot workspace. MCP generation draws from the same credit balance as dashboard generation, with one-time packs listed on the pricing page. The MCP connection guide lists the setup options if you want to use the hosted service.
FAQ
Can the authorization server share a Worker with the MCP endpoint?
It can, as this implementation does. Keep the issuer and resource identifiers distinct, and check whether any verification helper makes an unnecessary HTTP call back to the same deployment.
Why can the login page work while the MCP client fails to connect?
The client may fail before reaching login, when it requests discovery metadata. Inspect the metadata URL from the challenge and the issuer's path-specific discovery response first.
Does a valid JWT grant access to every app in the database?
It must not. The token identifies a subject and an intended resource; application membership checks still decide which app records a tool can use.
Is dynamic client registration still the only option?
The July 2026 specification includes Client ID Metadata Documents and pre-registration. Our provider keeps DCR alongside CIMD for clients that still use it.
Does removing consent invalidate a signed access token immediately?
Not in our local JWT verification path. Refresh-token revocation prevents renewal, while an existing access token can remain usable until its expiration.
Connect to the hosted screenshot workflow
To try the hosted workflow, sign in to Screenies and connect an agent to your workspace.
Related guides

Generate App Store Screenshots With Claude Code
Connect Screenies' remote MCP server to Claude Code and generate, localize, and download a full App Store screenshot set from prompts, with no local build to run.

How to Make App Store Screenshots
A step-by-step process for turning raw app captures into an uploaded App Store listing set, from simulator capture to App Store Connect.

How Many Screenshots for the App Store
Apple's limit is 1 to 10 screenshots per device size and localization. What the top charts actually upload, why the first three carry the weight, and how to pick your number.