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, Zendesk, Google) on behalf of users, someone has to run an OAuth flow with that service and hold the resulting tokens. On Runlayer Deploy that is either Runlayer, using an OAuth client you enter on the connector, or your server, hosting its own OAuth authorization server. This guide explains the dual-auth architecture, the two modes, 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 (Runlayer or MCP Server → Third-Party)

When a user first connects, someone runs an OAuth flow with the vendor (Slack, Zendesk, Google, etc.), receives an access token and refresh token, and stores them keyed by the user’s identity. On later tool calls the stored token is used to call the vendor API on the user’s behalf. On Runlayer Deploy that someone is one of:
  • Runlayer, using a Pre-registered Client you enter on the connector’s Settings tab. Runlayer stores the tokens and sends the user’s vendor access token to your server as the Bearer token on /mcp.
  • Your MCP server, acting as a container-hosted authorization server with its own /.well-known/oauth-authorization-server, /authorize, and /token endpoints. Your server stores the tokens.
Connecting OAuth to Runlayer covers how to set up each. 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:
This automatically:
  • Creates a DynamoDB table with server-side encryption
  • Sets up IAM permissions for your container
  • Injects DB_TABLE_NAME, DB_TABLE_REGION, and TOKEN_ENCRYPTION_KMS_KEY_ID
TOKEN_ENCRYPTION_KMS_KEY_ID contains the ARN of a customer-managed KMS key with automatic rotation. The deployment task role can use it for kms:GenerateDataKey and kms:Decrypt. When TOKEN_ENCRYPTION_KMS_KEY_ID is set, DynamoDBTokenStore uses the key automatically to encrypt sensitive token fields with AES-256-GCM. TOKEN_ENCRYPTION_MODE selects how strictly: bridge (the default) also keeps a plaintext copy so older connector builds can still read the record, while encrypted requires ciphertext for all reads and writes and fails closed if a record cannot be decrypted. Runlayer sets encrypted on newly created deployments; deployments created earlier stay on bridge until their tables hold no plaintext-only records. Passing mode in code takes effect only when TOKEN_ENCRYPTION_MODE is unset; if both are set to different values, the store fails at startup.
DynamoDB server-side encryption applies to the whole table. Application-layer encryption applies only when your code uses the KMS key; custom token stores must encrypt sensitive fields before writing them.
The table uses a composite key (pk string + sk string) with TTL support. Example usage:
The example below demonstrates DynamoDB access only. Do not store production OAuth credentials this way without application-layer encryption; use the Runlayer auth framework or an equivalent envelope-encryption implementation.
Once enable_db is set to true and deployed, it cannot be disabled. See Deploy docs for details.

Connecting OAuth to Runlayer

Both modes start with the vendor: register an OAuth app with it (Slack, Zendesk, Google, etc.) and note the client ID, client secret, and the scopes your server needs. In both modes, register https://<your-runlayer-host>/oauth/callback as the redirect URI with the vendor. Your container gets the same value as the RUNLAYER_OAUTH_CALLBACK_URL environment variable. Then pick a mode.

Pre-registered Client: Runlayer runs the vendor flow

Use this when the vendor issues you a fixed client ID and secret and you want no OAuth code in your container. Zendesk, for example, publishes no OAuth discovery metadata and offers no dynamic client registration, so this is the mode that fits it. Your server hosts no OAuth endpoints, needs no enable_db, and needs no service.expose. It has two jobs: reject requests to POST /mcp that carry no valid vendor token (respond 401 with a WWW-Authenticate: Bearer header), and use the token it does receive to call the vendor API. The client lives on the connector, not in runlayer.yaml:
  1. Run runlayer deploy init if you have not already. This creates the connector in Draft.
  2. In Runlayer, open the connector and go to its Settings tab.
  3. Set Authentication to OAuth 2.1 and Registration to Pre-registered Client.
  4. Enter the Client ID and Client Secret, the vendor’s Authorization URL and Token URL, and the OAuth Scopes your server needs.
  5. Save, then run runlayer deploy.
Until the client is entered, every deploy leaves the connector in Draft: Runlayer’s connection test reaches your server, gets a 401, and has no OAuth client to sign a user in with, so it does not activate the connector. Once the client is saved, the next deploy that succeeds activates it. You can enter the client before the first deploy or after one that left the connector in Draft.

Container-hosted authorization server: your server runs the vendor flow

Use this when your server needs to control the vendor exchange itself, or when the vendor client secret must stay inside the container. Your server acts as an OAuth authorization server. It hosts:
  • GET /.well-known/oauth-authorization-server — discovery metadata, with every URL built from DEPLOYMENT_URL
  • GET /authorize — redirects the user to the vendor’s consent page
  • POST /token — exchanges authorization codes and refreshes tokens
  • POST /register — optional. Returns the client Runlayer should use when talking to your server. Without it, an admin enters that client on the connector’s Settings tab instead.
  • Token storage in DynamoDB (enable_db: true)
Runlayer discovers your metadata, sends the user’s browser to your deployment’s /authorize, and calls your /token. Your server owns the credential exchange with the vendor and the per-user tokens. The vendor client goes in runlayer.yaml as env vars. This is how Runlayer’s built-in Slack, Google Drive, and Gmail connectors work:
On Runlayer Deploy, Runlayer always sends the user’s browser to your deployment’s own /authorize through the public proxy. It does not follow the authorization_endpoint in your discovery document. A container that points its discovery document at the vendor’s authorization endpoint and serves no /authorize of its own cannot complete a login, and its connector stays in Draft. Either serve /authorize or use a Pre-registered Client.

Comparison

OAuth Expose Paths and Auth Middleware

When your container hosts the authorization server, its OAuth endpoints must be accessible without a Bearer token: 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. Servers that use a Pre-registered Client, and servers without OAuth, host no OAuth endpoints and 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:

FAQ

No. The agent only holds a Runlayer Bearer token. The vendor token is stored and used server-side — either by Runlayer (Pre-registered Client) or by your server (container-hosted authorization server).
Only needed when your server hosts the authorization server. 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.
Runlayer’s connection test did not pass, so it did not activate the connector. If your vendor issued you a fixed client ID and secret, open the connector’s Settings tab, choose Pre-registered Client, enter the client and the vendor’s authorization and token URLs, and deploy again. If your server hosts the authorization server, your auth middleware is probably blocking its endpoints: make sure /.well-known/oauth-authorization-server, /authorize, and /token are listed in service.expose and served without a Bearer token.
The Runlayer-built connectors (Slack, Google Drive, Gmail) host their own authorization server: the server runs the vendor flow and stores tokens in DynamoDB. Open-source examples are coming soon.