docs/en/guides/11-oauth.md
OpenViking server ships a native OAuth 2.1 implementation. Any client that
needs OAuth — including MCP clients (Claude.ai, Claude Desktop, ChatGPT,
Cursor) and any future first-party browser app — can authorize against the
server directly, without a third-party proxy. The protocol surface (DCR,
authorize, token, metadata) is provided by the official mcp.server.auth
SDK and is otherwise standards-compliant OAuth 2.1, so non-MCP OAuth clients
work just as well.
Prerequisite: public HTTPS. OAuth 2.1 (and the MCP SDK) requires HTTPS for any non-localhost issuer. See the Public Access Guide for how to set up HTTPS with Caddy or nginx.
Set up HTTPS — follow Public Access Guide to
get https://ov.your-domain.com working (Caddy + .env +
docker compose up).
Enable OAuth in ~/.openviking/ov.conf:
{ "oauth": { "enabled": true } }
Restart (docker compose restart openviking).
Connect a client. In Claude.ai → Connectors → Add, enter
https://ov.your-domain.com/mcp. The browser flips to
https://ov.your-domain.com/studio/oauth/consent: if you aren't already
signed in to Studio, paste your API key into the "Connection & Identity"
dialog; then click Authorize on the consent card. Claude.ai gets the
token and the connector is live.
That's the whole production path. The rest of this guide explains why each piece exists, how to test locally without HTTPS, and how to verify with curl when something doesn't work.
Some MCP clients only accept OAuth 2.1, not API keys. Until now the only path was to deploy the community MCP-Key2OAuth Cloudflare Worker proxy that translates OAuth into an API-Key bearer. Native support removes:
API-Key auth still works as before — OAuth layers on top.
OpenViking's authorize UI runs inside OpenViking Studio by default (same-origin with the main server and sharing Studio's session). When an MCP client opens the browser to authorize:
1. MCP client POST /mcp → 401 + WWW-Authenticate header
2. MCP client GET /.well-known/oauth-protected-resource (RFC 9728)
3. MCP client GET /.well-known/oauth-authorization-server (RFC 8414)
4. MCP client POST /register (Dynamic Client Registration, RFC 7591)
5. MCP client GET /authorize?... (browser redirect)
6. Server → /studio/oauth/consent?pending=...
(consent SPA loads and fetches
/api/v1/auth/oauth/pending/<id> to render
client_name / redirect_host / scopes)
7. User confirms on the consent card in their already signed-in
Studio tab (or picks "Use a different API key" in the
IdentityPicker to do a one-off authorize)
8. Studio POST /api/v1/auth/oauth-verify
(Authorization: Bearer <api-key>,
body: {pending_id, decision})
Server marks pending as verified and binds the caller's
identity (account / user / role).
9. Studio polls /oauth/authorize/page/status, sees "approved",
redirects browser to the MCP client's redirect_uri with code
10. MCP client POST /token (PKCE S256) → access_token (ovat_...) +
refresh_token (ovrt_...)
11. MCP client POST /mcp (Authorization: Bearer ovat_...) → tool list
Consent happens inside Studio, so there's no cross-tab code copying on
the happy path. Studio already holds your API key in sessionStorage (the
one you entered when signing in); the consent SPA uses it as
Authorization: Bearer against /api/v1/auth/oauth-verify.
If you can't open Studio on the current device (CLI MCP clients, cross-device
authorize), the consent page's "Use another device →" link falls back to the
server-rendered HTML page at /oauth/authorize/page: it displays a 6-char
display_code, which you type at /studio/oauth/verify on another device
that's already signed in to Studio.
The "push" OTP endpoint POST /api/v1/auth/otp is still available for
CLI / scripted scenarios — and the OAuth client OTP block in the
Studio sidebar footer's OAuth setup entry (opens a dialog on desktop,
routes to /studio/oauth/setup on phone) gives you a one-click way to
mint a short-lived OTP to hand to an MCP client.
The fastest way to verify OAuth is wired correctly is on 127.0.0.1. The MCP
SDK accepts http://127.0.0.1 and http://localhost as issuer URLs without
HTTPS — but Claude.ai and Claude Desktop themselves require public HTTPS
endpoints, so this mode is only useful for local testing with tools like
MCP Inspector.
Enable OAuth in ~/.openviking/ov.conf:
{
"oauth": {
"enabled": true
}
}
Start the services:
docker compose up -d
Or without Docker:
openviking-server
Open Studio and sign in at http://127.0.0.1:1933/studio. From the top-right open "Connection & Identity", paste your API key, click Save.
Connect a local MCP client (e.g. MCP Inspector) to
http://127.0.0.1:1933/mcp. The client will hit the OAuth flow above and
the browser will land on /studio/oauth/consent?pending=...; click
Authorize and the client will receive a token. For push-style scenarios,
use the OAuth client OTP block in the sidebar footer's OAuth
setup entry (/studio/oauth/setup on phone).
For Claude.ai / Claude Desktop on the public internet, see the Public Access Guide.
OAuth 2.1 requires HTTPS for any non-localhost issuer. The Public Access Guide covers the full setup — Caddy, nginx, docker compose, CDN — in detail. The short version:
https://your-domain.com serving port 1934 over TLS.{ "oauth": { "enabled": true } } in ov.conf.docker compose restart openviking.OPENVIKING_PUBLIC_BASE_URL=https://your-domain.com in .env (the
server uses this as the issuer in OAuth metadata and WWW-Authenticate).Once HTTPS + OAuth are both up, connect clients as described below.
https://my.ov/mcp as the server URL.https://my.ov/studio/oauth/consent?pending=....If you triggered authorization from a CLI device with no local browser, either copy the authorize URL to a desktop browser, or click "Use another device →" on the consent page to switch to the 6-character display_code path (enter it on another device's
/studio/oauth/verify).
The same flow works from Claude Desktop. For Claude Code, the simpler path is still API key:
claude mcp add --transport http openviking https://my.ov/mcp \
--header "Authorization: Bearer <api-key>"
If you want Claude Code to drive OAuth, the connector flow is identical to Claude.ai's once configured.
Connector setup is via Settings → Beta features → Custom Connectors. Enter
the MCP URL; ChatGPT discovers the OAuth endpoints from the
/.well-known/... documents and walks the same authorize → token flow.
Cursor's MCP integration honors OAuth automatically when the server URL
returns a 401 with WWW-Authenticate: Bearer resource_metadata=.... Add the
URL via Cursor's MCP settings.
curlYou can drive the entire flow without a real MCP client:
# 1. Register a client
curl -X POST -H "Content-Type: application/json" \
-d '{"redirect_uris":["http://127.0.0.1:9999/cb"],"client_name":"test","token_endpoint_auth_method":"none"}' \
https://my.ov/register
# → {"client_id":"...", ...}
# 2. PKCE pair
VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | head -c 64)
CHALLENGE=$(printf "%s" "$VERIFIER" | openssl dgst -sha256 -binary | basenc --base64url | tr -d '=')
# 3. Open the authorize URL in a browser. The page shows a 6-char code.
echo "https://my.ov/authorize?response_type=code&client_id=$CID&redirect_uri=http://127.0.0.1:9999/cb&code_challenge=$CHALLENGE&code_challenge_method=S256&state=xyz"
# 4. Approve from the Studio consent page (or via API).
# - Studio path uses pending_id (?pending=... from the authorize URL).
# - Cross-device path uses the 6-char display_code.
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"pending_id":"<pending-id-from-authorize-url>","decision":"approve"}' \
https://my.ov/api/v1/auth/oauth-verify
# 5. The browser auto-redirects to /cb?code=ovac_...&state=xyz. Copy the code.
# 6. Exchange the auth code for tokens.
curl -X POST \
-d "grant_type=authorization_code&code=ovac_...&client_id=$CID&code_verifier=$VERIFIER&redirect_uri=http://127.0.0.1:9999/cb" \
https://my.ov/token
# → {"access_token":"ovat_...","refresh_token":"ovrt_...","expires_in":3600}
# 7. Call MCP with the access token.
curl -X POST -H "Authorization: Bearer ovat_..." \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
https://my.ov/mcp
ov.conf excerpt:
{
"oauth": {
"enabled": false, // off by default
"issuer": null, // e.g. "https://my.ov" (optional; env var wins)
"access_token_ttl_seconds": 3600, // 1 hour
"refresh_token_ttl_seconds": 2592000, // 30 days
"auth_code_ttl_seconds": 300, // 5 minutes
"otp_ttl_seconds": 300, // 5 minutes
"db_filename": "oauth.db" // relative to storage.workspace
}
}
Environment variables:
| Variable | Purpose |
|---|---|
OPENVIKING_PUBLIC_BASE_URL | Highest-priority public origin override (used as issuer, in PRM, in WWW-Authenticate) |
OPENVIKING_CONFIG_FILE | Path to ov.conf (or pass --config) |
| Token | Format | Prefix | Lifetime | Storage |
|---|---|---|---|---|
| Access token | secrets.token_urlsafe(40) | ovat_ | 1 hour | SQLite (SHA-256 indexed) |
| Refresh token | secrets.token_urlsafe(40) | ovrt_ | 30 days | SQLite (SHA-256 indexed) |
| Authorization code | secrets.token_urlsafe(40) | ovac_ | 5 minutes | SQLite (SHA-256 indexed) |
| Display code (page) | 6-char alphanumeric (no O/0/I/1) | — | 10 minutes | SQLite (oauth_pending_authorizations) |
All tokens are opaque; OpenViking does not issue JWTs. There is no
cryptographic key to manage on the server side. Token claims are looked up
from SQLite on every request, so revoking a token is a single UPDATE.
Each issued token is bound to a single (account_id, user_id, role) triple
recorded at authorization time. An OAuth token grants the same permissions
as the API key that produced it — not more, not less.
Every issued token additionally records the SHA-256 fingerprint of the API key whose holder authorized it. On every OAuth bearer request the server recomputes the user's current key fingerprint and demands a strict match. The practical effects:
regenerate_key) immediately invalidates
every OAuth access and refresh token previously issued under that user.
No manual revocation step is needed — the next bearer request gets a 401
asking the client to re-authorize.remove_user) has the same effect: the fingerprint
lookup returns None and all the user's OAuth tokens stop working./api/v1/auth/otp and
/api/v1/auth/oauth-verify reject these callers with 400.The fingerprint is sha256(stored_key_value), where the stored value is
either the plaintext key (when API key hashing is disabled) or its
Argon2id hash (when enabled). Both are written once at create / regenerate
and never mutate, so the fingerprint is stable until the next rotation.
The first thing Claude.ai does is GET /.well-known/oauth-protected-resource
on the URL you entered. If that 404s, the OAuth flow doesn't start. Check:
curl -i https://my.ov/.well-known/oauth-protected-resource
You should get a JSON body with authorization_servers. If you get 404,
either OAuth is disabled (oauth.enabled = false) or the reverse proxy isn't
forwarding /.well-known/... to 1933.
The MCP SDK rejects http:// issuers other than 127.0.0.1 / localhost.
Either:
OPENVIKING_PUBLIC_BASE_URL=https://my.ov, oroauth.issuer to an https:// URL in ov.conf, orhttp://127.0.0.1:1933 directly/studio/oauth/verify says "Invalid code"Codes are 6 characters, uppercase, and case-sensitive on the wire. The
/studio/oauth/verify input normalizes to uppercase. If you're typing
manually, check for letters that look like digits (the alphabet excludes
O, 0, I, 1 to reduce confusion).
Refresh tokens are one-shot. If you refresh and both the old and new refresh tokens get used (e.g. by a buggy client), the second one will be rejected and that token chain is revoked entirely (RFC 9700 §4.14). The client must restart the authorize flow.
WWW-Authenticate header missing on /mcp 401The header is only emitted when an oauth_provider is registered on
app.state — i.e. when oauth.enabled = true. Confirm with:
curl -i https://my.ov/mcp -d '{}' -H 'Content-Type: application/json' | grep -i www-authenticate