---
title: OAuth (self-hosted)
description: OAuth 2.1 + PKCE + Dynamic Client Registration wire format for a self-hosted @ggui-ai/mcp-server (enable with `oauth: true` / `ggui serve --oauth`), with the access-token-IS-the-API-key trade-off explained.
---

A self-hosted [`@ggui-ai/mcp-server`](/oss-quickstart/) — enabled with `oauth: true` on the factory or `ggui serve --oauth` — implements [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) with [PKCE](https://datatracker.ietf.org/doc/html/rfc7636), [Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591) (RFC 7591), the [Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) discovery profile (RFC 9728), and the [Resource Indicators](https://datatracker.ietf.org/doc/html/rfc8707) extension (RFC 8707) for per-app token scoping. MCP-aware hosts can connect with just a server URL — no manually-issued `client_id`, no shared secret. To onboard a remote host (Claude Desktop, claude.ai, Goose) against your local server, front it with a tunnel (ngrok / cloudflared) and pass `--public-base-url=<tunnel-url>` so the discovery URLs resolve from the host's browser.

This page is the wire reference. If you only want to **connect a client** ([Claude Desktop](/clients/claude-desktop/), claude.ai, Goose, VS Code Copilot), the host handles everything — skip this page. Read on if you're building a host, debugging a custom client, or operating a deployment of `@ggui-ai/mcp-server`.

:::note[Why this exists]
Current MCP spec drafts require remote MCP servers to surface OAuth-discoverable auth so hosts can onboard a server with just a URL. The OSS [`@ggui-ai/mcp-server`](/oss-quickstart/) speaks the full flow when you enable it. The hosted endpoint at `mcp.ggui.ai` will use the same flow — coming soon; it is not part of GGUI Preview 0.1.0.
:::

## The flow at a glance

```
Client                            Server                            User
  │                                 │                                 │
  │── GET /mcp (no auth) ─────────→│                                 │
  │← 401 WWW-Authenticate:         │                                 │
  │   Bearer realm="mcp",          │                                 │
  │   resource_metadata="<url>" ──│                                 │
  │                                 │                                 │
  │── GET /.well-known/             │                                 │
  │   oauth-protected-resource ───→│                                 │
  │← { authorization_servers } ───│                                 │
  │                                 │                                 │
  │── GET /.well-known/             │                                 │
  │   oauth-authorization-server ─→│                                 │
  │← { authorize_endpoint, ... } ─│                                 │
  │                                 │                                 │
  │── POST /oauth/register ───────→│  (RFC 7591 DCR — no client_secret)
  │← { client_id } ───────────────│                                 │
  │                                 │                                 │
  │── open /oauth/authorize ──────→│── in-process consent form ───→│
  │                                 │                                 │── paste key / pair code + Approve
  │                                 │← form POST {api_key, params} ─│
  │← 302 → redirect_uri?code=… ───│                                 │
  │                                 │                                 │
  │── POST /oauth/token            │                                 │
  │   {code, code_verifier} ──────→│                                 │
  │← { access_token: <api-key> } ─│                                 │
  │                                 │                                 │
  │── GET /mcp                     │                                 │
  │   Authorization: Bearer       ─→│                                 │
  │← MCP session ─────────────────│                                 │
```

## The access token IS the API key

The simplest possible bridge between MCP's OAuth-required client UX and ggui's existing API-key model:

- **No parallel token table.** The `access_token` returned at `/oauth/token` is verbatim the bearer key your `AuthAdapter` accepts on `/mcp` — pasted (or, with `ggui serve`, exchanged from the terminal pair code) during consent.
- **No translation in the request hot path.** An authenticated `/mcp` request looks identical to a static-bearer request — `Authorization: Bearer <api-key>`.
- **No refresh dance.** The access token TTL equals the API key TTL. Revoke the key in your store and the client's next request returns `401`. Re-auth is one OAuth round trip — one paste from the user.
- **One audit surface.** Every connected client appears as a single row in your keys list, labelled with the `client_name` from DCR.

The trade-off: there's no "this token came from OAuth" flag — the key works identically whether minted by your operator tooling or the OAuth flow. If that distinction matters to your operator policy, plug in your own `OAuthStorage` + `AuthAdapter` (see [Storage seam](#storage-seam) below).

## Endpoints

### `GET /.well-known/oauth-protected-resource` (RFC 9728)

Tells the client where to find the authorization server. Same origin in our case — your server (here a tunnel exposing the local `ggui serve`) is both the resource and the auth server.

```json
{
  "resource": "https://your-mcp.example.com/mcp",
  "authorization_servers": ["https://your-mcp.example.com"],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://modelcontextprotocol.io/extensions/apps/overview"
}
```

### `GET /.well-known/oauth-authorization-server` (RFC 8414)

Authorization-server metadata.

```json
{
  "issuer": "https://your-mcp.example.com",
  "authorization_endpoint": "https://your-mcp.example.com/oauth/authorize",
  "token_endpoint": "https://your-mcp.example.com/oauth/token",
  "registration_endpoint": "https://your-mcp.example.com/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["mcp"]
}
```

`token_endpoint_auth_methods_supported: ["none"]` is intentional — PKCE is the only supported client authentication, no `client_secret` is ever issued.

### `POST /oauth/register` (RFC 7591)

Dynamic Client Registration. Issues a random `client_id`, no secret. Accepts arbitrary `redirect_uris` from the client without an allowlist — the trade-off matches the MCP spec's pragmatism: any client willing to do PKCE + paste-key gets registered.

```bash
curl -X POST https://your-mcp.example.com/oauth/register \
  -H "Content-Type: application/json" \
  -d '{
    "redirect_uris": ["http://localhost:33418/callback"],
    "client_name": "Claude Desktop"
  }'
```

```json
{
  "client_id": "mcp_client_AbCdEf...",
  "redirect_uris": ["http://localhost:33418/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "client_name": "Claude Desktop"
}
```

### `GET /oauth/authorize`

The user-facing approval entry point. Required query params:

| Param                   | Value                                                                                                      |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- |
| `response_type`         | `code`                                                                                                     |
| `client_id`             | from DCR                                                                                                   |
| `redirect_uri`          | one of the URIs registered with DCR                                                                        |
| `code_challenge`        | base64url(SHA256(verifier))                                                                                |
| `code_challenge_method` | `S256`                                                                                                     |
| `state`                 | opaque, echoed back on redirect (CSRF defense)                                                             |
| `scope`                 | `mcp` (advertised; not gated server-side today)                                                            |
| `resource`              | optional — RFC 8707 indicator naming the target MCP endpoint (per-app routing); omit for universal scoping |

With no `consentUrl` configured (the typical self-hosted posture), the server renders an unbranded paste-key HTML form in-process — no external origin needed. When the operator does configure a `consentUrl`, the server 302s the browser there with every OAuth param forwarded plus an `mcp_origin` param the consent page uses to know where to POST back. (The hosted endpoint will point `consentUrl` at its own console — coming soon.)

### `POST /oauth/authorize` (form-encoded)

Called by the consent UI (the in-process form by default) after the user approves. Same OAuth params echoed as hidden inputs, plus one of two credential fields:

- `pair_code` — the 6-digit code printed on the terminal banner by `ggui serve`. The server exchanges it through `PairingService.completePairing` to mint a per-server bearer. This is the easiest self-hosted path.
- `api_key` — a freshly-minted (or pasted) bearer key your `AuthAdapter` accepts.

The server validates the resulting key against the same `AuthAdapter` that gates `/mcp`, mints a 5-minute auth code, and 302s to the client's `redirect_uri?code=…&state=…`.

### `POST /oauth/token`

Exchange the auth code for an access token.

```bash
curl -X POST https://your-mcp.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://localhost:33418/callback&client_id=mcp_client_…&code_verifier=ORIGINAL_VERIFIER"
```

```json
{
  "access_token": "<api-key>",
  "token_type": "Bearer",
  "scope": "mcp"
}
```

No `refresh_token`, no `expires_in` — the access token's lifetime is the underlying API key's lifetime. Re-auth to get a new one.

If the client passed a `resource` at `/authorize` and includes one at `/token`, the two MUST match (RFC 8707 §2.2); mismatch returns `400 invalid_target`. Omitting `resource` at `/token` is tolerated even when the auth code captured one — RFC 8707 only mandates the constraint when the client opts in.

## Resource indicators (RFC 8707)

The server supports per-app token scoping via the optional `resource` query parameter at `/oauth/authorize`. Two shapes are accepted:

- **Universal** — `${issuer}` (cloud bare root) or `${issuer}/mcp` (OSS default path). Tokens bind to the user's full app surface.
- **Per-app** — `${issuer}/apps/<appId>` where `<appId>` matches the deployment's app-id pattern. The auth code, and any token minted from it, is captured against that single app target. Per-app deployments also surface their own `/.well-known/oauth-protected-resource` at the app-scoped path so RFC 9728 discovery resolves correctly when a client starts from the per-app URL.

Absent `resource`, universal scoping applies. Unknown resources are rejected at `/authorize` with `invalid_target` (RFC 8707 §2) before the consent step so the user sees the failure before pasting a key. OSS deployments can plug a `validateResource(issuer, resource)` callback into the server factory to gate which targets are accepted.

The captured resource is snapshotted onto the DCR client record (`lastResource`) so an operator console can label rows "Connected to: &lt;App&gt;" rather than "Universal".

## OSS deployment notes

**Running the CLI?** `ggui serve --oauth` mounts this whole surface — the `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` discovery endpoints plus `/oauth/{authorize,token,register}`. It's required for OAuth-discovery hosts (claude.ai / ChatGPT "Add custom connector"); pure-bearer clients work without it. The paste-key form accepts either a `ggui_user_*` key or the 6-digit pair code from the serve banner.

If you're embedding [`@ggui-ai/mcp-server`](/oss-quickstart/) programmatically, pass `oauth: true` (or an `OAuthConfig`) to the server factory:

```typescript
import { createGguiServer } from "@ggui-ai/mcp-server";

const server = createGguiServer({
  // ...
  oauth: {
    issuerUrl: "https://your-mcp.example.com",
    consentUrl: "https://your-console.example.com/oauth/consent", // optional
    storage: new InMemoryOAuthStorage(), // or your own
  },
  auth: yourAuthAdapter,
});
```

### Storage seam

Auth codes and DCR clients live in `OAuthStorage`. The default `InMemoryOAuthStorage` is fine for single-replica dev and any deployment with sticky sessions on the load balancer. Multi-replica deployments without sticky sessions need a shared backend — Redis or DynamoDB both work. The interface is two collections (auth codes, DCR clients) with `put` / `consume` / `get` methods; see `packages/mcp-server/src/oauth.ts` in the [public repo](https://github.com/ggui-ai/ggui) for the full type.

### Consent UI

`consentUrl` delegates the user-facing approval step to a separate origin. The consent UI sees both the user's session (whatever auth model it uses) and the freshly-minted API key in plaintext — same trust boundary as the MCP server itself. Validate the `mcp_origin` param against an allowlist before posting back, otherwise an attacker can craft a URL that exfiltrates a key to a third-party origin.

Without `consentUrl`, the server renders an in-process paste-key HTML form. Functional but unbranded — fine for OSS deployers who don't want to stand up a separate origin.

### Disable OAuth entirely

`oauth: false` (or simply omitting the option) skips the OAuth surface — `/oauth/*` and the well-known endpoints return 404, and `/mcp` falls back to the bearer-only `AuthAdapter` model. Hosts that don't speak OAuth-DCR can still authenticate by setting a static `Authorization: Bearer …` header.

## Hosted ggui (coming soon)

The managed endpoint at `mcp.ggui.ai` will run this identical flow with two deployment-specific choices: `consentUrl` points at its own console's consent page (so approval happens on a branded origin that mints a `ggui_user_*` key for you), and the universal MCP endpoint mounts at the bare root rather than `/mcp`. Neither is live yet — hosted ggui is not part of GGUI Preview 0.1.0, and nothing on this page depends on it.

## Non-goals

- **Not refresh-token compatible.** The OAuth ceremony is one-time per credential. Adding a refresh-token grant requires decoupling the access token from the API key (separate token store + lifetime).
- **Not OIDC.** No ID tokens, no userinfo endpoint, no nonce. The MCP spec doesn't ask for them, and the access-token-IS-the-API-key model doesn't need them.
- **Not a replacement for static API keys.** If your client can hold a static `Authorization: Bearer <api-key>` header (CLI agents, server-side runtimes), keep doing that. For local dev, `Authorization: Bearer dev` works against `ggui serve --dev-allow-all`. OAuth exists for hosts that need DCR + browser-mediated approval.