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:

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 patternOnboardingOffboardingAudit trailScope control
Per-server API keysN grants for N serversN revocations for N serversKey identity onlyPer-server config
Shared OAuth client1 grant, same scope everywhere1 revocationClient ID onlyCoarse (same scope everywhere)
SAML SSO via IdP1 IdP group assignment1 IdP group removalUser DN + group contextFine-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:

  1. Monitor the health endpoint separately from the auth-protected endpoint. Expose a /health path 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 /health directly. If AliveMCP fires but the internal probe is green, the problem is in the auth layer.
  2. 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.
  3. 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

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free