AID-H-034.002: MCP Server OAuth Resource Boundary & Delegation Safety
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.
- AID-H-034.002-G002: Use progressive least-privilege scope elevation and reject wildcard, omnibus, or cross-tool scopes that hide tool intent.
- 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.
- 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.
- 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.
- 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.
AID-H-034.002-G001
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
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
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
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
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
- Match redirect URIs by exact string comparison against registered client metadata.
- Issue the CSRF state only after per-client consent exists; bind it to user ID, client ID, redirect URI, upstream service, requested scopes, and expiration; consume it once.
- If cookies track consent, use a host-only cookie name with the
__Host-prefix,Secure,HttpOnly,Path=/, andSameSite=Lax; bind consent to the specificclient_id, not just the user. - Serve the consent page with
Content-Security-Policy: frame-ancestors 'none'so a malicious client cannot clickjack consent.
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
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.jsonmax_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
- oauth2-proxy
- Keycloak
- ORY Hydra
- Open Policy Agent (OPA)
- SPIRE
- jose (JWT/JWK verification)
- Valkey
- Redis 8+ (AGPL-3.0)
Commercial / hosted tools
- Okta
- Auth0
- Microsoft Entra ID
- PingFederate
- HashiCorp Vault Enterprise
- HCP Vault Dedicated
Threat mappings
MITRE ATLAS
- AML.T0012 Valid Accounts
- AML.T0053 AI Agent Tool Invocation
- AML.T0086 Exfiltration via AI Agent Tool Invocation
- AML.T0098 AI Agent Tool Credential Harvesting
MAESTRO
- Agent Identity Attack (L7)
OWASP LLM Top 10 2026
- LLM03:2026 Excessive Agency
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- ASI03:2026 Identity and Privilege Abuse
NIST Adversarial Machine Learning 2025
- N/A
Cisco Integrated AI Security and Safety Framework
- AISubtech-14.1.2 Insufficient Access Controls
- AISubtech-14.2.1 Permission Escalation via Delegation
- AISubtech-2.1.1 Execution Approval Bypass (requires fresh client-specific OAuth-proxy consent before delegated authorization)
- AISubtech-2.1.2 Capability and Permission Overreach
- AISubtech-4.3.4 Replay Exploitation
- AISubtech-7.4.1 Token Theft (blocks token passthrough and binds enterprise grants to resource and proof of possession)
- AITech-14.2 Abuse of Delegated Authority
- AITech-2.1 Excessive Agency
- AITech-4.3 Protocol Manipulation
- AITech-7.4 Token Manipulation (blocks token passthrough and binds enterprise grants to resource and proof of possession)
Google Secure AI Framework 2.0 - Risks
- IIC: Insecure Integrated Component
- SDD: Sensitive Data Disclosure
- RA: Rogue Actions
Databricks AI Security Framework 3.0
- Agents - Tools MCP Server 13.17: Confused Deputy
- Agents - Tools MCP Server 13.19: Credential and Token Exposure
- Agents - Tools MCP Server 13.22: Excessive Permissions and Scope Creep
- Agents - Tools MCP Server 13.23: Data Exfiltration