A Terraform MCP server patch released on August 5, 2026 is the reason this tutorial exists. The flaw behind it, CVE-2026-16496, scored a perfect 10.0 on the CVSS scale. The bug lived in how the server handled stateful Streamable HTTP sessions: anyone holding another user’s session identifier could fire tool calls that ran with that user’s Terraform credentials. No malware, no exploit chain, just a session ID sitting in the wrong hands. That is the risk MCP authentication is built to close.
Running MCP servers inside a container or a microVM stops a compromised process from touching the host. It does nothing to stop a valid-looking request from the wrong caller, a token minted for one server getting replayed against another, or a tool call that should have needed a human’s sign-off sailing through unchecked. That is a separate problem, and as of October 22, 2026 it has a separate, fast-moving answer: treat every MCP server as an OAuth 2.1 resource server, bind every token to exactly one audience, and deny every tool call until a scope says otherwise.
This guide walks through building that setup in 13 steps, applying the same cybersecurity discipline teams already use for network and identity controls to the current Model Context Protocol specification dated 2026-07-28, the IETF’s OAuth 2.1 draft, and the authorization patterns now shipping in Red Hat Ansible Automation Platform 2.7, NetScaler MCP Gateway, and the official MCP Ruby SDK. By the end you will have a working resource-server reference implementation, a troubleshooting checklist, and a clear answer to the question container isolation alone cannot answer: how do you know the caller on the other end of an MCP tool call is who it says it is?
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why MCP Authentication Became a 2026 Priority
Model Context Protocol adoption outran its security tooling for most of 2025. The original MCP authorization specification, dated June 18, 2025, described authorization as technically optional even while it defined an OAuth-based flow for remote servers. That gap closed with the 2026-07-28 revision, which MCP’s lead maintainers, David Soria Parra and Den Delimarsky, called the largest protocol revision since launch. The new baseline makes issuer validation mandatory, drops the old stateful handshake and its Mcp-Session-Id header, and requires every request to be self-describing rather than tied to server-held session state.
The Terraform MCP Server CVE landed right in the gap the old model left open. Stateful session handling meant a leaked or guessed session identifier carried real authority, and nothing in the transport layer forced a server to check whether a token was actually meant for it. Under the current spec, remote MCP servers act as OAuth 2.1 resource servers rather than token issuers, using RFC 9728 Protected Resource Metadata, RFC 8707 resource indicators, scope challenges, and audience binding to make that kind of token reuse fail by default.
The IBM figures show why teams are moving now rather than later. IBM’s 2025 Cost of a Data Breach Report found that 13% of organizations reported breaches involving their AI models or applications, and 97% of those organizations lacked proper AI access controls. Agents running with access nobody is auditing is exactly the combination OAuth 2.1 zero-trust design is meant to fix.
It also matters that OAuth 2.1 itself is not finished. As of October 22, 2026 it remains IETF Internet-Draft draft-ietf-oauth-v2-1-16, reportedly dated early September 2026. It is mature enough that MCP, Red Hat, NetScaler, and Microsoft’s Azure MCP guidance all build on it, but it is not an RFC yet. Build to the draft’s current rules, keep an eye on revisions, and do not market anything here as OAuth 2.1 certified, because no such certification exists.
Prerequisites: Tools, Accounts, and Versions You Need
You do not need every tool on this list. Pick the authorization server and gateway path that fits your stack, then skip the sections that do not apply. Here is what to have ready before Step 1.
- An MCP server fleet on the current protocol baseline. The official reference packages moved to
modelcontextprotocol/serversv2.0.0 on October 19, 2026 (updated October 21), the successor to the legacy-era server line. - An SDK that already speaks OAuth 2.1. The MCP Ruby SDK treats HTTP-based MCP servers as OAuth 2.1 resource servers, and its current documentation is for version 1.8.1. The TypeScript SDK v2 package is
@modelcontextprotocol/serverversion2.0.0. - An OAuth 2.1 authorization server. This can be a standalone identity provider, or a platform-native option such as Red Hat Ansible Automation Platform 2.7’s built-in OAuth application support or NetScaler MCP Gateway running on NetScaler 14.1-72.x or later.
- A gateway or proxy layer if you are running more than a handful of MCP servers. NetScaler MCP Gateway and Docker’s MCP Toolkit both sit in front of backend servers and centralize authentication, though Docker’s catalog and toolkit remain labeled Beta as of October 2026.
- A sandbox or isolation layer underneath all of this. Authentication controls who can call a tool; it does not replace process isolation. If you have not set that layer up yet, pair this guide with a container or microVM isolation pass first.
- Basic familiarity with JSON-RPC 2.0, since MCP’s data layer runs on it for every transport, including the notification pattern used when no response is expected.
- A test identity provider or sandbox tenant so you are not wiring OAuth flows against production credentials while you work through these steps.
If you have not built or deployed an MCP server before, work through a basic MCP server setup guide first; this tutorial assumes servers are already running and focuses entirely on locking down who and what can call them.
Step 1: Audit Every MCP Server for Authentication Gaps
Start by listing every MCP server your agents can reach, remote and local, first-party and third-party. For each one, record three things: what authentication it currently uses, whether it validates the audience of any token it accepts, and what happens if a request arrives with no credentials at all. Teams are routinely surprised by how many internal MCP servers answer any well-formed JSON-RPC request with no identity check whatsoever, because that was normal under the pre-2026-07-28 model.
Static API keys deserve special attention in this audit. Red Hat’s current Ansible Automation Platform documentation treats manually created personal access tokens as a supported compatibility method, not a replacement for OAuth 2.1, and that distinction matters: a flat API key has no audience, no expiry enforcement beyond what you build yourself, and no way to prove it was issued for this specific server rather than copied from another one. If a server’s only credential model is “paste this key into a config file,” flag it as high priority for migration.
Pay close attention to any server still running a stateful session handshake rather than the self-describing request model from the current MCP specification. That pattern is exactly what the Terraform MCP Server CVE exploited, and it is worth treating as a blocking finding rather than a backlog item. Finish the audit with a simple severity list: servers with no auth, servers with static keys only, servers with OAuth 2.0 but not 2.1 semantics, and servers already compliant. The rest of this tutorial moves that list from the bottom up.
Step 2: Choose Your OAuth 2.1 Authorization Server
You have more platform-native options in late 2026 than you did a year ago. The table below compares four current implementations of MCP-aware OAuth 2.1 support, based on each vendor’s own documentation as of October 2026.
| Platform | Current version | OAuth 2.1 mechanism | Egress or rate control | Status |
|---|---|---|---|---|
| Red Hat Ansible Automation Platform | 2.7 (baseline 2.7.20260603) | OAuth application + PKCE + OIDC discovery via OAUTH2_ENABLED | Not gateway-native; pair with a proxy | Generally available, PATs still supported |
| NetScaler MCP Gateway | NetScaler 14.1-72.x+ | RFC 9470 401 WWW-Authenticate discovery + RFC 9728 metadata | Tool-based rate limiting, server access control | Documented, no listed price tier |
| Docker MCP Toolkit / Catalog | 300+ signed images under mcp/ | Automatic browser-based OAuth for supported remote servers | 1 CPU / 2GB RAM per container, no host filesystem by default | Beta; Docker says scanning is “not exhaustive” |
| MCP Ruby SDK (self-hosted) | v1.8.1 | Resource-server token verification on Streamable HTTP | You build it; SDK handles token validation only | Stable release |
If you already run Ansible for infrastructure automation, the AAP 2.7 path is the least new surface area to learn: you enable OAUTH2_ENABLED, create an OAuth application, and the MCP server starts honoring PKCE and OIDC discovery alongside existing personal access tokens. If you are standardizing on a load balancer or application delivery controller for several MCP servers at once, NetScaler MCP Gateway centralizes OAuth 2.1 enforcement, server access control, and tool-based rate limiting in one place. Teams using a dedicated zero-trust gateway like Cloudflare One, Zscaler, or Netskope for other traffic may prefer to extend that same gateway layer to MCP rather than stand up a separate product. For identity providers that back any of these choices, options built around passwordless and device-trust signals, such as the approaches compared in identity platforms like Beyond Identity, HYPR, and Transmit Security, can double as the OAuth 2.1 issuer sitting behind your MCP resource servers.
Step 3: Register Each MCP Server as a Protected Resource
RFC 9728 Protected Resource Metadata is the mechanism that lets an MCP client discover how to authenticate against a given server without hardcoding assumptions. NetScaler’s current documentation describes this as part of the standard MCP authorization interoperability pattern: the server publishes a metadata document at a well-known path, and a client that receives an unauthorized response uses that document to find the correct authorization server and required scopes.
Here is a minimal Protected Resource Metadata document for a hypothetical internal MCP server:
GET /.well-known/oauth-protected-resource HTTP/1.1
Host: mcp.internal.example.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"resource": "https://mcp.internal.example.com",
"authorization_servers": [
"https://auth.internal.example.com"
],
"scopes_supported": [
"mcp:tools:read",
"mcp:tools:deploy",
"mcp:tools:billing"
],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://mcp.internal.example.com/docs"
}
The resource field is the piece most teams get wrong on the first pass: it has to be the exact identifier the server expects to see bound into any token presented to it, matching RFC 8707 resource indicators one for one. If a client requests a token for https://mcp.internal.example.com/ with a trailing slash and the server expects no trailing slash, validation fails, and it should fail loudly rather than silently falling back to an unscoped token.
When a request arrives with no token or an invalid one, the server’s job is to respond in a way the client can act on. The server returns a 401 with a WWW-Authenticate: Bearer challenge, and RFC 9728 Protected Resource Metadata points the client to the authorization server:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.internal.example.com/.well-known/oauth-protected-resource"
Note the bare WWW-Authenticate: Bearer challenge with no error code, which RFC 6750 Section 3.1 specifies for the initial unauthenticated case. Save the richer error="invalid_token" or error="insufficient_scope" variants for requests that already carry a token that failed validation; conflating the two makes debugging client-side auth failures much harder later.
Step 4: Turn On PKCE and Lock Down Redirect URIs
Proof Key for Code Exchange is not optional under OAuth 2.1 the way it was treated under OAuth 2.0. Current guidance from multiple vendors, including Microsoft’s Azure MCP security documentation, converges on the same baseline: authorization-code flow with PKCE using the S256 challenge method, exact redirect-URI matching with no wildcard subdomains, and single-use, short-lived state values for every authorization request.
A PKCE exchange in practice looks like this on the client side, generating a code verifier and its derived challenge before the user is ever redirected to the authorization server:
import crypto from "node:crypto";
function base64url(input) {
return input.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(
crypto.createHash("sha256").update(codeVerifier).digest()
);
const authUrl = new URL("https://auth.internal.example.com/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", process.env.MCP_CLIENT_ID);
authUrl.searchParams.set("redirect_uri", "https://agent.internal.example.com/callback");
authUrl.searchParams.set("resource", "https://mcp.internal.example.com");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", base64url(crypto.randomBytes(16)));
Notice the resource parameter set alongside the PKCE parameters. That is the RFC 8707 binding happening at request time, not after the fact, and it is what lets the authorization server mint a token scoped to exactly one MCP resource rather than a general-purpose bearer token that could be replayed anywhere. Store codeVerifier server-side or in a secure, httpOnly cookie, never in a URL or in model context the agent can read, and discard it immediately after the token exchange completes.
Redirect URI matching should be byte-for-byte exact, including the scheme, port, and path. If you are building an MCP client extension for an editor, the same discipline applies whether you are working inside a Cursor MCP server configuration or a VS Code agent setup: hardcode the registered callback rather than deriving it dynamically, because a dynamic redirect is the single most common PKCE implementation bug.
Step 5: Bind Every Token to One Resource
Audience binding is the control that directly prevents the confused-deputy and token-replay pattern behind the Terraform MCP Server CVE. The idea is simple: a token issued for resource A must be rejected by resource B, even if B trusts the same authorization server and even if the token is otherwise valid and unexpired.
RFC 8707 resource indicators are how a client requests this binding at token-issuance time, and the authorization server’s job is to put the requested resource into the token’s aud claim rather than issuing a blanket token. Every MCP server must then verify that claim on every request, not just at session establishment, because the current MCP specification’s shift to self-describing requests means there is no persistent session to lean on. Current MCP security guidance is explicit that implementations should validate that each access token was issued for the specific MCP server, reject any attempt at token passthrough between servers, and keep tokens out of URLs entirely.
Refresh-token rotation belongs in this step too. For public clients, which is what most agent runtimes are, a refresh token should be single-use: each refresh exchange issues a new refresh token and invalidates the old one, so a stolen refresh token has a short window before the legitimate client’s next refresh attempt reveals the theft and the authorization server can revoke the whole token family.
Step 6: Validate Issuer, Audience, and Expiry on Every Call
Token validation is where most MCP authentication implementations either succeed quietly or fail loudly months later. The checklist below reflects the current guidance from Azure MCP Server security documentation and the MCP specification’s own authorization requirements.
| Token claim | Required check | Failure mode if skipped |
|---|---|---|
iss | Matches a configured, trusted authorization server exactly | Server accepts tokens from a rogue or misconfigured issuer |
aud | Contains this server’s exact resource identifier | Confused-deputy replay across MCP servers |
exp | Current time is before expiry, with minimal clock skew allowance | Stale, stolen, or long-expired tokens keep working |
scope | Contains the specific scope the requested tool requires | Over-broad tool access regardless of what the caller asked for |
| signature | Verified against the issuer’s current published key set, not a cached one | Forged tokens pass if keys were rotated and not refreshed |
A minimal Python resource-server check applying that table looks like this, using a JWT library to decode and verify before anything touches the actual tool logic:
import time
import jwt
from jwt import PyJWKClient
RESOURCE_ID = "https://mcp.internal.example.com"
TRUSTED_ISSUER = "https://auth.internal.example.com"
jwks_client = PyJWKClient(f"{TRUSTED_ISSUER}/.well-known/jwks.json")
def validate_mcp_token(bearer_token: str, required_scope: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(bearer_token)
claims = jwt.decode(
bearer_token,
signing_key.key,
algorithms=["RS256"],
audience=RESOURCE_ID,
issuer=TRUSTED_ISSUER,
options={"require": ["exp", "aud", "iss", "scope"]},
)
if time.time() >= claims["exp"]:
raise PermissionError("token expired")
granted_scopes = claims.get("scope", "").split()
if required_scope not in granted_scopes:
raise PermissionError(f"missing required scope: {required_scope}")
return claims
Run this check as middleware ahead of every tool handler, not once at connection time. Because the current MCP spec removed the stateful handshake in favor of self-describing requests, there is no connection-level session to trust after the first check passes; each JSON-RPC call needs its own validation pass, which is a meaningful change in mental model for anyone who built MCP integrations against the older, session-based spec.
Step 7: Set Deny-by-Default, Least-Privilege Scopes Per Tool
Current MCP authorization guidance recommends authorizing every tool invocation individually, using deny-by-default policies, and evaluating the requesting principal, tenant, and requested action as separate questions rather than one combined check. In practice that means every tool your MCP server exposes needs its own scope string, and the default answer to “can this caller run this tool” is no until a scope explicitly says otherwise.
Resist the temptation to collapse scopes into a single mcp:all grant for convenience. A deploy tool and a billing-read tool should never share a scope, because the blast radius of a leaked or over-granted token is defined entirely by what that token’s scopes allow. Red Hat’s AAP 2.7 MCP server illustrates the pattern well with its /mcp/discover endpoint, which exposes tools progressively based on what the caller’s token actually authorizes rather than advertising the server’s full tool catalog to everyone who connects. That progressive discovery approach also reduces token consumption on the client side, since an agent only sees the tool definitions it can actually invoke.
For any tool that can mutate state, charge money, or access sensitive data, add an explicit human-consent step on top of scope checking. Current guidance treats tool descriptions and tool outputs as untrusted input precisely because an agent can be manipulated into requesting a sensitive action through prompt injection; a scope check confirms the caller is allowed to ask, but a consent step confirms a human actually wants this specific action to happen right now.
Step 8: Put a Gateway in Front of Your MCP Servers
Once you have more than two or three MCP servers, implementing OAuth 2.1 validation independently in each one creates drift. A gateway centralizes authentication, authorization, routing, and monitoring in one enforcement point, which is the architecture NetScaler MCP Gateway and Docker’s MCP Toolkit both use.
NetScaler’s current documentation describes the gateway sitting between MCP clients and backend MCP servers, handling OAuth discovery, server access control, backend authentication and token insertion, protocol-aware monitoring, and tool-based rate limiting all at one layer. Docker’s MCP Toolkit takes a narrower but still useful approach for development and smaller deployments: it manages OAuth authentication automatically for supported remote servers, including the browser-based authorization flow, runs each server in its own container capped at 1 CPU and 2GB of memory, denies host-filesystem access by default, and can block tool requests or responses that contain detected secrets. Docker is upfront that its catalog scanning is “not exhaustive,” so treat the Toolkit as a strong development-time guardrail rather than a substitute for production-grade token validation.
If your organization already runs a zero-trust network access layer for other internal traffic, extending it to cover MCP servers is often less work than deploying an MCP-specific gateway from scratch. The same identity-aware access and least-privilege routing principles behind a standard zero-trust network access deployment apply directly to agent-to-server traffic; the difference is that MCP authentication adds a tool-call-level scope check on top of the network-level identity check a ZTNA layer already performs. Teams running a mesh VPN like Tailscale for zero-trust connectivity can layer MCP-aware OAuth validation on top of that mesh rather than replacing it.
Step 9: Add Network Egress Allowlisting
Authentication controls who can call your MCP servers. Egress control limits what those servers, and the agents behind them, can reach once a tool call is already authorized. Both matter, because a correctly authenticated agent with an over-broad network path can still exfiltrate data or reach a cloud metadata endpoint it has no business touching.
Current sandbox platforms treat this as a first-class, configurable control rather than an afterthought. Daytona’s 2026 sandbox comparison documents configurable outbound controls including CIDR allowlists capped at 10 entries, domain allowlists capped at 100 entries, and a block-all mode, with egress restricted by default on lower service tiers and opened up only on higher tiers. A representative policy for an MCP tool execution sandbox looks like this:
egress_policy:
mode: allowlist
default_action: deny
cidr_allowlist:
- 10.0.4.0/24 # internal MCP server subnet
- 10.0.8.0/24 # internal OAuth authorization server
domain_allowlist:
- api.github.com
- registry.npmjs.org
block_cloud_metadata: true
metadata_block_targets:
- 169.254.169.254
audit_log: true
The block_cloud_metadata line is not optional in any serious deployment. Private subnets with no NAT route, combined with an explicit block of 169.254.169.254 and IMDSv2 with a low hop limit, closes off one of the most common escalation paths for a compromised agent process: reaching the instance metadata service and pulling cloud credentials that have nothing to do with the MCP server’s own scope. If you are running MCP tool execution inside an archived or unmaintained sandbox project, this is also a reason to migrate to one that still receives security updates.
Step 10: Log and Audit Every Tool Call
A zero-trust MCP setup without logging is a set of rules nobody can verify. Current security guidance for MCP calls for audit logging of authentication events, authorization decisions, policy evaluations, and every individual tool call, not just failed requests. Log the caller’s subject identifier, the resource and scope requested, the decision made, and a timestamp, at minimum, for every request that reaches a tool handler.
Treat this log as a security artifact, not a debugging convenience. Feed it into whatever SIEM or log pipeline already ingests your other identity and access events, so an anomaly in MCP tool-call patterns, such as a single token suddenly requesting a scope it has never used before, shows up alongside the rest of your access telemetry instead of sitting isolated in an agent-specific log file nobody reviews. NetScaler MCP Gateway’s monitoring capability and Docker MCP Toolkit’s secret-blocking behavior both generate events worth forwarding into that same pipeline rather than treating as standalone dashboards.
Step 11: Rotate Refresh Tokens and Enforce Session Expiry
Short-lived access tokens are only as good as the refresh and expiry discipline behind them. Set access-token lifetimes short, generally in the range of minutes to a couple of hours depending on how sensitive the tools behind a given server are, and rely on refresh-token rotation rather than long-lived access tokens for anything an agent runtime holds in memory for an extended session.
For streaming connections, cap how long an authenticated SSE stream can stay open before the client has to present its token again; a 30-minute cap is a reasonable default unless your deployment has a specific reason to extend it, and it should never be unlimited. When an agent sandbox tears down, revoke its tokens and sessions immediately rather than letting them expire naturally; an ephemeral sandbox that outlives its own credentials by even a few minutes is an unnecessary window of exposure for very little operational benefit.
Step 12: Stress-Test Against the CVE-2026-16496 Attack Pattern
Every control above is worth validating against the actual attack that motivated it. Build a test that attempts to replay a valid session identifier or token issued for one MCP server against a different one, and confirm the second server rejects it on the audience check from Step 6, not on some incidental network-level block. If that test passes without the audience check specifically catching it, you likely have a gap elsewhere, such as a gateway that strips or ignores the aud claim before forwarding requests to backend servers.
Add a second test for token passthrough: confirm that a token your gateway receives for one resource is never silently forwarded to a different backend MCP server as if it were valid there. This is the specific failure mode current MCP security guidance calls out by name, and it is easy to introduce accidentally in a gateway configuration that treats all backend servers as interchangeable rather than as distinct resources with distinct audiences. Run both tests in CI against a staging environment before every deployment that touches authentication configuration, not just once during initial rollout.
Step 13: Set Up Continuous Monitoring and Quarterly Review
OAuth 2.1 is still an active IETF draft, MCP’s own authorization model changed substantially as recently as July 28, 2026, and the SDKs implementing all of this are shipping new releases roughly every two to three weeks based on recent TypeScript, Ruby, PHP, and Java SDK release dates. Treat your MCP authentication configuration as something that needs a scheduled review, not a one-time setup.
Set a quarterly checklist: confirm every MCP server is still on a current, patched release; confirm scopes still match the actual tool inventory, since tools get added faster than scopes get cleaned up; confirm no server has quietly fallen back to accepting unscoped or audience-less tokens after a configuration change; and confirm your egress allowlists still reflect what the agents behind each server genuinely need to reach. Pair this review with whatever credential rotation schedule you already use for other service accounts, since an MCP server’s OAuth client secret is exactly as sensitive as any other service credential in your environment, and the same operational discipline described for securing AI agent credentials through a dedicated MCP gateway setup applies directly here.
Common Pitfalls When Securing MCP Authentication
- Treating OAuth 2.0 as OAuth 2.1. An existing OAuth 2.0 integration is not automatically compliant with OAuth 2.1’s stricter rules on PKCE, implicit-grant removal, and exact redirect matching. Audit it explicitly rather than assuming compatibility.
- Validating tokens only at connection time. Under the current MCP spec’s self-describing request model, there is no persistent session to lean on after the first check. Validate on every call, every time.
- Collapsing all tools into one scope. A single
mcp:allgrant turns a minor token leak into full server compromise. Keep scopes granular and tool-specific. - Forgetting to block cloud metadata endpoints. Authentication at the MCP layer does nothing to stop a tool-execution sandbox from reaching
169.254.169.254if egress rules do not explicitly block it. - Letting a gateway strip the audience claim. Some reverse-proxy configurations forward requests to backend servers without preserving or re-validating the
audclaim, silently reintroducing the exact token-replay risk audience binding exists to prevent. - Assuming Docker’s catalog signing is a full security review. Docker signs images under its
mcp/namespace for provenance, but its own documentation states the automated testing and scanning behind that catalog is “not exhaustive.” Signing proves origin, not safety.
Troubleshooting Your Zero-Trust MCP Setup
These are the failure modes teams run into most often while rolling out OAuth 2.1 across an existing MCP fleet, along with the fix that usually resolves each one.
| Symptom | Likely cause | Fix |
|---|---|---|
| Every request returns 401, even with a valid-looking token | Resource identifier mismatch between client request and server’s expected audience, often a trailing-slash difference | Make the resource parameter and the server’s configured audience byte-for-byte identical |
| Token works against server A but fails against server B with the same scopes | Audience binding working as intended; the token was issued for A only | Request a new token with resource set to B’s exact identifier, or request a multi-resource grant if your authorization server supports it |
| PKCE exchange fails with an invalid_grant error | Code verifier lost between redirect and callback, often due to a stateless client or load-balanced callback handler | Persist the verifier server-side keyed to the state value, not in client-side memory that a redirect can clear |
| Agent loses access mid-session with no error until the next tool call | Access token expired and no refresh occurred because the agent runtime does not proactively refresh | Refresh proactively before expiry, not reactively after a 401, and shorten the access-token lifetime if this keeps happening |
| Gateway logs show requests succeeding that should have failed scope checks | Gateway validating the token’s signature but not re-checking scope per tool call | Move scope enforcement into the per-tool handler, not just the gateway’s initial auth pass |
| Legacy MCP clients can no longer connect after upgrading servers | Client still expects the pre-2026-07-28 stateful handshake | Confirm your server supports the documented fallback to the 2025-11-25 handshake, or upgrade the client SDK |
| Secrets keep showing up in tool call logs | No output filtering between the tool handler and the audit log | Apply the same secret-detection pattern Docker’s MCP Toolkit uses to block sensitive content in tool requests and responses before logging |
| Refresh token reuse triggers an unexpected full logout | Rotation detected reuse of an already-exchanged refresh token, correctly assuming theft | This is working as intended; investigate where the old refresh token leaked rather than disabling rotation |
Advanced Tips for Scaling Zero-Trust MCP Across Teams
Once the core 13 steps are in place, a few refinements pay off as your MCP footprint grows past a handful of servers. First, separate your scope namespace by team or domain early, even if you only have a few tools today. A naming convention like mcp:billing:read and mcp:infra:deploy scales far better than flat scope names once a second team starts adding its own tools and needs its own audit trail.
Second, if you are building a new MCP server rather than only securing existing ones, bake bearer-token authentication into the server from the first commit. A reference Python tutorial for building an MCP server with FastMCP demonstrates adding OAuth 2.1 authentication as an explicit build step rather than retrofitting it later, which is the far less painful order of operations. The same applies if your team is building a new MCP server from scratch: wire in bearer-token validation before you wire in your first tool handler, not after.
Third, track SDK release cadence deliberately. MCP maintainers have described an approximately eight-month release cycle for the core specification, with the next major revision targeted for March 2027, but the SDKs underneath it move much faster, with the Ruby SDK alone shipping two releases eight days apart in October 2026. Pin SDK versions in your build, but review changelogs on a short cycle, because authorization-relevant fixes land in point releases, not just major ones.
Finally, pair this authentication layer with the isolation layer covered in a dedicated AI agent sandboxing guide for isolating MCP servers, since the two controls answer different questions. Authentication decides who gets to call a tool; sandboxing decides what happens if a tool call itself goes wrong. Running one without the other leaves a real gap no amount of OAuth configuration can close on its own.
The Complete Working Project
Putting the previous 13 steps together, here is a compact but complete MCP resource-server reference implementation in Python. It validates audience-bound tokens, enforces deny-by-default scopes per tool, and logs every decision, which covers Steps 6, 7, and 10 in one runnable file. Extend it with your chosen gateway and egress policy from Steps 8 and 9 for a full deployment.
import time
import logging
import jwt
from jwt import PyJWKClient
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
logging.basicConfig(level=logging.INFO)
audit_log = logging.getLogger("mcp.audit")
RESOURCE_ID = "https://mcp.internal.example.com"
TRUSTED_ISSUER = "https://auth.internal.example.com"
jwks_client = PyJWKClient(f"{TRUSTED_ISSUER}/.well-known/jwks.json")
TOOL_SCOPES = {
"list_deployments": "mcp:infra:read",
"trigger_deploy": "mcp:infra:deploy",
"read_billing_summary": "mcp:billing:read",
}
def validate_token(bearer_token):
signing_key = jwks_client.get_signing_key_from_jwt(bearer_token)
claims = jwt.decode(
bearer_token,
signing_key.key,
algorithms=["RS256"],
audience=RESOURCE_ID,
issuer=TRUSTED_ISSUER,
options={"require": ["exp", "aud", "iss", "scope"]},
)
if time.time() >= claims["exp"]:
raise PermissionError("token expired")
return claims
def authorize_tool_call(claims, tool_name):
required_scope = TOOL_SCOPES.get(tool_name)
if required_scope is None:
raise PermissionError(f"unknown tool: {tool_name}")
granted = claims.get("scope", "").split()
if required_scope not in granted:
raise PermissionError(f"missing scope {required_scope} for {tool_name}")
return True
class MCPHandler(BaseHTTPRequestHandler):
def do_POST(self):
auth_header = self.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
self._deny(401, 'Bearer resource_metadata="%s/.well-known/oauth-protected-resource"' % RESOURCE_ID)
return
token = auth_header.split(" ", 1)[1]
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
tool_name = body.get("params", {}).get("name", "unknown")
subject = "unknown"
try:
claims = validate_token(token)
subject = claims.get("sub", "unknown")
authorize_tool_call(claims, tool_name)
except PermissionError as exc:
audit_log.info("DENY subject=%s tool=%s reason=%s", subject, tool_name, exc)
self._deny(403, 'Bearer error="insufficient_scope"')
return
except Exception as exc:
audit_log.info("DENY subject=unknown tool=%s reason=invalid_token(%s)", tool_name, exc)
self._deny(401, 'Bearer error="invalid_token"')
return
audit_log.info("ALLOW subject=%s tool=%s", subject, tool_name)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"result": f"executed {tool_name}"}).encode())
def _deny(self, code, www_authenticate):
self.send_response(code)
self.send_header("WWW-Authenticate", www_authenticate)
self.end_headers()
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 8443), MCPHandler)
audit_log.info("MCP resource server listening with OAuth 2.1 enforcement on :8443")
server.serve_forever()
Run it behind TLS termination in production, point your gateway or load balancer at it, and confirm the troubleshooting table above against your own token issuer before connecting real agent traffic. The structure here, validate first, authorize per tool second, log every outcome third, is the pattern every platform covered in this guide converges on, whether it is NetScaler MCP Gateway, Red Hat AAP’s MCP server, or a hand-rolled implementation like this one.
Frequently Asked Questions
Is OAuth 2.1 a finished standard I can rely on?
Not yet in the formal sense. As of October 22, 2026, OAuth 2.1 remains IETF Internet-Draft draft-ietf-oauth-v2-1-16, published September 3, 2026. It is stable enough that MCP, Red Hat, NetScaler, and Microsoft all build current guidance around it, but it has not been published as an RFC.
Do I still need container or microVM isolation if I set up OAuth 2.1 authentication?
Yes. Authentication controls who can call a tool and with what scope. It does not contain what happens if a tool execution environment itself is compromised. Pair this guide with a sandbox or isolation layer rather than treating either as a substitute for the other.
Can I keep using static API keys alongside OAuth 2.1?
Red Hat’s current AAP 2.7 documentation supports exactly this, treating personal access tokens as a supported additional method rather than a deprecated one. Just do not present API-key access as equivalent to OAuth 2.1’s audience binding and scope enforcement; migrate sensitive tools to OAuth 2.1 first and keep static keys, if you keep them at all, for low-risk, read-only tools.
Is Docker’s MCP Catalog safe to use for production authentication?
Docker’s catalog includes over 300 verified, digitally signed server images, which is a meaningful provenance guarantee. Docker’s own documentation states its automated scanning is “not exhaustive” and the catalog and toolkit remain labeled Beta as of October 2026, so treat it as a strong starting point rather than a complete authentication and security review on its own.
How short should access-token lifetimes be for MCP tool calls?
Current guidance favors short-lived tokens generally measured in minutes to a couple of hours, paired with refresh-token rotation rather than long-lived access tokens. For authenticated SSE streams specifically, a 30-minute cap before requiring the token again is a reasonable default.
What is the single most common mistake teams make when adding OAuth 2.1 to existing MCP servers?
Validating the token once at connection time and then trusting every subsequent request on that same connection. The current MCP specification’s move to self-describing, stateless requests means every single call needs its own audience, issuer, expiry, and scope check, not just the first one.





