← All essays· Engineering

Native OAuth 2.1 for MCP Clients

How we removed the proxy and let ChatGPT, Claude, and Cursor connect to OpenViking directly.

Claude.ai, ChatGPT, Cursor — they all speak OAuth, not API keys. We built native OAuth 2.1 into OpenViking so these clients can connect without a proxy.

Before this change, connecting an MCP client to OpenViking required a Cloudflare Worker proxy. You deploy a Worker, configure two KV namespaces, and bridge OAuth tokens to API keys. It works. It also means you are trusting a third party with your credentials, paying for another service, and debugging across two systems when something breaks.

What the user sees

The entire flow, from the user side:

1
Client requests MCP
POST /mcp → 401 + WWW-Authenticate header. Client auto-discovers OAuth endpoints.
2
Browser opens
Authorization page shows a 6-character code. Human-readable, no ambiguous characters.
3
User confirms
If already logged into Console in the same browser, one click. Otherwise, enter the code in Console.
4
Done
Client gets an access token + refresh token. All subsequent MCP calls use Bearer auth.

No API key pasted anywhere. No proxy deployed. No extra services to maintain.


Design decisions

Six choices shaped the implementation. Each one is a tradeoff:

DecisionChoiceWhy
Protocolmcp.server.auth SDKZero crypto code on our side. DCR, PKCE, redirect validation all handled upstream.
Token formatOpaque + SQLiteNo signing keys to manage. Revocation is one UPDATE. SHA-256 hash index, microsecond lookups.
Auth flowDevice-flow style"I approve that request from a place I trust." Users confirm in an already-authenticated browser.
Quick authlocalStorage + explicit clickConvenient but not automatic. User always knows what they are approving.
Redirect whitelistNone (DCR accepts any URI)Matches MCP ecosystem behavior. SDK does strict-equal validation internally.
Revocation scopePer (account, user)API key rotation revokes all OAuth state for that user. Clean break.

Tokens

Four token types, each with a prefix for fail-closed routing:

ovat_ · 1hovrt_ · 30dovac_ · 5min6-char · 10min

Deployment

The PR adds a Caddy reverse proxy that merges two ports into one:

Caddyfilejs
:1934 {  handle /console/* {    reverse_proxy :8020  }  handle {    reverse_proxy :1933  }}

Why merge?

Same-origin guarantee. Console and OAuth pages share one origin, so Quick Authorize works out of the box.

HTTPS?

OAuth 2.1 requires HTTPS for non-localhost issuers. Add a domain block to Caddyfile and uncomment 3 lines in docker-compose. Done.


Backwards compatibility

  • Off by defaultoauth.enabled: false means zero side effects. No routes mounted, no bearer inspection.
  • Bearer routing — Only tokens with ovat_ prefix go through OAuth lookup. Plain bearer tokens still hit the API key path.
  • Fail-closed — If a token has the OAuth prefix but is not found, it is 401. No fallback to API key. No ambiguity.
ov.confjs
{  "oauth": {    "enabled": true  }}

Verified clients

ClientStatus
ChatGPTWorking
Claude DesktopWorking
MCP InspectorWorking
CursorPending

37 new tests cover the full flow: storage atomics, device-flow happy path, bearer fail-closed, refresh rotation, replay detection, and WWW-Authenticate header injection. Zero regressions in existing auth tests.