
Remote MCP Server OAuth on Cloudflare Workers
How Screenies helps MCP clients find the login flow, checks access tokens inside a Cloudflare Worker, and limits tools to the user's workspace permissions.
On this page
- Give the resource and issuer separate URLs
- Make discovery work before debugging login
- Read signing keys directly inside the Worker
- Support current and older client registration methods
- Reuse app permissions after authentication
- Test login and permissions separately
- Use the connection with your Screenies workspace
- FAQ
A remote MCP server using OAuth must tell clients where to sign in and check their access tokens before running tools. In Screenies, sign-in worked, but checking access tokens failed because our server could not fetch its own signing keys over HTTP. We fixed this by reading the keys directly within the Worker.
This post explains how we added MCP access 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 to check signed access tokens called JWTs. We fixed two problems: clients could not find the login information at the URLs they requested, and our token checks failed when they made a network request back to our own server. 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. The same sign-in runs when you connect from Cursor or Codex, and each of those walkthroughs shows the client's side of the OAuth prompt. Here, we'll trace what has to work behind that connection.
Give the resource and issuer separate URLs
Start with two URLs. The resource URL identifies the service a token can access. For Screenies, it is https://screenies.app/mcp. The issuer URL identifies the server that issued the token. Ours is https://screenies.app/api/auth. Both use the Screenies domain, but they serve different purposes. A token with these values swapped should fail verification.
| 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 the server to check that a token is meant for its service. Don't accept a token just because its signature is valid. Check who issued it, which service it is meant for (its audience), and whether it has expired. Only then use its subject field as the signed-in user's 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
When a client sends an MCP request without a valid token, Screenies returns a 401 response with this header:
WWW-Authenticate: Bearer resource_metadata="https://screenies.app/.well-known/oauth-protected-resource/mcp"
That URL returns a JSON document that identifies the protected service and its authorization server. The client then reads the authorization server's metadata, a document that lists where to sign in and get tokens. 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 adds the /api/auth prefix and calls the handler again directly. This lets the requested URLs return the provider's own metadata. We do not need to maintain a second copy.
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 listed S256 PKCE support, a client registration URL, and support for Client ID Metadata Documents. This check showed that clients could read the login information. It did not test a complete sign-in or tool call.
Read signing keys directly inside the Worker
Our first token check fetched the public signing keys, called a JSON Web Key Set (JWKS), over HTTP. The Worker requested /api/auth/jwks from its own public domain. 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 gets the keys directly from the auth handler, without a network request back to the 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. If a check fails, the route returns the 401 response shown above and does not run the tool.
This works because our authorization provider runs inside the same Worker. If yours runs on another server, use its supported method for fetching keys remotely. The direct call fixes the failure described here; it is not a general fix for every signing-key error.
Our implementation retrieves the key set for each verification. It doesn't add a separate application cache. If you add a cache, make sure it can fetch new keys when the provider replaces a key or a token refers to a key it has not seen.
Support current and older client registration methods
We first enabled dynamic client registration (DCR), which lets a connecting client register and get 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.
Client ID Metadata Documents (CIMD) let clients identify themselves through a metadata document hosted at a URL. Keep this method in mind 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.
The code that fetches those documents must also work on Cloudflare Workers. The package's Node version uses Node networking APIs. Our Worker uses its built-in fetch API and refuses redirects. When you adapt this code, check its security rules, including which destination URLs it allows. A successful fetch alone does not prove those checks work. This post describes our integration; it does not establish that our endpoint meets every requirement of the latest MCP specification.
Reuse app permissions after authentication
After checking the token, the route passes its subject field to tools as the user ID. 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. Signing in must not let an agent access another user's app just by supplying its ID.
Be clear about what approving and removing access do. 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. This stops the client from renewing access. However, a JWT access token it already holds can still work until it expires, up to an hour in this implementation. Removing a connection does not immediately invalidate every token.
Starting the auth provider can also create OAuth resource records in the database. For public requests without cookies, our server skips this setup when the requested path does not need authentication. MCP, API, and discovery requests still initialize auth. This lets the build generate public pages without connecting to the database just to start the auth provider.
Test login and permissions separately
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.
Run these checks before release. The public URL checks described earlier do not show whether signed-in requests and permission checks work.
Connect your agent to Screenies to use the hosted screenshot workflow.
Use the connection with your Screenies workspace
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. Check the metadata URL in the 401 response, then check the authorization server's metadata.
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?
No. In this implementation, removing consent stops the client from renewing access. An existing JWT access token can still work until it expires, up to an hour later.
Connect to the hosted screenshot workflow
To try the hosted workflow, sign in to Screenies and connect an agent to your workspace.
Related guides

Best MCP Servers for Claude Code: 13 Worth Adding
A working list of MCP servers for Claude Code, each with its install command, transport, and the job it does, from Context7 and Sentry to Playwright, Figma, and Screenies.

Fastlane Screenshots: snapshot, frameit, and deliver on Current iPhones and iPads
How to capture App Store screenshots with fastlane snapshot, frame them with frameit, and upload them with deliver, with the simulator names, sizes, and frame gaps that apply to fastlane 2.241.2.

Google Play Feature Graphic: Size, Placement, and How to Make One
How to make the 1024 x 500 Google Play feature graphic, where Google shows it, how to keep your logo and app name out of the cutoff zones and the video play button, and how to check the file before Play Console sees it.