Guide · Enterprise Security
MCP server SAML SSO
Enterprise teams running internal MCP servers face a common access-control problem: each server has its own API key or OAuth config, so adding a new employee means updating credentials in ten places, and offboarding them means auditing ten systems to verify revocation. SAML 2.0 and OIDC single sign-on solves this by routing all MCP authentication through your corporate identity provider — Okta, Azure AD, Ping, or any SAML 2.0 IdP. Users authenticate once, the IdP issues a session, and the MCP server receives a signed JWT carrying the user's identity, group memberships, and the scopes your IAM team controls. Every tools/call is attributed to a specific user, which is what compliance auditors actually check.
TL;DR
Put a reverse proxy sidecar (OAuth2 Proxy or Vouch Proxy) in front of your MCP server. Configure it to require a valid OIDC/SAML session, then forward the user's JWT as X-Auth-User and X-Auth-Groups headers to the MCP server. The MCP server reads those headers to enforce per-user tool access and log user-attributed tools/call events. Monitor the auth layer separately from the MCP server itself with AliveMCP — a SAML misconfiguration surfaces as a 401/403 on the MCP endpoint, which looks identical to a server outage if you're not distinguishing error types.
Why SAML SSO for MCP servers
An enterprise team running 15 internal MCP servers has 15 independent auth configs. Each server might use a different API key rotation policy, a different OAuth client ID, or different allowed-origin rules. The problems compound quickly:
- Onboarding friction: a new engineer needs access to seven MCP servers — that's seven separate credential grants to coordinate.
- Offboarding risk: a departing employee's access must be revoked in each server individually; miss one and their API key still works.
- Audit gap: individual server logs record requests by API key, not by human identity — useless for SOC 2 user activity reviews.
- Scope sprawl: teams that need read-only access to a data MCP but write access to an ops MCP have to manage that distinction in every server's config.
SAML SSO centralizes all of this in your IdP. Add an employee to the mcp-dev-team AD group, and they automatically get the scopes every MCP server in that group allows. Remove them from the group, and access is revoked everywhere in one action — no per-server credential rotation.
| Auth pattern | Onboarding | Offboarding | Audit trail | Scope control |
|---|---|---|---|---|
| Per-server API keys | N grants for N servers | N revocations for N servers | Key identity only | Per-server config |
| Shared OAuth client | 1 grant, same scope everywhere | 1 revocation | Client ID only | Coarse (same scope everywhere) |
| SAML SSO via IdP | 1 IdP group assignment | 1 IdP group removal | User DN + group context | Fine-grained via group-to-role mapping |
Architecture: reverse proxy sidecar pattern
MCP servers use HTTP-based transports (SSE or streamable HTTP). This means a standard reverse proxy can intercept all traffic before it reaches the MCP server process. The sidecar pattern keeps SSO logic out of the MCP server implementation — the MCP server only needs to read headers, not implement an OAuth flow.
┌─────────────────────────────────────────┐
│ Enterprise network │
│ │
[MCP client] ──────► [Reverse proxy + OAuth2 Proxy sidecar] │
│ │ │
│ │ X-Auth-User: alice@corp.com │
│ │ X-Auth-Groups: mcp-dev,sre │
│ ▼ │
│ [MCP server :8080] │
│ │
│ [Okta / Azure AD / Ping IdP] │
└─────────────────────────────────────────┘
The proxy intercepts the request, validates the OIDC/SAML session cookie, fetches the user's claims from the IdP token, and forwards user identity in request headers. The MCP server reads those headers and uses them for access control and audit logging.
OAuth2 Proxy setup with Okta OIDC
OAuth2 Proxy is the most widely deployed open-source solution for this pattern. It supports Okta, Azure AD, Google Workspace, and any OIDC provider:
# oauth2-proxy.cfg — place in front of each MCP server
# IdP configuration (Okta example)
provider = "oidc"
client_id = "0oa5abc123XYZ"
client_secret = "$OKTA_CLIENT_SECRET"
oidc_issuer_url = "https://your-org.okta.com/oauth2/default"
redirect_url = "https://mcp-search.internal/oauth2/callback"
# What to forward to the MCP server
set_xauthrequest = true # enables X-Auth-Request-User header
pass_access_token = true # forwards raw JWT as X-Forwarded-Access-Token
set_authorization_header = true # forwards as Authorization: Bearer <token>
# Session config
cookie_secret = "$COOKIE_SECRET" # 32-byte random base64
cookie_name = "_mcp_session"
cookie_expire = "8h"
# Upstream MCP server
upstreams = ["http://localhost:8080"]
http_address = "0.0.0.0:443"
tls_cert_file = "/etc/tls/mcp-search.crt"
tls_key_file = "/etc/tls/mcp-search.key"
# Group-based access control
allowed_groups = ["mcp-dev", "mcp-platform", "sre-oncall"]
# docker-compose.yml — sidecar pattern
services:
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
volumes:
- ./oauth2-proxy.cfg:/etc/oauth2-proxy/oauth2-proxy.cfg:ro
ports:
- "443:443"
environment:
OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET}
COOKIE_SECRET: ${COOKIE_SECRET}
depends_on:
- mcp-server
mcp-server:
image: your-org/mcp-search:latest
ports:
- "127.0.0.1:8080:8080" # only accessible from localhost
environment:
MCP_AUTH_MODE: "header" # trust X-Auth-* headers from sidecar
Reading user identity in your MCP server
The MCP server receives user identity as HTTP headers. Read them in your tool handlers to enforce access control and build per-user audit trails:
from mcp.server.fastmcp import FastMCP
from fastapi import Request, HTTPException
import logging
import json
from datetime import datetime, timezone
logger = logging.getLogger("mcp.audit")
mcp = FastMCP("search-mcp")
def get_user_context(request: Request) -> dict:
"""Extract user identity from SSO sidecar headers."""
user_email = request.headers.get("X-Auth-Request-User", "")
groups_raw = request.headers.get("X-Auth-Request-Groups", "")
groups = [g.strip() for g in groups_raw.split(",") if g.strip()]
if not user_email:
raise HTTPException(status_code=401, detail="No authenticated user")
return {
"email": user_email,
"groups": groups,
"is_admin": "mcp-platform" in groups,
"can_write": any(g in groups for g in ["mcp-dev", "sre-oncall"]),
}
def audit_log(user: dict, tool_name: str, args: dict, result_summary: str):
"""Emit a structured audit log entry for every tools/call."""
logger.info(json.dumps({
"ts": datetime.now(timezone.utc).isoformat(),
"event": "mcp.tool_call",
"user": user["email"],
"groups": user["groups"],
"tool": tool_name,
"args_keys": list(args.keys()), # log keys not values (PII avoidance)
"result_summary": result_summary,
}))
@mcp.tool()
async def search_internal_docs(query: str, request: Request) -> str:
"""Search the internal documentation index."""
user = get_user_context(request)
# All authenticated users can search
result = await _do_search(query)
audit_log(user, "search_internal_docs", {"query": query}, f"{len(result)} results")
return result
@mcp.tool()
async def delete_index_document(doc_id: str, request: Request) -> str:
"""Delete a document from the index."""
user = get_user_context(request)
# Only platform team and SRE can delete
if not user["can_write"]:
raise HTTPException(status_code=403, detail=f"User {user['email']} not in write-access group")
result = await _do_delete(doc_id)
audit_log(user, "delete_index_document", {"doc_id": doc_id}, "deleted")
return result
Logging args_keys instead of args values is intentional: tool call arguments often contain the content being searched or processed, which may include PII. The audit trail needs to know which tool was called by whom — not the data itself.
Azure AD group-to-role mapping
Azure AD (Entra ID) exports group memberships as OIDC claims. Configure the app registration to include group claims, then map AD groups to MCP server roles:
# In Azure AD app registration → Token configuration:
# Add optional claim: groups (type: securityGroup)
# This adds a "groups" array of group OIDs to the access token
# In oauth2-proxy, map group OIDs to human-readable names:
# oauth2-proxy does not rename groups — do this in the MCP server
# Group OID mapping (load from environment or config)
AZURE_GROUP_ROLES = {
"7a1b2c3d-...": "mcp-dev", # Engineering AD group
"8b2c3d4e-...": "mcp-platform", # Platform team AD group
"9c3d4e5f-...": "sre-oncall", # SRE on-call AD group
}
def resolve_roles_from_azure_groups(raw_groups: list[str]) -> list[str]:
"""Convert Azure AD group OIDs to MCP role names."""
return [
AZURE_GROUP_ROLES[oid]
for oid in raw_groups
if oid in AZURE_GROUP_ROLES
]
Store the OID mapping in a config file rather than environment variables so that adding new groups doesn't require a container restart. Reload on SIGHUP or check for config changes on a short interval.
Just-in-time (JIT) user provisioning
In a large organisation, you don't want to pre-create accounts for every employee who might access an MCP server. JIT provisioning creates or updates local user records the first time a SAML assertion is received for a new user:
import sqlite3
from datetime import datetime, timezone
DB = sqlite3.connect("./data.db")
def provision_user_if_new(email: str, groups: list[str]) -> None:
"""Upsert user on first SAML login — JIT provisioning."""
now = datetime.now(timezone.utc).isoformat()
DB.execute("""
INSERT INTO mcp_users (email, groups_json, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
groups_json = excluded.groups_json,
last_seen_at = excluded.last_seen_at
""", (email, json.dumps(groups), now, now))
DB.commit()
This table is the local roster your audit reports draw from. Cross-reference it against the IdP's user list monthly to find stale records — a user deprovisioned in the IdP shouldn't appear in your MCP server's user roster with a recent last_seen_at. If they do, your sidecar's group validation isn't working.
Monitoring SAML auth regressions
SAML misconfiguration is one of the most common causes of enterprise MCP outages, and it's the hardest to diagnose because it presents identically to a server outage from the outside. When a SAML certificate expires, when an Okta policy change adds a new required claim your proxy doesn't forward, or when a network change breaks the IdP callback URL, all MCP clients see the same thing: a connection that was working yesterday now returns 401 or 403 on the first request.
Three monitoring practices prevent auth regressions from going undetected:
- Monitor the health endpoint separately from the auth-protected endpoint. Expose a
/healthpath on the MCP server that bypasses the sidecar (bind it to a separate internal port). AliveMCP probes the MCP protocol endpoint (which goes through the sidecar); your internal synthetic probe hits/healthdirectly. If AliveMCP fires but the internal probe is green, the problem is in the auth layer. - Watch SAML certificate expiry. Most IdPs issue SAML signing certificates with 1-5 year lifetimes. Add the expiry date to your monitoring calendar with a 90-day warning. A certificate expiry kills all SAML-protected MCP servers simultaneously — one of the most disruptive enterprise MCP failure modes.
- Alert on 4xx spikes, not just 5xx. A 401/403 rate spike on an MCP endpoint means an auth regression, not server degradation. Route 4xx alerts to your IAM team, 5xx alerts to your SRE on-call.
AliveMCP's HTTP error-code breakdown (visible in the Team and Enterprise dashboards) makes the 4xx/5xx distinction visible in the status timeline. A vertical band of 401s shows exactly when the SAML certificate expired or the policy changed — not just that the server "went down."
Frequently asked questions
Can MCP clients handle SAML SSO redirects automatically?
No. SAML authentication requires a browser redirect to the IdP login page, which MCP clients (CLI tools, agent frameworks) cannot follow automatically. For programmatic MCP clients, use OIDC client credentials flow instead of SAML — the client authenticates with a client ID and secret to get a JWT directly, without a browser redirect. Reserve SAML for human users accessing MCP servers via browser-based tools or dashboards. Programmatic service accounts should use OIDC client credentials or API keys scoped to a service account identity in the IdP.
How do I handle MCP server calls from CI/CD pipelines that can't do SSO?
Create a dedicated service account in your IdP for each CI/CD pipeline, use OIDC Workload Identity Federation (GitHub Actions, GitLab CI, and Google Cloud all support this), and exchange the pipeline's OIDC token for a short-lived JWT that your MCP sidecar accepts. This gives CI/CD pipelines a real identity in your audit log rather than a shared API key that's impossible to attribute to a specific pipeline run.
What if my MCP server uses stdio transport instead of HTTP?
SAML/OIDC sidecars only work with HTTP transports. For stdio MCP servers, implement authentication inside the server process. Read the user identity from an environment variable set by the launcher (e.g., MCP_USER_EMAIL), where the launcher is an authenticated script that fetches the current user's identity from the IdP before spawning the MCP process. This is less elegant than the sidecar pattern but achieves the same audit trail.
How long should SAML session cookies last for MCP server access?
Match your org's IdP session policy, typically 8–12 hours for internal tools. MCP servers used in automated pipelines don't need session cookies at all — use short-lived JWTs (1 hour max) from OIDC client credentials. For human users running long agent sessions, implement refresh token logic or set the cookie expiry to match the expected maximum session length (a 4-hour agent run needs a 4-hour cookie at minimum).
Can I use SAML SSO with cloud-hosted MCP servers on Fly.io or Railway?
Yes. Deploy OAuth2 Proxy as a second container in the same Fly.io application and route external traffic through it. The MCP server container binds to fly-local-6pn (Fly's private network interface) rather than the public address, so only the sidecar can reach it. The same pattern works on Railway with an internal service reference. The key requirement is that the MCP server port is not directly internet-accessible — all external traffic must route through the auth sidecar.
Further reading
- MCP server RBAC — role-based access control for tool-level permissions
- MCP server OAuth — OAuth 2.1 authorization for MCP endpoints
- MCP server JWT validation — verifying tokens in MCP tool handlers
- MCP server audit logging — structured event logs for compliance
- MCP server SOC 2 compliance — trust criteria and evidence requirements
- MCP server multi-tenant architecture — isolation patterns for shared infrastructure
- AliveMCP — continuous protocol monitoring for MCP servers