# How OAuth for MCP Works in Hosted vs Self‑Hosted Mode: A Complete Technical Guide

> Discover how OAuth for MCP functions in hosted versus self-hosted modes. Explore Cloudflare Workers OAuth Provider for secure token management with OpenSEO.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: deep-dive
- Published: 2026-08-10

---

**OpenSEO uses Cloudflare Workers‑OAuth‑Provider to issue and validate OAuth tokens for its MCP API, with strict identity enforcement in hosted mode and flexible external provider support in self‑hosted mode.**

The **Model‑Chat‑Protocol (MCP)** API in the [open-seo](https://github.com/every-app/open-seo) repository supports two distinct authentication architectures. Whether you run the managed SaaS or deploy to your own Cloudflare account, understanding these differences is critical for securing MCP tool calls correctly.

## How Hosted Mode Enforces Built‑In OAuth

In **hosted mode**, OpenSEO acts as its own OAuth authority. The platform creates a dedicated `OAuthProvider` via `createOpenSeoOAuthProvider` in [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) (lines 37‑44), exposing standard endpoints at `/api/auth/oauth2/*`.

### Provider Initialization and Endpoints

The provider is instantiated lazily with the current SaaS base URL:

```typescript
// src/server/mcp/oauth-provider.ts
const provider = createOpenSeoOAuthProvider({
  baseUrl: getHostedBaseUrl(),
  // ... additional configuration
});

```

This provider supplies three critical routes:

- `/authorize` – Initiates the consent flow
- `/token` – Exchanges authorization codes for access tokens
- `/register` – Allows dynamic client registration

### Session Blocker Redirects Anonymous Users

Before any OAuth flow begins, `getAuthorizeSessionBlocker` enforces authentication. It calls `resolveHostedContext` to verify a signed‑in user session. If the check fails, the user is redirected to `/sign‑in` rather than proceeding to consent.

The implementation in [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) (lines 62‑71) distinguishes hosted from self‑hosted behavior through the return value: hosted mode throws or redirects, while self‑hosted mode returns `null` to continue anonymously.

### Strict Token Payload Requirements

Hosted deployments use `hostedWorkersOAuthMcpPropsSchema`, defined in [`src/server/mcp/context.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/context.ts). This schema **requires** both `clientId` and `scopes` (lines 47‑52):

```typescript
// Hosted schema enforces client identity
const hostedWorkersOAuthMcpPropsSchema = workersOAuthMcpPropsSchema.extend({
  clientId: z.string().min(1),  // Required
  scopes: z.array(z.string()).min(1),  // Non-empty array required
});

```

The `createWorkersOAuthMcpProps` function pins the hosted base URL to every token, ensuring MCP calls remain scoped to `https://app.openseo.com`.

## How Self‑Hosted Mode Supports External Providers

**Self‑hosted mode** removes the built‑in provider entirely. Your deployment must integrate with an external OAuth authority—Google, GitHub, Auth0, or any custom implementation.

### Runtime Mode Detection

The switch between modes happens at runtime via `isHostedServerAuthMode()` in [`src/server/lib/runtime-env.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/runtime-env.ts) (lines 40‑42):

```typescript
// Returns true only when AUTH_MODE=hosted
export function isHostedServerAuthMode(): boolean {
  return getRuntimeEnv().AUTH_MODE === "hosted";
}

```

When this returns `false`, all code paths bypass hosted‑specific logic.

### Permissive Token Validation

Without a built‑in provider, `getOAuthHelpers` throws if invoked (lines 72‑78), and `resolveHostedContext` fails silently. The MCP transport accepts tokens minted externally using the generic `workersOAuthMcpPropsSchema`, where `clientId` and `scopes` are **optional**:

| Field | Hosted Requirement | Self‑Hosted Handling |
|-------|------------------|---------------------|
| `clientId` | Required, validated | Often omitted by external providers |
| `scopes` | Must include `"mcp"` | Assumed granted externally |
| `baseUrl` | Pinned to SaaS domain | Uses request's `Host` header |

### Flexible Origin Handling

The transport layer in [`src/server/mcp/transport.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/transport.ts) deliberately avoids origin pinning for self‑hosted deployments. This allows a single OpenSEO instance to serve multiple custom domains without token mismatches.

## Code Examples: Authentication Flows

### Hosted: Requesting a Token from OpenSEO

```typescript
// Build the authorization URL for the SaaS provider
const authorizeUrl = new URL("/api/auth/oauth2/authorize", "https://app.openseo.com");

authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", "openseo-mcp");
authorizeUrl.searchParams.set("redirect_uri", "https://app.openseo.com/callback");
authorizeUrl.searchParams.set("scope", "mcp");

// User is redirected to /sign-in if not authenticated
// After consent, the token payload contains:
// {
//   userId: "u_...",
//   userEmail: "user@example.com",
//   organizationId: "org_...",
//   clientId: "openseo-mcp",
//   scopes: ["mcp"],
//   baseUrl: "https://app.openseo.com"
// }

```

### Self‑Hosted: Integrating Google OAuth

```typescript
// Self-hosted deployments must obtain tokens externally
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID!,
    client_secret: process.env.GOOGLE_CLIENT_SECRET!,
    code: authorizationCode,
    grant_type: "authorization_code",
    redirect_uri: "https://seo.internal.company.com/callback",
    scope: "openid email mcp",
  }),
});

const { access_token } = await tokenResponse.json();

// Token may lack clientId/scopes—accepted by self-hosted validation

```

### Making an Authenticated MCP Request

```typescript
// Both modes use identical request formatting
const response = await fetch("https://<your-domain>/mcp/analyze-seo", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${access_token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com",
    depth: "comprehensive"
  }),
});

```

## Key Implementation Files

| File | Purpose |
|------|---------|
| [`src/server/mcp/oauth-provider.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/oauth-provider.ts) | `createOpenSeoOAuthProvider`, `getAuthorizeSessionBlocker`, `getOAuthHelpers` |
| [`src/server/mcp/context.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/context.ts) | Schema definitions: `workersOAuthMcpPropsSchema`, `hostedWorkersOAuthMcpPropsSchema`, `createWorkersOAuthMcpProps` |
| [`src/server/lib/runtime-env.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/runtime-env.ts) | `isHostedServerAuthMode()` for runtime detection |
| [`src/middleware/ensure-user/hosted.ts`](https://github.com/every-app/open-seo/blob/main/src/middleware/ensure-user/hosted.ts) | `resolveHostedContext` for session resolution |
| [`src/server/mcp/transport.ts`](https://github.com/every-app/open-seo/blob/main/src/server/mcp/transport.ts) | Request handling and base URL logic |

## Summary

- **Hosted mode** uses `AUTH_MODE=hosted` to activate a built‑in Cloudflare Workers‑OAuth‑Provider with mandatory `clientId`/`scopes` validation and automatic sign‑in redirection.
- **Self‑hosted mode** disables the provider, accepts externally minted tokens, and relaxes schema requirements to accommodate diverse OAuth authorities.
- The `isHostedServerAuthMode()` function in [`src/server/lib/runtime-env.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/runtime-env.ts) controls all branching behavior at runtime.
- Token payloads in hosted mode are locked to the SaaS `baseUrl`, while self‑hosted deployments respect the incoming request's host header.

## Frequently Asked Questions

### What happens if I call `getOAuthHelpers` in self‑hosted mode?

The function throws an error because no `OAuthProvider` is initialized. Self‑hosted deployments must implement their own token validation logic or rely on middleware that validates external provider signatures before reaching MCP handlers.

### Can I use the hosted OAuth provider with a custom domain?

No. The hosted provider pins `baseUrl` to `getHostedBaseUrl()`, which returns the canonical SaaS domain. Self‑hosted mode is designed specifically for custom domain deployments where you control the OAuth authority.

### Why does the self‑hosted schema allow missing `clientId` and `scopes`?

External providers like Google or Azure AD may not include these fields in their access token payloads. Rather than reject valid tokens from major identity platforms, OpenSEO's `workersOAuthMcpPropsSchema` treats them as optional and assumes the external provider has already enforced appropriate consent.

### How do I migrate from hosted to self‑hosted without breaking MCP integrations?

You must reconfigure your MCP clients to authenticate against your new external OAuth provider instead of `https://app.openseo.com/api/auth/oauth2/*`. Update `client_id`, `redirect_uri`, and token endpoint URLs. The MCP tool_call formatting remains identical, but the token acquisition flow changes completely.