In this blog

Share article:

MCP OAuth 2.1 Security: Authentication Best Practices for AI Tool Integrations

Varun Kumar
Varun Kumar
MCP OAuth 2.1

OAuth 2.0 was designed for a world where the client was a web application and the user was a human clicking through a browser. AI agents are neither. They are long-running, autonomous processes that authenticate programmatically, operate without human supervision, and can take actions across dozens of connected services in a single session.

The original OAuth 2.0 security model, with its implicit grant, optional PKCE, and perpetual refresh tokens, was not built for this threat profile. OAuth 2.1 is the specification that fixes the gaps. Understanding exactly what changed and why is important for every MCP deployment running authentication today.

The MCP specification now recommends OAuth 2.1 as the authentication standard for production deployments. This isn’t an arbitrary versioning preference. It reflects a concrete set of attack classes that OAuth 2.0 without its security BCP addenda was vulnerable to and that are now closed by default in OAuth 2.1.

An MCP server running OAuth 2.0 with optional PKCE, implicit grant support, and long-lived rotating-off refresh tokens is a server with exploitable authentication gaps. An MCP server running OAuth 2.1 correctly has those gaps closed by the protocol itself, not by configuration discipline.

This guide covers what changed between OAuth 2.0 and 2.1 and why each change is relevant specifically for MCP, the complete authorization code flow with PKCE implemented for AI agent clients, how to design OAuth scopes that enforce tool-level permissions at the token layer, refresh token rotation and token lifecycle management, dynamic client registration for agent deployments, resource indicators that prevent cross-server token replay, and the seven most common OAuth 2.1 implementation mistakes that teams make despite understanding the specification correctly in theory.

What Changed in OAuth 2.1 and Why It Matters for MCP

OAuth 2.1 is a consolidation of OAuth 2.0 plus its security best practice RFCs: RFC 7636 (PKCE), RFC 6749 security considerations, RFC 8252 (native app flows), and the OAuth Security BCP, into a single specification that makes those security practices mandatory rather than optional. For MCP, the practical effect is that a correct OAuth 2.1 implementation is secure by default in ways that a correct OAuth 2.0 implementation is not.

PropertyOAuth 2.0OAuth 2.1MCP Security Impact
PKCEOptionalMandatory (all flows)Prevents auth code interception in agent runtimes that cannot store client secrets
Implicit GrantPermittedRemoved entirelyEliminates access tokens exposed in URL fragments -Highest-risk grant for agent environments
Resource Owner Password GrantPermittedRemoved entirelyEliminates credential exposure via agent-handled username/password flows
Refresh Token RotationOptionalRequired (single-use)Converts stolen refresh tokens into detectable events – critical for long-running agents
Redirect URI MatchingPartial matching allowedExact matching onlyCloses redirect URI manipulation attacks against agent callback endpoints
Bearer Token TransportQuery params permittedAuthorization header onlyPrevents tokens from appearing in server logs, browser history, and referrer headers
Resource IndicatorsOptional extensionRecommended (RFC 8707)Binds issued tokens to specific MCP server – Prevents cross-server token replay

The Implicit Grant Removal – MCP Context

The implicit grant removal deserves particular attention in the MCP context. AI agent frameworks that implemented OAuth 2.0 for MCP authentication in 2024–2025 frequently used the implicit grant for its implementation simplicity. Access token returned directly in the authorization response, no code exchange needed.

The problem is that the access token then appears in the redirect URI fragment, is stored in browser history if a browser-based flow is involved, and can be leaked through referrer headers. For MCP agents operating in enterprise environments with extensive logging infrastructure, the implicit grant created systematic access token exposure. OAuth 2.1’s removal of this grant eliminates the attack surface entirely.

The MCP OAuth 2.1 Authorization Flow

The correct flow for MCP agent authentication is the authorization code flow with PKCE. The agent is a public client. It cannot securely store a client secret. This makes PKCE the only mechanism that prevents an attacker from exchanging an intercepted authorization code for an access token.

Authorization Code Flow with PKCE

AI AGENT                    AUTHORIZATION SERVER         MCP SERVER
  (Public Client)

    │                             │                           │
    ├─ Generate code_verifier ─→  │                           │
    │  (43-128 chars random)       │                           │
    │                              │                           │
    ├─ code_challenge = BASE64URL(SHA256(code_verifier)) ─→   │
    │                              │                           │
    │                              │                           │
    ├──── GET /authorize ─────────→│                           │
    │     ?response_type=code      │                           │
    │     &client_id=...           │                           │
    │     &code_challenge=HASH     │                           │
    │     &code_challenge_method=S256                          │
    │     &scope=mcp:tool:read_file:read                       │
    │     &resource=https://mcp.example.com (RFC 8707)         │
    │                              │                           │
    │                              │                           │
    │←──── code (short-lived) ─────│                           │
    │      (~60s)                  │                           │
    │                              │                           │
    │                              │                           │
    ├──── POST /token ────────────→│                           │
    │     grant_type=authorization_code                        │
    │     &code=...                │                           │
    │     &code_verifier=ORIGINAL_VERIFIER                     │
    │     (PKCE proof)             │                           │
    │                              │                           │
    │                              │                           │
    │←─── access_token + ──────────│                           │
    │     refresh_token            │                           │
    │     (JWT, 5-15min)           │                           │
    │     Bound to: scope + resource                           │
    │                              │                           │
    │                              │                           │
    ├─────────── Tool Request ───────────────────────────────→│
    │            Authorization: Bearer {access_token}          │
    │                              │                           │
    │                              │        Validates:         │
    │                              │        - sig + exp        │
    │                              │        - aud + resource   │
    │                              │        - scope            │
    │                              │                           │
    │←─────── Tool Response ──────────────────────────────────│

Implementing PKCE for MCP Agent Clients

PKCE is mandatory in OAuth 2.1, but implementation details matter. The two most common failures are using plain transformation instead of S256, which provides no security; and generating code verifiers with insufficient entropy, making them predictable. The implementation below uses the correct S256 method with cryptographically secure random generation.

JavaScript: PKCE Implementation for MCP Agent

import { randomBytes, createHash } from 'crypto';

export function generatePKCE() {
  // 96 bytes → 128 base64url chars — well within 43-128 range
  const verifier = randomBytes(96)
    .toString('base64url')
    .replace(/[^a-zA-Z0-9\-._~]/g, '');  // RFC 7636 unreserved chars

  // S256: BASE64URL(SHA256(ASCII(code_verifier)))
  const challenge = createHash('sha256')
    .update(verifier, 'ascii')
    .digest('base64url');

  return { verifier, challenge };  // Store verifier in memory only
}

// Build the authorization URL with all OAuth 2.1 required params
export function buildAuthURL(config, pkce, state) {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: config.clientId,
    redirect_uri: config.redirectUri,    // Must match EXACTLY
    scope: config.scopes.join(' '),
    state: state,                        // CSRF protection
    code_challenge: pkce.challenge,
    code_challenge_method: 'S256',       // Never 'plain'
    resource: config.mcpServerUri        // RFC 8707 resource indicator
  });

  return `${config.authorizationEndpoint}?${params.toString()}`;
}

// Exchange code for tokens — include verifier, NOT secret (public client)
export async function exchangeCode(config, code, verifier) {
  const resp = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      code_verifier: verifier,           // PKCE proof — the original verifier
      client_id: config.clientId,
      redirect_uri: config.redirectUri,
      resource: config.mcpServerUri      // RFC 8707
    }).toString()
  });

  if (!resp.ok) {
    throw new Error(`Token exchange failed: ${resp.status}`);
  }

  return resp.json();
}

PKCE Storage Mistake

The code verifier must be stored in memory only. Not in localStorage, sessionStorage, a cookie, or any persistent storage. An attacker who can read persistent storage can recover the verifier and use it to exchange an intercepted authorization code. The verifier should be held in a module-scoped variable for the duration of the authorization flow and explicitly cleared after the token exchange completes, regardless of outcome.

Designing OAuth Scopes for Tool-Level Permissions

OAuth scopes in MCP deployments are not just a categorization mechanism. They are an enforcement layer. When designed correctly, the scopes encoded in an access token determine which tools the bearer is permitted to invoke, creating a permission boundary at the protocol layer that is independent of session-level allowlist controls. If session allowlists are compromised through injection, token-level scope enforcement provides a second gate.

The scope naming convention that works best for MCP follows a three-part structure: namespace:resource:operation. For MCP tool permissions specifically: mcp:tool:{tool_name}:{operation_class}. This pattern is both human-readable and machine-parseable, making authorization logic straightforward to implement and audit.

Scope StringPermission GrantedRisk Level
mcp:tool:read_file:readInvoke read_file tool with read operations onlyLOW
mcp:tool:search_database:readInvoke search_database with SELECT queries onlyLOW
mcp:tool:web_fetch:readInvoke web_fetch for GET requests to declared domainsMEDIUM
mcp:tool:write_file:writeInvoke write_file – requires operation-level tokenHIGH
mcp:tool:send_email:writeInvoke send_email – requires operation-level tokenHIGH
mcp:tool:execute_code:execnvoke execute_code – requires operation-level tokenHIGH
mcp:tool:access_secrets:adminAccess secrets store – admin role only, always requires tokenHIGH
mcp:manifest:readRead the tool manifest — required for session initializationLOW

Python – Scope Enforcement Middleware on MCP Server

from functools import wraps
import jwt

# Tool-to-required-scope mapping — single source of truth
TOOL_SCOPE_REQUIREMENTS = {
    "read_file": "mcp:tool:read_file:read",
    "search_database": "mcp:tool:search_database:read",
    "web_fetch": "mcp:tool:web_fetch:read",
    "write_file": "mcp:tool:write_file:write",
    "send_email": "mcp:tool:send_email:write",
    "execute_code": "mcp:tool:execute_code:exec",
}

def validate_token_and_scope(tool_name):
    def decorator(handler):
        @wraps(handler)
        async def wrapper(request, *args, **kwargs):
            # Extract bearer token — Authorization header ONLY (OAuth 2.1)
            auth = request.headers.get("Authorization", "")
            
            if not auth.startswith("Bearer "):
                return unauthorized("Missing bearer token")
            
            token = auth[7:]  # Remove "Bearer " prefix
            
            try:
                claims = jwt.decode(
                    token,
                    JWT_PUBLIC_KEY,
                    algorithms=["RS256"],
                    audience=MCP_SERVER_URI,  # Must match exactly
                    options={"verify_exp": True}
                )
                
                # Validate resource indicator — token bound to this server
                token_resource = (
                    claims.get("resource", [])
                    if isinstance(claims.get("resource"), list)
                    else [claims.get("resource")]
                )
                
                if MCP_SERVER_URI not in token_resource:
                    return forbidden("Token not issued for this resource")
                
                # Enforce tool-level scope requirement
                token_scopes = claims.get("scope", "").split()
                required = TOOL_SCOPE_REQUIREMENTS.get(tool_name)
                
                if required and required not in token_scopes:
                    log_scope_violation(claims["sub"], tool_name, required, token_scopes)
                    return forbidden(f"Missing scope: {required}")
                
                return await handler(request, *args, **kwargs)
                
            except jwt.ExpiredSignatureError:
                return unauthorized("Token expired")
            except jwt.InvalidTokenError as e:
                return unauthorized(f"Invalid token: {e}")
        
        return wrapper
    return decorator

Refresh Token Rotation and the Token Lifecycle

Long-lived refresh tokens are one of the most commonly exploited weaknesses in OAuth implementations for AI agents. An agent that remains authenticated for weeks or months using the same refresh token provides an attacker who compromises that token with persistent access for its entire lifetime. With no detection mechanism and no session boundary. OAuth 2.1’s mandatory refresh token rotation closes this by making token reuse detectable.

Token Lifecycle: OAuth 2.1 Rotation Model

ACCESS TOKEN (JWT)                REFRESH TOKEN (Opaque)
────────────────────────────────  ──────────────────────────────────
Lifetime: 5–15 minutes            Lifetime: 24 hours (max)

ROTATION SEQUENCE:
1. Agent uses refresh_token_A     ──→ Auth server
2. Auth server issues new_access_token + refresh_token_B
3. Auth server invalidates refresh_token_A immediately
4. Agent stores refresh_token_B, discards refresh_token_A

THEFT DETECTION SCENARIO:
1. Attacker steals refresh_token_A and uses it first
2. Auth server issues refresh_token_B to attacker, invalidates A
3. Legitimate agent attempts to use refresh_token_A → FAILS
4. Auth server detects reuse of invalidated token
5. Revoke entire token family → both attacker and agent lose access
6. Alert fires → SOC investigates token theft

Token Configuration Parameters

  • Access token lifetime: short enough that MFA re-auth is rare but breach window is minimal
  • Refresh token absolute expiry: 24h maximum — agent must re-authenticate daily

Refresh Token Family Revocation

The refresh token family revocation pattern is the most critical operational decision here: When a refresh token reuse event is detected—meaning an already-invalidated token was presented—the correct response is to revoke the entire token family, not just the reused token. This forces re-authentication of all active sessions derived from the compromised refresh token, including any sessions the attacker may have established. It is disruptive but it is the only response that guarantees the attacker’s access is terminated.

Dynamic Client Registration for Agent Deployments

Enterprise MCP deployments often involve dozens of agent instances. Different agents for different teams, tasks, and capability profiles. Managing OAuth clients for each agent through a manual registration process creates operational overhead that teams routinely short-circuit by sharing client credentials across agent instances. Shared client credentials mean a compromise of one agent instance’s credentials compromises all agent instances that share them.

Dynamic Client Registration (RFC 7591) solves this by enabling programmatic, per-agent client registration at deployment time. Each agent instance receives a unique client ID and, for confidential clients, a unique client secret. Making per-agent credential isolation operational rather than aspirational.

JavaScript – Dynamic Client Registration at Agent Deploy

// RFC 7591 — per-agent client registration on deployment

async function registerMCPAgentClient(agentConfig) {
  const registrationRequest = {
    // Client identification
    client_name: `${agentConfig.agentRole}-${agentConfig.deploymentId}`,
    redirect_uris: [agentConfig.exactRedirectUri],
    
    // OAuth 2.1 — authorization_code only, NO implicit
    response_types: ['code'],
    grant_types: ['authorization_code', 'refresh_token'],
    token_endpoint_auth_method: 'none',        // Public client — PKCE instead
    
    // Exact redirect URIs — OAuth 2.1 requires exact match
    redirect_uris: [agentConfig.exactRedirectUri],
    
    // Declare only the scopes this agent role requires
    scope: agentConfig.requiredScopes.join(' '),
    
    // Metadata for audit and management
    contacts: [agentConfig.ownerEmail],
    software_id: agentConfig.softwareId,
    software_version: agentConfig.version,
  };

  const response = await fetch(REGISTRATION_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // Initial registration token — issued by your bootstrap auth
      'Authorization': `Bearer ${REGISTRATION_ACCESS_TOKEN}`
    },
    body: JSON.stringify(registrationRequest)
  });

  const { client_id, registration_access_token } = await response.json();

  // Store client_id and registration token in secrets manager
  await storeInSecretsManager(`agents/${agentConfig.deploymentId}/oauth`, {
    client_id: client_id,
    registration_token: registration_access_token  // For future client management
  });

  return client_id;
}

The Seven OAuth 2.1 Implementation Mistakes

Getting the OAuth 2.1 specification right on paper is substantially easier than implementing it correctly under real operational constraints. These are the seven mistakes that appear most frequently in MCP OAuth deployments. Each one creates a security gap despite teams believing they have implemented the standard correctly.

01. Storing the PKCE code verifier in sessionStorage

Wrong: sessionStorage.setItem('pkce_verifier', verifier)

Right: Hold verifier in module-scoped closure variable, clear after exchange

02. Accepting tokens in query parameters “for compatibility”

Wrong: if (token = req.query.access_token || req.headers.authorization)

Right: Authorization header only — reject any request presenting token elsewhere

03. Validating token signature but not audience

Wrong: jwt.decode(token, key, algorithms=['RS256']) # No audience check

Right: jwt.decode(token, key, algorithms=['RS256'], audience=MCP_SERVER_URI)

04. Issuing long-lived access tokens to “reduce token endpoint load”

Wrong: access_token_lifetime = 86400 # 24 hours

Right: access_token_lifetime = 900 # 15 minutes max — use refresh tokens for longevity

05. Implementing refresh token rotation without reuse detection

Wrong: Rotate tokens on use but don’t track prior tokens — attacker can silently use stolen token

Right: Maintain refresh token family registry — any reuse of an invalidated token revokes the entire family

06. Allowing wildcard or partial redirect URI matching

Wrong: redirect_uri.startsWith(registered_uri) # Partial match

Right: redirect_uri === registered_uri # Exact match — OAuth 2.1 requirement

07. Not validating the state parameter on authorization response

Wrong: Generating state but not validating it on callback — CSRF protection is theater

Right: Cryptographically random state, stored in memory, validated on callback before code exchange

Resource Indicators: Binding Tokens to Specific MCP Servers

In a typical enterprise MCP deployment, multiple MCP servers may use the same authorization server. A token issued to grant access to a low-privilege document retrieval MCP server could, without resource indicator validation, be replayed against a high-privilege code execution MCP server that trusts the same authorization server. Resource indicators (RFC 8707) close this by binding each token to the specific server it was issued for.

On the Agent Client

Include resource=https://your-mcp-server.example.com in both the authorization request and the token exchange request. The authorization server binds the issued token to this URI. If requesting access to multiple MCP servers, include multiple resource parameters. The server issues tokens scoped to each declared resource separately.

On the MCP Server

Validate that the incoming access token’s resource claim (or aud claim) contains your server’s exact URI. A token that does not include your server’s URI in its resource binding must be rejected with 401. Regardless of whether the signature is valid. Signature validity alone does not confirm the token was intended for your server.

The Validation Difference

The resource indicator validation is the control that most MCP OAuth implementations skip because it adds one line to the token validation middleware and seems redundant with audience validation. It is not redundant. Audience validation confirms the token’s intended consumer category. Resource indicator validation confirms the exact resource server. In a multi-server deployment where servers share an authorization server, the difference between these two checks determines whether cross-server token replay is possible.

The OAuth 2.1 Implementation Test (10 Minutes)

After deploying OAuth 2.1 on your MCP server, run these four checks:

  1. Attempt to authenticate using the implicit grant; you should get an error
  2. Attempt to present a valid access token as a query parameter; the server should reject it
  3. Use a refresh token twice in rapid succession; the second use should fail and the token family should be revoked
  4. Present an access token issued for a different MCP server’s resource; should be rejected with 401

If all four checks produce the expected rejections, your OAuth 2.1 implementation covers the primary attack surface correctly.

Conclusion

OAuth 2.1 is not optional for production MCP. Implementation details determine whether you have security or just the appearance of it. PKCE verifier storage. Refresh token family revocation. Resource indicator validation. These are requirements, not preferences.

Deploy authorization code flow + PKCE this week. Add resource indicators by month end. Test token reuse by month two.

Implementing OAuth 2.1 wrong is how breaches happen. The CMCPSE teaches the correct patterns: PKCE flows, scope enforcement, token lifecycle, resource binding. Hands-on labs, live incident scenarios. Launching June 2026. Get early access.

Certified MCP Security Expert

Attack, defend, and pen test MCP servers in 30+ hands-on labs.

Certified MCP Security Expert

FAQs

What is OAuth 2.1 and how does it differ from OAuth 2.0 for MCP?

OAuth 2.1 consolidates OAuth 2.0 and its security best practice RFCs into a single specification with security measures mandatory by default. For MCP, the critical differences are: PKCE is mandatory for all authorization code flows, the implicit grant and resource owner password credentials grant are removed entirely, refresh token rotation is required (single-use), redirect URIs must match exactly with no partial matching, and bearer tokens must be sent in the Authorization header only. MCP servers implementing OAuth 2.0 without these restrictions are vulnerable to attack classes that OAuth 2.1 closes structurally.

Why is PKCE required for MCP agent authentication?

PKCE is required because AI agent runtimes are public clients. They cannot securely store a client secret. Without PKCE, an authorization code intercepted by a malicious process on the same machine can be exchanged for an access token. PKCE prevents this by requiring the client to prove it is the same entity that generated the authorization request. Using a cryptographic challenge that cannot be replicated by an attacker who only captured the authorization code.

How should OAuth 2.1 scopes be designed for MCP tool permissions?

MCP OAuth 2.1 scopes should use a three-part pattern: mcp:tool:{tool_name}:{operation_class}. Each scope grants access to a specific tool with a specific operation type. Agents request only the scopes required for their declared task. The MCP server validates scope claims on every tool invocation—refusing calls where the required scope is absent in the token. This creates token-layer enforcement independent of session-level allowlists, providing a second gate if allowlist controls are compromised through injection.

What is refresh token rotation and why is it mandatory in OAuth 2.1 for MCP?

Refresh token rotation issues a new refresh token on every use and invalidates the prior one. This converts a stolen refresh token into a detectable event. When the attacker uses the stolen token, the legitimate client’s next refresh fails because the old token is invalidated, triggering an alert. The critical implementation detail is reuse detection: when an already-invalidated token is presented, the entire token family must be revoked—not just that token—ensuring the attacker loses access even if they used the stolen token first.

What are resource indicators in OAuth 2.1 and how do they apply to MCP?

Resource indicators (RFC 8707) allow the agent to specify the MCP server’s URI in the authorization request, binding the issued token to that specific server. The MCP server validates that incoming tokens include its own URI in the resource binding before accepting them. This prevents cross-server token replay. Where a token issued for a low-privilege MCP server is presented to a high-privilege server that accepts tokens from the same authorization server. Without resource indicators, sharing an authorization server across multiple MCP servers creates a cross-server escalation path.

Is there a certification covering MCP OAuth 2.1 implementation?

The Certified MCP Security Expert (CMCPSE) from Practical DevSecOps, launching June 2026, includes hands-on lab exercises covering OAuth 2.1 implementation for MCP servers. PKCE flow, scope design, refresh token rotation, dynamic client registration, and resource indicator validation. The practical exam includes authentication implementation scenarios in a live lab environment. Early registration is open at practical-devsecops.com.

Varun Kumar

Varun Kumar

Security Research Writer

Varun is a Security Research Writer specializing in DevSecOps, AI Security, and cloud-native security. He takes complex security topics and makes them straightforward. His articles provide security professionals with practical, research-backed insights they can actually use.

Related articles

Start your journey today and upgrade your security career

Gain advanced security skills through our certification courses. Upskill today and get certified to become the top 1% of cybersecurity engineers in the industry.