Skip to main content

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:

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:
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:
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")
Once enable_db is set to true and deployed, it cannot be disabled. See Deploy docs for details.

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) 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.
@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:
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 storageRunlayerYour server (DynamoDB)
enable_db neededNoYes
Server complexityLowerHigher

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.

Public Endpoints and Auth Middleware

Regardless of which option you choose, certain endpoints must be accessible without a Bearer token:
EndpointPurposeRequired for
GET /.well-known/oauth-authorization-serverOAuth discoveryBoth options
POST /registerDynamic Client RegistrationOption A
POST /authorizeOAuth authorization redirectOption B
POST /tokenOAuth token exchangeOption B
Only POST /mcp should require authentication. If your auth middleware is applied globally (blocking all unauthenticated requests), connector registration will fail because Runlayer can’t reach the discovery endpoints. Fix: scope your auth middleware to only protect POST /mcp, or use service.expose in your runlayer.yaml:
service:
  port: 8000
  path: /mcp
  expose:
    - "/.well-known/*"
    - "/authorize"
    - "/token"
    - "/register"

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.