# The Cloudflare KV race that broke our MCP OAuth at random (and how we killed it)

> Source: <https://dev.to/anthony_builds/the-cloudflare-kv-race-that-broke-our-mcp-oauth-at-random-and-how-we-killed-it-a1g>
> Published: 2026-09-08 14:35:38+00:00

Originally published on the Katto blog: [https://katto.tech/blog/mcp-oauth-cloudflare-kv-race](https://katto.tech/blog/mcp-oauth-cloudflare-kv-race)

Katto is an AI video clipper. You give it a long video, it finds the best moments and cuts them into captioned vertical clips, and you can drive the whole thing from an AI agent through our official hosted MCP server at mcp.katto.tech. I build Katto on my own, in public, so here is a bug that cost me an evening and the fix that actually ended it.

Connecting Katto to Claude over MCP sometimes failed with "Authorization expired or was not approved. Please start over." The confusing part: the consent page had already shown "Connecting your MCP client...", which only happens after the user approves and the key is minted. So the approval clearly worked, yet the final step claimed it had not. And it was intermittent. Some connections went through cleanly, others died on the same account minutes apart.

The security rule for our hosted MCP is that the API key must never travel through the browser. So the OAuth handoff is server to server:

`/authorize` stores the OAuth request in Cloudflare KV under a transaction id.`/oauth/deposit` over an HMAC-signed channel.`/oauth/callback`, which reads that deposit back and completes the grant.
My first guess was an HMAC secret mismatch between the app and the worker. It was not. A bad signature makes `/oauth/deposit` reject with a visible error, and the user would have seen that. Instead they saw "Connecting...", which means the deposit POST returned 200. The key was minted and deposited fine. The failure came one hop later, at the callback.

Cloudflare Workers KV is eventually consistent. `/oauth/deposit` writes the deposit on whichever edge location served that request. The browser is then meta-refreshed, with zero delay, to `/oauth/callback`, which is a separate request that can land on a different edge location and read that key before it has propagated. It reads null, and the callback declares the transaction dead. When the two requests happen to hit the same location, it works. When they do not, you get "Authorization expired." It is a read-after-write across two requests, which KV explicitly does not guarantee.

That is why it looked random. It was random, in the sense that it depended on which edge served each of the two requests.

The first patches were the obvious ones: retry the callback read a few times (ten attempts, 500ms apart) and add a one second delay to the meta-refresh so KV gets a head start. That cut the failure rate sharply. It did not remove it. As long as the callback reads a key that another request just wrote, propagation can always take longer than your retry budget. Those were band-aids on a design flaw.

The real fix was to remove the cross-request read entirely. The `/oauth/deposit` request already holds everything it needs: the OAuth request (written at `/authorize`, long since propagated) and the freshly minted key, in hand, in memory. So it completes the grant right there and returns the final redirect URL. The browser goes straight to the client with the authorization code. It never round-trips through the callback to re-read a key that was written moments ago.

We kept the old callback as a fallback and shipped it backward compatible: the consent page uses the returned redirect URL when the worker provides one, and otherwise falls back to the callback, so an old and a new deploy can never break each other during a rollout. After the change, fresh connections stopped failing.

If a value has to be available to the very next step, do not write it to eventually consistent storage and read it back in a separate request. Complete the work in the request that already holds the data, or use a strongly consistent store. Retries and delays hide the race; removing the cross-request read is what ends it. We found this by dogfooding our own MCP the way a reviewer would, before a reviewer did.
