> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runlayer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth for Deployed MCP Servers

> Build custom MCP servers with OAuth that authenticate users against third-party services like Slack or Google.

# OAuth for Deployed MCP Servers

When you build a custom MCP server that needs to call third-party APIs (e.g., Slack, Google, Atlassian) on behalf of users, your server needs to broker its own OAuth flow with that service. This guide explains the dual-auth architecture and how to persist tokens across container restarts.

## Dual-Auth Architecture

There are two completely independent OAuth flows involved when an agent uses your deployed MCP server:

```mermaid theme={null}
sequenceDiagram
    participant Agent as Agent / Client
    participant MCP as Your Deployed MCP Server
    participant Vendor as Third-Party API (e.g., Slack)

    Note over Agent,MCP: Auth 1 — Runlayer Platform Auth
    Agent->>MCP: Every request includes Runlayer Bearer token
    MCP->>MCP: Identify user from Bearer token

    Note over MCP,Vendor: Auth 2 — Vendor OAuth (one-time setup per user)
    MCP->>Vendor: Redirect user to vendor OAuth consent
    Vendor-->>MCP: Authorization code
    MCP->>Vendor: Exchange code for access + refresh tokens
    MCP->>MCP: Store tokens keyed by user identity

    Note over Agent,Vendor: Subsequent tool calls
    Agent->>MCP: Tool call + Runlayer Bearer token
    MCP->>MCP: Look up stored vendor token for this user
    MCP->>Vendor: API call using vendor token
    Vendor-->>MCP: API response
    MCP-->>Agent: Tool result
```

### Auth 1 — Runlayer Platform Auth (Agent → MCP Server)

The agent authenticates to your MCP server using a **Runlayer Bearer token**. This token comes from Runlayer platform auth (e.g., Rippling SSO → Runlayer). The agent stores this token locally and sends it in the `Authorization` header on every request to `/mcp`.

This token identifies **who** is calling. Your server uses it to look up the right vendor tokens for that user.

### Auth 2 — Vendor OAuth (MCP Server → Third-Party)

Your MCP server acts as an **OAuth authorization server** itself. It hosts standard OAuth endpoints:

* `/.well-known/oauth-authorization-server` — discovery metadata
* `/authorize` — redirects the user to the vendor's consent screen
* `/token` — exchanges authorization codes and refreshes tokens

When a user first connects, your server brokers the OAuth flow with the vendor (Slack, Google, etc.), receives an access token and refresh token, and stores them keyed by the user's identity. On subsequent tool calls, the server looks up the stored tokens and uses them to make API calls on the user's behalf.

**The agent never sees the vendor token.** It's stored and used entirely server-side.

## DynamoDB Storage

Runlayer can provision a DynamoDB table for your deployed container. This is useful for any persistent storage your server needs — OAuth tokens, user preferences, cached data, etc.

In your `runlayer.yaml`, enable it with:

```yaml theme={null}
infrastructure:
  enable_db: true
```

This automatically:

* Creates a DynamoDB table with server-side encryption
* Sets up IAM permissions for your container
* Injects two environment variables: `DB_TABLE_NAME` and `DB_TABLE_REGION`

The table uses a composite key (`pk` string + `sk` string) with TTL support. Example usage:

```python theme={null}
import os
import time
import boto3

dynamodb = boto3.resource("dynamodb", region_name=os.environ["DB_TABLE_REGION"])
table = dynamodb.Table(os.environ["DB_TABLE_NAME"])

def save_tokens(user_id: str, access_token: str, refresh_token: str, expires_in: int):
    table.put_item(Item={
        "pk": user_id,
        "sk": "tokens",
        "access_token": access_token,
        "refresh_token": refresh_token,
        "expires_at": int(time.time()) + expires_in,
        "ttl": int(time.time()) + (90 * 86400),  # Auto-expire after 90 days
    })

def load_tokens(user_id: str) -> dict | None:
    resp = table.get_item(Key={"pk": user_id, "sk": "tokens"})
    return resp.get("Item")
```

<Warning>
  Once `enable_db` is set to `true` and deployed, it **cannot be disabled**. See [Deploy docs](/platform-deploy) for details.
</Warning>

## Connecting OAuth to Runlayer

Both approaches below start the same way:

1. **Register an OAuth app with the vendor** (Slack, Google, etc.) — note the client ID, client secret, and scopes
2. **Pass the credentials as env vars** in your `runlayer.yaml`
3. **Expose `/.well-known/oauth-authorization-server`** publicly (no auth) so Runlayer can discover your server's OAuth configuration

Where they differ is **who runs the OAuth flow and stores vendor tokens**.

### Option A: Let Runlayer handle OAuth (simpler)

Your server implements a `/register` endpoint ([Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591)) that returns the OAuth credentials it already has from env vars. When you register the connector, Runlayer calls `/register`, gets the credentials, and takes over from there — it drives the OAuth flow with the vendor and stores the resulting tokens.

```python theme={null}
@app.post("/register")
async def register_client(request: Request):
    body = await request.json()
    return JSONResponse({
        "client_id": os.environ["MY_OAUTH_CLIENT_ID"],
        "client_secret": os.environ["MY_OAUTH_CLIENT_SECRET"],
        "redirect_uris": body.get("redirect_uris", []),
    })
```

Your server doesn't need to implement `/authorize` or `/token`, doesn't need to store tokens, and doesn't need `enable_db`. It just receives a Bearer token on `POST /mcp` and uses it for vendor API calls.

This is the same pattern used by Runlayer's built-in connectors.

### Option B: Server handles OAuth itself (self-contained)

Your server implements the full OAuth flow internally:

* `POST /authorize` — redirect the user to the vendor's consent page
* `POST /token` — exchange auth codes for tokens, handle refresh
* Token storage in DynamoDB (via `enable_db: true`)

Your server owns everything: the OAuth endpoints, the credential exchange with the vendor, and the per-user token persistence. Runlayer discovers the OAuth configuration but your server runs the flow.

This is how the built-in Google Drive and Gmail connectors work:

```yaml theme={null}
infrastructure:
  enable_db: true

env:
  GOOGLE_OAUTH_CLIENT_ID: "${GOOGLE_OAUTH_CLIENT_ID}"
  GOOGLE_OAUTH_CLIENT_SECRET: "${GOOGLE_OAUTH_CLIENT_SECRET}"
  GOOGLE_OAUTH_REDIRECT_URIS: "$$RUNLAYER_OAUTH_CALLBACK_URL"
  BASE_URL: "$$DEPLOYMENT_URL"
```

### Comparison

|                    | Option A (Runlayer handles OAuth) | Option B (Server handles OAuth)                            |
| ------------------ | --------------------------------- | ---------------------------------------------------------- |
| Server implements  | `/.well-known/*` + `/register`    | `/.well-known/*` + `/authorize` + `/token` + token storage |
| Token storage      | Runlayer                          | Your server (DynamoDB)                                     |
| `enable_db` needed | No                                | Yes                                                        |
| Server complexity  | Lower                             | Higher                                                     |

### Manual fallback

If you don't implement DCR (`/register`) and your server doesn't handle OAuth itself, Runlayer will detect the OAuth endpoints via discovery and prompt you to enter the client ID and secret manually in the UI. This works but requires a manual step each time you set up the connector.

## OAuth Expose Paths and Auth Middleware

Regardless of which option you choose, certain endpoints must be accessible **without** a Bearer token:

| Endpoint                                      | Purpose                      | Required for |
| --------------------------------------------- | ---------------------------- | ------------ |
| `GET /.well-known/oauth-authorization-server` | OAuth discovery              | Both options |
| `POST /register`                              | Dynamic Client Registration  | Option A     |
| `POST /authorize`                             | OAuth authorization redirect | Option B     |
| `POST /token`                                 | OAuth token exchange         | Option B     |
| `POST /revoke`                                | OAuth token revocation       | Optional     |

Only list OAuth endpoints your server actually implements. Typical OAuth expose paths are `/.well-known/*`, `/register`, `/authorize`, `/token`, and optionally `/revoke`.

`/mcp` stays authenticated and should not be listed in `service.expose`. Non-OAuth deployed servers should not set `service.expose`.

If your auth middleware is applied **globally** (blocking all unauthenticated requests), connector registration will fail because Runlayer can't reach the OAuth discovery routes.

**Fix:** scope your auth middleware to protect `POST /mcp`, and allow only the OAuth routes with `service.expose` in your `runlayer.yaml`:

```yaml theme={null}
service:
  port: 8000
  path: /mcp
  expose:
    - "/.well-known/*"
    - "/register"
    - "/authorize"
    - "/token"
    - "/revoke"
```

## FAQ

**Q: Does the agent ever see the vendor token (e.g., Slack token)?**
A: No. The agent only holds a Runlayer Bearer token. The vendor token is stored and used server-side — either by Runlayer (Option A) or by your server (Option B).

**Q: Are the two OAuth flows related?**
A: No. Runlayer platform auth (SSO → Bearer token) and vendor OAuth (your server → Slack) are completely independent. The Runlayer token identifies the user; the vendor token authorizes API calls.

**Q: Why DynamoDB for token storage?**
A: Only needed for Option B. Deployed containers can restart or scale at any time, so in-memory or file-based storage won't survive. `enable_db: true` gives you persistent storage with no setup.

**Q: My connector registration fails. What's wrong?**
A: Most likely your auth middleware is blocking the discovery endpoints. Make sure `/.well-known/oauth-authorization-server` and any other required endpoints (see table above) are publicly accessible. Only `POST /mcp` should require a Bearer token.

**Q: Can I look at an example?**
A: The Runlayer-built connectors (Slack, Google Drive, Gmail) follow Option B — the server hosts its own OAuth endpoints, brokers the vendor flow, and stores tokens in DynamoDB. Open-source examples are coming soon.
