AID-H-034.002: MCP Server OAuth Resource Boundary & Delegation Safety

Version 1.20261015 · Actionable sub-technique

Enforce the MCP server's role as an OAuth protected resource. The server must publish protected resource metadata where applicable, validate token issuer, audience, resource, scope, and expiry, reject tokens not issued for this MCP server, avoid token passthrough, and use separate delegated credentials for upstream services. Enterprise-managed authorization must be explicitly advertised and must bind the identity assertion to the authenticated MCP client, the resource authorization server, narrowed local scopes, replay policy, and proof-of-possession when the assertion or local policy requires it.

Pillars: app, infra

Lifecycle: building, operation

Scope boundary

Owns MCP-server-specific OAuth protected-resource metadata, issuer and audience validation, resource and scope checks, enterprise-managed authorization binding, no-token-passthrough rules, and delegated upstream credentials.

AID-H-004.002: AID-H-034.002 specializes service authentication for MCP protected-resource and delegation semantics; AID-H-004.002 owns the broader service and API authentication architecture. Generic token validation does not cover MCP resource metadata or confused-deputy delegation by itself.

AID-H-018.002: AID-H-034.002 authenticates and narrows the authority presented to the MCP server; AID-H-018.002 decides whether that principal may perform the requested capability and object action. Token validity is not action authorization.

Implementation guidance

AID-H-034.002-G001

Publish OAuth protected resource metadata and return scope-aware WWW-Authenticate challenges for protected MCP servers.
Concept:

When an MCP server is protected by OAuth over HTTP transport, clients need a standard way to discover the resource server and the authorization server. The server should expose protected resource metadata and use precise WWW-Authenticate challenges to tell clients which resource and scope are required for the current operation. Do not publish a full privilege catalog or force clients to ask for broad scopes during first contact.

Example protected resource metadata
// file: src/oauth/resourceMetadata.js
    export function protectedResourceMetadata(req, res) {
      res.json({
        resource: "https://payments-mcp.internal.example.com",
        authorization_servers: [
          "https://auth.internal.example.com"
        ],
        scopes_supported: [
          "mcp:tools-basic",
          "payments:read"
        ],
        bearer_methods_supported: ["header"]
      });
    }
    
Example scope challenge
// file: src/oauth/challenge.js
    export function requireScope(requiredScope) {
      return function scopeGate(req, res, next) {
        if (!req.auth) {
          res.setHeader(
            "WWW-Authenticate",
            'Bearer resource_metadata="https://payments-mcp.internal.example.com/.well-known/oauth-protected-resource"'
          );
          return res.status(401).end();
        }
        if (!req.auth.scopes.includes(requiredScope)) {
          res.setHeader(
            "WWW-Authenticate",
            'Bearer error="insufficient_scope", resource_metadata="https://payments-mcp.internal.example.com/.well-known/oauth-protected-resource", scope="' + requiredScope + '"'
          );
          return res.status(403).end();
        }
        return next();
      };
    }
    

Operational notes: Keep scopes operation-specific. Reject wildcard or omnibus scopes such as *, all, and full-access. Avoid advertising refresh-token scopes as a resource requirement unless the server is also acting as an authorization server and the design has been reviewed separately.

Verify safely

A separately credentialed verifier fetches metadata independently and exercises absent, malformed, wrong-resource, expired, and insufficient-scope requests against every protected route, then compares challenges with the effective scope map.

AID-H-034.002-G002

Use progressive least-privilege scope elevation and reject wildcard, omnibus, or cross-tool scopes that hide tool intent.
Concept:

MCP clients often do not know a server's domain-specific scope model before discovery. If the server publishes every possible scope or accepts broad scopes by default, one stolen token can call unrelated tools. Start with low-risk discovery/read scopes, then step up only when a privileged operation is attempted.

Example per-tool scope policy
// file: src/oauth/toolScopePolicy.js
    const toolScopes = {
      list_accounts: ["payments:read"],
      read_customer_account: ["customers:read"],
      refund_payment: ["payments:refund"],
      export_customer_data: ["customers:export"]
    };

    const forbiddenScopes = new Set(["*", "all", "full-access", "admin:*"]);

    export function requiredScopesForTool(toolName) {
      const scopes = toolScopes[toolName];
      if (!scopes) {
        throw new Error("Unknown tool scope policy: " + toolName);
      }
      return scopes;
    }

    export function validateScopeSet(scopes) {
      for (const scope of scopes) {
        if (forbiddenScopes.has(scope)) {
          throw new Error("Wildcard or omnibus scopes are not accepted");
        }
      }
    }

    export function requireToolScopes({ auth, toolName }) {
      const granted = new Set(auth.scopes || []);
      validateScopeSet(granted);
      const missing = requiredScopesForTool(toolName).filter(scope => !granted.has(scope));
      if (missing.length > 0) {
        return {
          ok: false,
          // Challenge only for scopes required by this operation; the client
          // accumulates previously granted scopes when requesting elevation.
          wwwAuthenticateScope: [...new Set(missing)].join(" ")
        };
      }
      return { ok: true };
    }
    

Operational notes: Log scope elevation prompts with user, client, tenant, tool, requested scope, granted subset, and correlation ID. Do not treat possession of a broad token as a substitute for handler-level authorization in AID-H-034.003.

Verify safely

A separately credentialed verifier recomputes the tool-scope matrix from the signed contract and runs missing, extra, wildcard, duplicate, cross-tool, stale-contract, and privilege-escalation fixtures through the deployed gate.

AID-H-034.002-G003

Validate issuer, audience, resource, expiry, and scope on every HTTP request, and reject tokens not issued for this MCP server.
Concept:

The MCP server is a protected resource. It must only accept tokens that were issued for this exact MCP server resource. A token meant for another API, another MCP server, or an upstream SaaS API must be rejected even if it is structurally valid.

Example JWT validation middleware
// File: src/oauth/validateToken.js
import crypto from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";

const tokenDigest = token => crypto.createHash("sha256").update(token).digest("hex");
function stringList(value) {
  const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(" ") : [];
  if (!values.length || values.some(item => typeof item !== "string" || !item) ||
      values.length !== new Set(values).size) throw new Error("invalid_string_list_claim");
  return values;
}

export function buildMcpBearerValidator(policy, dependencies) {
  const jwksUri = new URL(policy.jwksUri);
  if (!policy.version || new URL(policy.issuer).protocol !== "https:" ||
      new URL(policy.resource).protocol !== "https:" ||
      new URL(policy.metadataUrl).protocol !== "https:" || jwksUri.protocol !== "https:" ||
      !Array.isArray(policy.algorithms) || !policy.algorithms.length ||
      !Number.isSafeInteger(policy.clockToleranceSeconds) || policy.clockToleranceSeconds < 0) {
    throw new Error("invalid_signed_resource_server_policy");
  }
  const jwks = createRemoteJWKSet(jwksUri);
  return async function validateMcpBearer(req, res, next) {
    const match = String(req.headers.authorization || "").match(/^Bearer ([^\s]+)$/i);
    if (!match) {
      res.setHeader("WWW-Authenticate",
        'Bearer resource_metadata="' + policy.metadataUrl + '"');
      return res.status(401).end();
    }
    const token = match[1];
    try {
      const { payload, protectedHeader } = await jwtVerify(token, jwks, {
        issuer: policy.issuer, audience: policy.resource,
        algorithms: policy.algorithms, clockTolerance: policy.clockToleranceSeconds
      });
      if (!["at+jwt", "application/at+jwt"].includes(String(protectedHeader.typ || "").toLowerCase())) {
        throw new Error("access_token_typ_required");
      }
      const audience = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
      const clientId = payload.client_id || payload.azp;
      if (audience.length !== 1 || audience[0] !== policy.resource ||
          typeof payload.sub !== "string" || !payload.sub ||
          typeof clientId !== "string" || !clientId || !Number.isSafeInteger(payload.exp)) {
        throw new Error("required_access_token_binding_missing");
      }
      const scopes = stringList(payload.scope);
      const tenantIds = payload.tenant_ids === undefined ? [] : stringList(payload.tenant_ids);
      const activeTenantId = payload.active_tenant_id || payload.tenant_id || payload.tid || null;
      if ((policy.requireTenant && (!activeTenantId || !tenantIds.includes(String(activeTenantId)))) ||
          (activeTenantId && tenantIds.length && !tenantIds.includes(String(activeTenantId)))) {
        throw new Error("tenant_claim_binding_mismatch");
      }
      if (policy.requireRevocation) {
        if (typeof payload.jti !== "string" || !payload.jti) throw new Error("jti_required_for_revocation");
        if (await dependencies.isRevoked({ issuer: payload.iss, jti: payload.jti,
          tokenSha256: tokenDigest(token) })) throw new Error("access_token_revoked");
      }
      req.auth = Object.freeze({
        subject: payload.sub, clientId, scopes: Object.freeze(scopes),
        tenantIds: Object.freeze(tenantIds), activeTenantId: activeTenantId ? String(activeTenantId) : null,
        issuer: payload.iss, audience: policy.resource, expiresAt: payload.exp,
        tokenSha256: tokenDigest(token), policyVersion: policy.version
      });
      return next();
    } catch {
      res.setHeader("WWW-Authenticate",
        'Bearer error="invalid_token", resource_metadata="' + policy.metadataUrl + '"');
      return res.status(401).end();
    }
  };
}

Operational notes: Validate the Authorization header on every Streamable HTTP POST, including subscriptions/listen and each Task request. There is no protocol session or resumed request whose state can substitute for token validation. If the server exposes tenant-scoped tools, normalize tenant claims into a consistent tenantIds and activeTenantId shape so downstream authorization, task isolation, and budget controls can fail closed instead of building keys with undefined tenant context.

Verify safely

A separately credentialed verifier uses independent keys and live route probes for valid, missing, malformed, unsigned, wrong-algorithm/type/issuer/audience/resource, absent-expiry/scope, expired, revoked, cross-tenant, and JWKS-outage cases.

AID-H-034.002-G004

Ban token passthrough and require separate upstream credentials or standards-based token exchange when the MCP server calls backend APIs.
Concept:

An MCP client token proves authorization to call the MCP server. It is not a general-purpose token to forward to upstream APIs. When the server integrates with GitHub, Google Drive, databases, ticketing systems, payment APIs, or cloud APIs, it must obtain a separate upstream credential for that user, service account, delegated grant, or OAuth 2.0 Token Exchange flow.

Example upstream token guard
// File: src/oauth/upstreamTokenGuard.js
import crypto from "node:crypto";

const digest = value => crypto.createHash("sha256").update(value).digest();
function sameToken(left, right) {
  const a = digest(left); const b = digest(right);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export async function callUpstreamApi(req, upstreamClient, operation) {
  const inboundToken = String(req.headers.authorization || "").replace(/^Bearer\s+/i, "");
  if (!inboundToken) throw new Error("validated_inbound_token_missing");
  const delegated = await upstreamClient.getDelegatedToken({
    subjectToken: inboundToken, subject: req.auth.subject,
    clientId: req.auth.clientId, tenantId: req.auth.activeTenantId,
    scopes: operation.requiredUpstreamScopes, audience: operation.upstreamAudience
  });
  if (!delegated?.token || sameToken(inboundToken, delegated.token) ||
      delegated.audience !== operation.upstreamAudience ||
      delegated.subject !== req.auth.subject || delegated.clientId !== req.auth.clientId ||
      delegated.tenantId !== req.auth.activeTenantId ||
      operation.requiredUpstreamScopes.some(scope => !delegated.scopes.includes(scope)) ||
      delegated.scopes.some(scope => !operation.requiredUpstreamScopes.includes(scope)) ||
      delegated.expiresAtMs <= Date.now()) {
    throw new Error("upstream_credential_binding_failed");
  }
  return upstreamClient.request(operation, delegated.token, {
    idempotencyKey: req.mcpRequestId
  });
}
Example RFC 8693 token exchange for getDelegatedToken
// File: src/oauth/tokenExchange.js
import crypto from "node:crypto";

const sha256 = value => crypto.createHash("sha256").update(value).digest("hex");

function canonicalHttpsEndpoint(value) {
  const endpoint = new URL(value);
  if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password ||
      endpoint.hash || endpoint.origin + endpoint.pathname !== value) {
    throw new Error("STS endpoint must be an exact HTTPS URL without query or fragment");
  }
  return value;
}

async function boundedJsonObject(response, maximumBytes) {
  const declared = response.headers.get("content-length");
  if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maximumBytes)) {
    throw new Error("upstream_token_response_too_large");
  }
  if (!response.body) throw new Error("upstream_token_response_body_missing");
  const reader = response.body.getReader();
  const chunks = [];
  let total = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      total += value.byteLength;
      if (total > maximumBytes) throw new Error("upstream_token_response_too_large");
      chunks.push(Buffer.from(value));
    }
  } catch (error) {
    await reader.cancel(error).catch(() => {});
    throw error;
  } finally {
    reader.releaseLock();
  }
  const parsed = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new Error("upstream_token_response_not_object");
  }
  return parsed;
}

export async function exchangeForUpstreamToken(input) {
  const rule = input.policy.upstreams?.[input.audience];
  if (!rule ||
      !Number.isSafeInteger(rule.maxTokenLifetimeSeconds) || rule.maxTokenLifetimeSeconds <= 0 ||
      !Number.isSafeInteger(rule.cacheSkewSeconds) || rule.cacheSkewSeconds < 0 ||
      !Number.isSafeInteger(rule.timeoutMs) || rule.timeoutMs <= 0 ||
      !Number.isSafeInteger(rule.maxTokenResponseBytes) || rule.maxTokenResponseBytes <= 0) {
    throw new Error("invalid_signed_upstream_token_policy");
  }
  const stsEndpoint = canonicalHttpsEndpoint(input.policy.stsEndpoint);
  const scopes = [...new Set(input.scopes)].sort();
  if (!scopes.length || scopes.some(scope => !rule.allowedScopes.includes(scope))) {
    throw new Error("upstream_scope_outside_policy");
  }
  const cacheKey = [input.policy.version, input.audience, scopes.join(" "),
    sha256(input.subjectToken), sha256(input.subject), sha256(input.tenantId),
    input.clientId].join("|");
  const cached = await input.cache.get(cacheKey);
  if (cached && cached.expiresAtMs > Date.now()) return cached;
  const body = new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    subject_token: input.subjectToken,
    subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
    requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
    resource: input.audience,
    scope: scopes.join(" "),
    client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: await input.clientAssertion(stsEndpoint)
  });
  const response = await input.fetch(stsEndpoint, {
    method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
    body, signal: AbortSignal.timeout(rule.timeoutMs), redirect: "error"
  });
  if (!response.ok) {
    await response.body?.cancel().catch(() => {});
    throw new Error("upstream_token_exchange_failed:" + response.status);
  }
  const result = await boundedJsonObject(response, rule.maxTokenResponseBytes);
  if (typeof result.access_token !== "string" || !result.access_token ||
      result.token_type?.toLowerCase() !== "bearer" ||
      result.issued_token_type !== "urn:ietf:params:oauth:token-type:access_token" ||
      !Number.isSafeInteger(result.expires_in) || result.expires_in <= rule.cacheSkewSeconds ||
      result.expires_in > rule.maxTokenLifetimeSeconds || result.refresh_token) {
    throw new Error("upstream_token_response_outside_policy");
  }
  const delegated = {
    token: result.access_token, audience: input.audience, scopes,
    subject: input.subject, tenantId: input.tenantId, clientId: input.clientId,
    expiresAtMs: Date.now() + (result.expires_in - rule.cacheSkewSeconds) * 1000
  };
  await input.cache.set(cacheKey, delegated, delegated.expiresAtMs - Date.now());
  return delegated;
}

Operational notes: Store upstream credentials in a server-side secret store with user, tenant, client, upstream audience, scope, and expiry metadata. Prefer RFC 8693 token exchange when the upstream authorization server supports it (cloud equivalents: Entra on-behalf-of, AWS STS AssumeRoleWithWebIdentity); otherwise use a separately consented upstream grant. Never cache the inbound MCP bearer token beyond the request it authorizes, and never log inbound or upstream token values; log only hashes or token identifiers.

Verify safely

A separately credentialed verifier captures egress at a controlled upstream and tests inbound-token equality, wrong audience/scope/subject/tenant, cache collision, expiry, broker outage, retry, and log/trace leakage.

AID-H-034.002-G005

If the MCP server acts as an OAuth proxy or OAuth client to a third-party API, enforce per-client consent, exact redirect URI validation, CSRF state, and consent cookies bound to client_id.
Concept:

This control only applies when the MCP server also acts as an OAuth proxy or OAuth client for an upstream third-party service. It does not apply to a pure MCP protected resource that only validates bearer tokens. In proxy mode, the server must prevent a malicious MCP client from abusing static upstream OAuth consent to obtain authorization codes meant for another client. Consent must be recorded before issuing a one-time authorization state.

Example consent record with one-time state
-- File: migrations/20260728_mcp_oauth_proxy_state.sql
CREATE TABLE mcp_oauth_proxy_consents (
  subject_key bytea NOT NULL,
  client_id text NOT NULL,
  redirect_uri text NOT NULL,
  upstream_issuer text NOT NULL,
  scopes text[] NOT NULL,
  policy_version text NOT NULL,
  approved_at timestamptz NOT NULL DEFAULT now(),
  expires_at timestamptz NOT NULL,
  revoked_at timestamptz,
  PRIMARY KEY (subject_key, client_id, redirect_uri, upstream_issuer)
);

CREATE TABLE mcp_oauth_proxy_states (
  state_sha256 bytea PRIMARY KEY,
  subject_key bytea NOT NULL,
  client_id text NOT NULL,
  redirect_uri text NOT NULL,
  upstream_issuer text NOT NULL,
  scopes text[] NOT NULL,
  pkce_challenge text NOT NULL,
  policy_version text NOT NULL,
  expires_at timestamptz NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX mcp_oauth_proxy_states_expiry_idx ON mcp_oauth_proxy_states (expires_at);
// File: src/oauth/proxyConsent.js
import crypto from "node:crypto";

const SUBJECT_KEY = Buffer.from(process.env.MCP_SUBJECT_HMAC_KEY_B64, "base64");
if (SUBJECT_KEY.length !== 32) throw new Error("MCP_SUBJECT_HMAC_KEY_B64 must decode to 32 bytes");
const subjectKey = value => crypto.createHmac("sha256", SUBJECT_KEY).update(value).digest();
const stateDigest = value => crypto.createHash("sha256").update(value).digest();

function exactClientRule(policy, { clientId, redirectUri, upstreamIssuer, scopes }) {
  const client = policy.clients?.[clientId];
  if (!client || !Array.isArray(client.redirectUris) ||
      !client.redirectUris.includes(redirectUri)) throw new Error("unregistered_redirect_uri");
  const allowed = new Set(client.upstreams?.[upstreamIssuer]?.scopes || []);
  const normalized = [...new Set(scopes)].sort();
  if (!normalized.length || normalized.some(scope => !allowed.has(scope))) {
    throw new Error("upstream_scope_not_allowed");
  }
  if (!Number.isSafeInteger(policy.stateTtlMs) || policy.stateTtlMs <= 0 ||
      !Number.isSafeInteger(policy.consentTtlMs) || policy.consentTtlMs <= 0) {
    throw new Error("invalid_signed_oauth_proxy_ttl_policy");
  }
  return normalized;
}

async function transaction(db, work) {
  const client = await db.connect();
  try {
    await client.query("BEGIN");
    const result = await work(client);
    await client.query("COMMIT");
    return result;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

export async function recordConsent(db, input) {
  const scopes = exactClientRule(input.policy, input);
  await db.query(
    "INSERT INTO mcp_oauth_proxy_consents " +
    "(subject_key,client_id,redirect_uri,upstream_issuer,scopes,policy_version,expires_at,revoked_at) " +
    "VALUES ($1,$2,$3,$4,$5,$6,now()+($7::bigint*interval '1 millisecond'),NULL) " +
    "ON CONFLICT (subject_key,client_id,redirect_uri,upstream_issuer) DO UPDATE SET " +
    "scopes=EXCLUDED.scopes,policy_version=EXCLUDED.policy_version,approved_at=now()," +
    "expires_at=EXCLUDED.expires_at,revoked_at=NULL",
    [subjectKey(input.userId), input.clientId, input.redirectUri, input.upstreamIssuer,
      scopes, input.policy.version, input.policy.consentTtlMs]
  );
}

export async function createOneTimeState(db, input) {
  const scopes = exactClientRule(input.policy, input);
  if (!/^[A-Za-z0-9_-]{43,128}$/.test(input.pkceChallenge)) {
    throw new Error("invalid_pkce_challenge");
  }
  const state = crypto.randomBytes(32).toString("base64url");
  await transaction(db, async client => {
    const consent = await client.query(
      "SELECT 1 FROM mcp_oauth_proxy_consents WHERE subject_key=$1 AND client_id=$2 " +
      "AND redirect_uri=$3 AND upstream_issuer=$4 AND revoked_at IS NULL " +
      "AND expires_at>now() AND scopes @> $5::text[] FOR SHARE",
      [subjectKey(input.userId), input.clientId, input.redirectUri, input.upstreamIssuer, scopes]
    );
    if (consent.rowCount !== 1) throw new Error("fresh_per_client_consent_required");
    await client.query(
      "INSERT INTO mcp_oauth_proxy_states " +
      "(state_sha256,subject_key,client_id,redirect_uri,upstream_issuer,scopes,pkce_challenge,policy_version,expires_at) " +
      "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,now()+($9::bigint*interval '1 millisecond'))",
      [stateDigest(state), subjectKey(input.userId), input.clientId, input.redirectUri,
        input.upstreamIssuer, scopes, input.pkceChallenge, input.policy.version,
        input.policy.stateTtlMs]
    );
  });
  return state;
}

export async function consumeOneTimeState(db, input) {
  return transaction(db, async client => {
    const consumed = await client.query(
      "DELETE FROM mcp_oauth_proxy_states WHERE state_sha256=$1 AND subject_key=$2 " +
      "AND client_id=$3 AND redirect_uri=$4 AND upstream_issuer=$5 AND expires_at>now() " +
      "RETURNING scopes,pkce_challenge,policy_version",
      [stateDigest(input.state), subjectKey(input.userId), input.clientId,
        input.redirectUri, input.upstreamIssuer]
    );
    if (consumed.rowCount !== 1) throw new Error("invalid_expired_or_replayed_oauth_state");
    const consent = await client.query(
      "SELECT 1 FROM mcp_oauth_proxy_consents WHERE subject_key=$1 AND client_id=$2 " +
      "AND redirect_uri=$3 AND upstream_issuer=$4 AND revoked_at IS NULL AND expires_at>now() " +
      "AND scopes @> $5::text[]",
      [subjectKey(input.userId), input.clientId, input.redirectUri, input.upstreamIssuer,
        consumed.rows[0].scopes]
    );
    if (consent.rowCount !== 1) throw new Error("oauth_consent_revoked_before_callback");
    return Object.freeze(consumed.rows[0]);
  });
}
Redirect, cookie, and UI requirements

Operational notes: Keep this proxy-specific path separate from ordinary protected-resource token validation so operators do not assume every MCP server needs redirect handling.

Verify safely

A separately credentialed verifier executes CSRF, login mix-up, client substitution, redirect variation, scope escalation, expired/replayed state, cookie theft/fixation, concurrent callback, and clickjacking tests through the deployed flow.

AID-H-034.002-G006

Implement enterprise-managed MCP authorization through the advertised ID-JAG grant profile; validate the assertion at the Resource Authorization Server, bind it to the authenticated client and exact MCP resource, narrow scopes under signed local policy, and enforce proof-of-possession and replay limits.
Before you begin

Use this profile only when an HTTP-based MCP client and MCP server participate in enterprise SSO and the MCP Resource Authorization Server explicitly supports enterprise-managed authorization.

Step 1: Advertise the exact authorization profile

Publish the following fields in the Resource Authorization Server metadata. Advertising the profile declares processing support; it does not authorize every issuer, client, user, tenant, resource, or scope.

{
  "issuer": "https://auth.payments-mcp.internal.example.com",
  "token_endpoint": "https://auth.payments-mcp.internal.example.com/oauth2/token",
  "grant_types_supported": [
    "urn:ietf:params:oauth:grant-type:jwt-bearer"
  ],
  "authorization_grant_profiles_supported": [
    "urn:ietf:params:oauth:grant-profile:id-jag"
  ]
}

The client obtains an ID-JAG from the enterprise IdP by OAuth Token Exchange, requesting urn:ietf:params:oauth:token-type:id-jag for this authorization-server audience and MCP resource. It then authenticates to this authorization server and presents the ID-JAG as the assertion in a urn:ietf:params:oauth:grant-type:jwt-bearer request.

Step 2: Sign the local trust and scope policy
{
  "schema_version": "aidefend.mcp-id-jag-policy.v1",
  "policy_version": "payments-mcp-enterprise-auth-v1",
  "authorization_server_issuer": "https://auth.payments-mcp.internal.example.com",
  "resource": "https://payments-mcp.internal.example.com",
  "trusted_issuers": {
    "https://idp.internal.example.com": {
      "jwks_uri": "https://idp.internal.example.com/.well-known/jwks.json",
      "algorithms": ["ES256"],
      "allowed_clients": {
        "https://approved-client.internal.example.com/client.json": [
          "payments:read",
          "payments:refund"
        ]
      }
    }
  },
  "require_dpop": true,
  "maximum_assertion_bytes": 16384,
  "maximum_jwks_bytes": 262144,
  "maximum_jwks_keys": 32,
  "jwks_timeout_ms": 5000,
  "jwks_cache_seconds": 300,
  "max_clock_skew_seconds": 5,
  "max_grant_age_seconds": 300,
  "max_redemptions_per_grant": 1
}
cosign sign-blob --yes --key env://MCP_POLICY_SIGNING_KEY   --bundle id-jag-policy.json.sig id-jag-policy.json

max_redemptions_per_grant is an organizational replay policy. A value greater than one is permitted when a client is intentionally allowed to re-submit an unexpired ID-JAG, but it must remain bounded and monitored. Proof-of-possession is still validated independently.

Step 3: Validate the ID-JAG and reserve its redemption atomically
// File: src/oauth/validateIdJagGrant.js
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
  createLocalJWKSet,
  decodeJwt,
  decodeProtectedHeader,
  jwtVerify
} from "jose";
import { createClient } from "redis";

const ASSERTION_TYP = "oauth-id-jag+jwt";
const ALLOWED_JWT_ALGORITHMS = new Set([
  "RS256", "RS384", "RS512", "PS256", "PS384", "PS512",
  "ES256", "ES384", "ES512", "EdDSA"
]);
const POLICY_FIELDS = [
  "authorization_server_issuer",
  "jwks_cache_seconds",
  "jwks_timeout_ms",
  "max_clock_skew_seconds",
  "maximum_assertion_bytes",
  "maximum_jwks_bytes",
  "maximum_jwks_keys",
  "max_grant_age_seconds",
  "max_redemptions_per_grant",
  "policy_version",
  "require_dpop",
  "resource",
  "schema_version",
  "trusted_issuers"
].sort();

function sha256(value) {
  return createHash("sha256").update(value).digest("hex");
}

function exactKeys(object, expected) {
  return object &&
    typeof object === "object" &&
    !Array.isArray(object) &&
    JSON.stringify(Object.keys(object).sort()) === JSON.stringify(expected);
}

function rejectDuplicateJsonKeys(text) {
  let index = 0;
  function whitespace() {
    while (index < text.length && " \n\r\t".includes(text[index])) index += 1;
  }
  function stringValue() {
    const start = index;
    if (text[index++] !== "\"") throw new Error("JSON string expected");
    while (index < text.length) {
      if (text[index] === "\\") { index += 2; continue; }
      if (text[index++] === "\"") return JSON.parse(text.slice(start, index));
    }
    throw new Error("unterminated JSON string");
  }
  function value() {
    whitespace();
    if (text[index] === "{") return objectValue();
    if (text[index] === "[") {
      index += 1; whitespace();
      if (text[index] === "]") { index += 1; return; }
      while (true) {
        value(); whitespace();
        if (text[index] === "]") { index += 1; return; }
        if (text[index++] !== ",") throw new Error("invalid JSON array");
      }
    }
    if (text[index] === "\"") { stringValue(); return; }
    const match = text.slice(index).match(/^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/);
    if (!match) throw new Error("invalid JSON value");
    index += match[0].length;
  }
  function objectValue() {
    index += 1; whitespace();
    const keys = new Set();
    if (text[index] === "}") { index += 1; return; }
    while (true) {
      whitespace();
      const key = stringValue();
      if (keys.has(key)) throw new Error("duplicate JSON key: " + key);
      keys.add(key); whitespace();
      if (text[index++] !== ":") throw new Error("invalid JSON object");
      value(); whitespace();
      if (text[index] === "}") { index += 1; return; }
      if (text[index++] !== ",") throw new Error("invalid JSON object");
    }
  }
  value(); whitespace();
  if (index !== text.length) throw new Error("trailing JSON data");
}

function canonicalHttps(value, label) {
  const parsed = new URL(value);
  const rendered = parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname);
  if (parsed.protocol !== "https:" || parsed.username || parsed.password ||
      parsed.search || parsed.hash || rendered !== value) {
    throw new Error(label + " must be a canonical HTTPS URL");
  }
  return value;
}

function positiveEnvironmentInteger(name) {
  const value = Number(process.env[name]);
  if (!Number.isSafeInteger(value) || value <= 0) throw new Error(name + " is invalid");
  return value;
}

function loadSignedPolicy(path, signaturePath, publicKeyPath) {
  const bytes = readFileSync(path);
  const temporaryRoot = mkdtempSync(join(tmpdir(), "mcp-id-jag-policy-"));
  const snapshotPath = join(temporaryRoot, "policy.json");
  try {
    writeFileSync(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
    execFileSync("cosign", [
      "verify-blob", "--key", publicKeyPath,
      "--bundle", signaturePath, snapshotPath
    ], {
      stdio: ["ignore", "pipe", "pipe"],
      timeout: positiveEnvironmentInteger("VERIFIED_MCP_POLICY_VERIFY_TIMEOUT_MS"),
      maxBuffer: positiveEnvironmentInteger("VERIFIED_MCP_POLICY_VERIFY_MAX_BUFFER_BYTES"),
      windowsHide: true
    });
  } finally {
    rmSync(temporaryRoot, { recursive: true, force: true });
  }
  const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  rejectDuplicateJsonKeys(text);
  const policy = JSON.parse(text);
  if (!exactKeys(policy, POLICY_FIELDS)) throw new Error("policy_schema_mismatch");
  if (policy.schema_version !== "aidefend.mcp-id-jag-policy.v1") {
    throw new Error("unsupported_policy_schema");
  }
  for (const field of ["policy_version", "authorization_server_issuer", "resource"]) {
    if (typeof policy[field] !== "string" || !policy[field]) {
      throw new Error("invalid_policy_" + field);
    }
  }
  canonicalHttps(policy.authorization_server_issuer, "authorization_server_issuer");
  canonicalHttps(policy.resource, "resource");
  if (typeof policy.require_dpop !== "boolean") throw new Error("invalid_require_dpop");
  for (const field of [
    "max_clock_skew_seconds", "max_grant_age_seconds", "max_redemptions_per_grant",
    "maximum_assertion_bytes", "maximum_jwks_bytes", "maximum_jwks_keys",
    "jwks_timeout_ms", "jwks_cache_seconds"
  ]) {
    if (!Number.isSafeInteger(policy[field]) || policy[field] < 1) {
      throw new Error("invalid_policy_" + field);
    }
  }
  if (!policy.trusted_issuers || Object.keys(policy.trusted_issuers).length === 0) {
    throw new Error("trusted_issuer_policy_empty");
  }
  return { policy, policySha256: sha256(bytes) };
}

const loaded = loadSignedPolicy(
  process.env.MCP_ID_JAG_POLICY_PATH,
  process.env.MCP_ID_JAG_POLICY_SIGNATURE_PATH,
  process.env.MCP_ID_JAG_POLICY_PUBLIC_KEY_PATH
);
const policy = loaded.policy;
const replayStore = createClient({ url: process.env.MCP_REPLAY_REDIS_URL });
replayStore.on("error", function onRedisError(error) {
  console.error("ID-JAG replay store error", { errorClass: error.constructor.name });
});
await replayStore.connect();

async function boundedJson(url, maximumBytes, timeoutMs) {
  const response = await fetch(url, {
    method: "GET", redirect: "error", signal: AbortSignal.timeout(timeoutMs)
  });
  if (!response.ok || !response.body) throw new Error("jwks_fetch_failed");
  const declared = response.headers.get("content-length");
  if (declared !== null && (!/^[0-9]+$/.test(declared) || Number(declared) > maximumBytes)) {
    await response.body.cancel();
    throw new Error("jwks_response_too_large");
  }
  const reader = response.body.getReader();
  const chunks = [];
  let total = 0;
  try {
    while (true) {
      const part = await reader.read();
      if (part.done) break;
      total += part.value.byteLength;
      if (total > maximumBytes) throw new Error("jwks_response_too_large");
      chunks.push(Buffer.from(part.value));
    }
  } catch (error) {
    await reader.cancel(error).catch(function ignoreCancel() {});
    throw error;
  } finally {
    reader.releaseLock();
  }
  const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total));
  rejectDuplicateJsonKeys(text);
  const value = JSON.parse(text);
  if (!exactKeys(value, ["keys"]) || !Array.isArray(value.keys) ||
      value.keys.length === 0 || value.keys.length > policy.maximum_jwks_keys) {
    throw new Error("jwks_schema_or_population_invalid");
  }
  return value;
}

const jwksCache = new Map();
async function issuerConfiguration(issuer) {
  const configuration = policy.trusted_issuers[issuer];
  if (!configuration) throw new Error("untrusted_idp_issuer");
  if (
    !exactKeys(configuration, ["algorithms", "allowed_clients", "jwks_uri"]) ||
    typeof configuration.jwks_uri !== "string" ||
    !Array.isArray(configuration.algorithms) ||
    configuration.algorithms.length === 0 ||
    configuration.algorithms.length !== new Set(configuration.algorithms).size ||
    configuration.algorithms.some(function invalidAlgorithm(algorithm) {
      return !ALLOWED_JWT_ALGORITHMS.has(algorithm);
    }) ||
    !configuration.allowed_clients ||
    typeof configuration.allowed_clients !== "object" ||
    Array.isArray(configuration.allowed_clients)
  ) {
    throw new Error("invalid_issuer_configuration");
  }
  canonicalHttps(issuer, "trusted issuer");
  canonicalHttps(configuration.jwks_uri, "issuer jwks_uri");
  const clients = Object.entries(configuration.allowed_clients);
  if (
    clients.length === 0 ||
    clients.some(function invalidClient(entry) {
      const [clientId, scopes] = entry;
      return !clientId || !Array.isArray(scopes) || scopes.length === 0 ||
        scopes.length !== new Set(scopes).size ||
        scopes.some(function invalidScope(scope) {
          return typeof scope !== "string" || !scope;
        });
    })
  ) {
    throw new Error("invalid_issuer_client_policy");
  }
  const now = Date.now();
  let cached = jwksCache.get(issuer);
  if (!cached || cached.expiresAt <= now) {
    const promise = boundedJson(
      configuration.jwks_uri, policy.maximum_jwks_bytes, policy.jwks_timeout_ms
    ).then(createLocalJWKSet);
    cached = { promise, expiresAt: now + policy.jwks_cache_seconds * 1000 };
    jwksCache.set(issuer, cached);
  }
  try {
    return { configuration, jwks: await cached.promise };
  } catch (error) {
    if (jwksCache.get(issuer) === cached) jwksCache.delete(issuer);
    throw error;
  }
}

function normalizeScope(value) {
  if (value === undefined) return [];
  if (typeof value !== "string") throw new Error("invalid_scope_claim");
  const scopes = value.split(" ").filter(Boolean);
  if (scopes.length !== new Set(scopes).size) throw new Error("duplicate_scope_claim");
  return scopes;
}

const RESERVE_REDEMPTION_LUA = [
  "local count = redis.call('INCR', KEYS[1])",
  "if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end",
  "return count"
].join("\n");

async function reserveRedemption(issuer, jti, expiresAt) {
  const ttl = Math.max(1, expiresAt - Math.floor(Date.now() / 1000));
  const key = "mcp:id-jag:redemption:" + sha256(issuer + "\u0000" + jti);
  const count = Number(await replayStore.eval(RESERVE_REDEMPTION_LUA, {
    keys: [key],
    arguments: [String(ttl)]
  }));
  if (!Number.isSafeInteger(count) || count > policy.max_redemptions_per_grant) {
    throw new Error("id_jag_redemption_limit_exceeded");
  }
  return count;
}

export async function validateIdJagGrant(input) {
  if (typeof input.assertion !== "string" ||
      Buffer.byteLength(input.assertion, "utf8") > policy.maximum_assertion_bytes) {
    throw new Error("assertion_missing_or_too_large");
  }
  if (input.grantType !== "urn:ietf:params:oauth:grant-type:jwt-bearer") {
    throw new Error("unsupported_grant_type");
  }
  if (typeof input.authenticatedClientId !== "string" || !input.authenticatedClientId) {
    throw new Error("client_authentication_required");
  }

  // The unverified issuer is used only as an exact lookup into signed local policy.
  const unverified = decodeJwt(input.assertion);
  const issuer = unverified.iss;
  if (typeof issuer !== "string") throw new Error("missing_issuer");
  const { configuration, jwks } = await issuerConfiguration(issuer);
  const header = decodeProtectedHeader(input.assertion);
  if (header.typ !== ASSERTION_TYP) throw new Error("invalid_assertion_typ");

  const verified = await jwtVerify(input.assertion, jwks, {
    issuer,
    audience: policy.authorization_server_issuer,
    algorithms: configuration.algorithms,
    clockTolerance: policy.max_clock_skew_seconds
  });
  const claims = verified.payload;
  for (const field of ["sub", "jti", "client_id", "iat", "exp", "resource"]) {
    if (claims[field] === undefined || claims[field] === null || claims[field] === "") {
      throw new Error("missing_claim_" + field);
    }
  }
  for (const field of ["sub", "jti", "client_id", "resource"]) {
    if (typeof claims[field] !== "string" || !claims[field]) {
      throw new Error("invalid_claim_" + field);
    }
  }
  const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (audience.length !== 1 || audience[0] !== policy.authorization_server_issuer) {
    throw new Error("invalid_assertion_audience");
  }
  if (claims.client_id !== input.authenticatedClientId) {
    throw new Error("assertion_client_binding_mismatch");
  }
  if (claims.resource !== policy.resource) throw new Error("assertion_resource_mismatch");
  if (!Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp)) {
    throw new Error("invalid_assertion_time_claims");
  }
  const now = Math.floor(Date.now() / 1000);
  if (claims.iat > now + policy.max_clock_skew_seconds ||
      now - claims.iat > policy.max_grant_age_seconds) {
    throw new Error("assertion_outside_maximum_age");
  }
  if (claims.exp <= claims.iat || claims.exp - claims.iat > policy.max_grant_age_seconds) {
    throw new Error("assertion_lifetime_exceeds_policy");
  }
  if (claims.authorization_details !== undefined) {
    throw new Error("authorization_details_not_supported_by_local_policy");
  }

  const clientScopes = configuration.allowed_clients[input.authenticatedClientId];
  if (!Array.isArray(clientScopes) || clientScopes.length === 0) {
    throw new Error("client_not_allowed_for_issuer");
  }
  const assertionScopes = normalizeScope(claims.scope);
  const requestedScopes = Array.isArray(input.requestedScopes)
    ? input.requestedScopes
    : assertionScopes;
  if (
    requestedScopes.length === 0 ||
    requestedScopes.length !== new Set(requestedScopes).size ||
    requestedScopes.some(function invalidScope(scope) {
    return typeof scope !== "string" || !scope || !assertionScopes.includes(scope) || !clientScopes.includes(scope);
    })
  ) {
    throw new Error("requested_scope_not_authorized");
  }
  const grantedScopes = [...new Set(requestedScopes)].sort();

  const assertionJkt = claims.cnf && claims.cnf.jkt;
  if (claims.cnf !== undefined && (typeof assertionJkt !== "string" || !assertionJkt)) {
    throw new Error("invalid_assertion_confirmation_claim");
  }
  if (assertionJkt && assertionJkt !== input.verifiedDpopJkt) {
    throw new Error("assertion_dpop_binding_mismatch");
  }
  if (policy.require_dpop && (typeof input.verifiedDpopJkt !== "string" || !input.verifiedDpopJkt)) {
    throw new Error("dpop_proof_required");
  }

  const redemptionCount = await reserveRedemption(issuer, String(claims.jti), claims.exp);
  return {
    status: "GRANT_ACCEPTED",
    subject: String(claims.sub),
    clientId: input.authenticatedClientId,
    resource: policy.resource,
    scopes: grantedScopes,
    dpopJkt: assertionJkt || input.verifiedDpopJkt || null,
    policyVersion: policy.policy_version,
    evidence: {
      policySha256: loaded.policySha256,
      assertionSha256: sha256(input.assertion),
      subjectSha256: sha256(issuer + "\u0000" + String(claims.sub)),
      jtiSha256: sha256(issuer + "\u0000" + String(claims.jti)),
      redemptionCount,
      expiresAt: claims.exp
    }
  };
}

The three network and size bounds in the policy are signed organizational values; configure the verification-command limits from the same verified release profile. The JWKS fetch rejects redirects, non-canonical URLs, duplicate-key JSON, over-cap decoded bodies, and oversized key populations before JOSE sees them.

The token endpoint must obtain authenticatedClientId from completed OAuth client authentication, and verifiedDpopJkt from an RFC 9449 verifier; neither value may come from untrusted form fields. Map failed assertion checks to the OAuth invalid_grant response without returning internal reason details, while retaining the normalized reason code in protected telemetry. After GRANT_ACCEPTED, issue a short-lived access token whose audience is exactly the returned MCP resource, whose scopes are exactly the narrowed set, and whose cnf.jkt matches dpopJkt when present. Do not issue a refresh token for this grant, forward the ID-JAG to the MCP server, or log the raw assertion.

Step 4: Prove the control independently

Run an integration suite through the deployed authorization-server endpoint. Include one valid exchange and negative cases for an extra audience, wrong authenticated client, wrong resource, untrusted issuer, expired or over-age assertion, scope escalation, unsupported authorization details, mismatched or missing DPoP proof, and redemption above the signed policy limit. Preserve authorization-server metadata, signed policy and digest, issuer/JWKS identity, assertion digest, hashed subject and JTI, authenticated client ID, resource, requested and granted scopes, DPoP thumbprint, redemption count, decision reason, access-token audience readback, and test report in append-only evidence storage. The gate succeeds only when every negative case fails and the valid token is independently decoded and verified against the exact MCP audience; missing test coverage or any verifier or replay-store failure blocks token issuance.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-034.002: MCP Server OAuth Resource Boundary & Delegation Safety | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version