Security
Securitybeta20 min

Enterprise-managed authorization — Okta Cross App Access and Agent SSO

Let MCP clients reach MCPG with access your enterprise IdP governs — the gateway's embedded authorization server redeems ID-JAGs, and this page covers how it works end to end, what MCPG checks, and how to set it up with Okta.

Beta, built on specifications that still move. The Identity Assertion JWT Authorization Grant (ID-JAG) is an IETF Internet-Draft; MCPG implements draft-ietf-oauth-identity-assertion-authz-grant-04. The MCP extension io.modelcontextprotocol/enterprise-managed-authorization is stable, and MCPG serves it under both MCP authorization revisions it speaks, 2025-11-25 and 2026-07-28. Client ID Metadata Documents are an Internet-Draft too. Okta's Cross App Access is generally available. Expect changes in the ID-JAG claims for multi-tenant IdPs and SAML subjects, in how IdPs advertise support, and in each client vendor's setup. Keep the gateway and its plugins on one release, and run mcpg config check after every upgrade.

An enterprise wants its users' AI clients (Claude, VS Code, an agent it runs) to reach MCP servers with access the enterprise IdP decides: the IdP knows which user, which client and which server, applies its policy, and can stop the access. It does not want every MCP server to run its own consent screen, or every user to paste tokens.

The IdP achieves this by issuing a short-lived signed grant, the ID-JAG, that names the user, the client and the MCP server's authorization server. The client takes the ID-JAG to that authorization server and gets an access token for the MCP server. MCPG ships that authorization server: the embedded authorization server (governance.access.authorization_server). It redeems ID-JAGs, mints access tokens for the gateway alone, and verifies them on every MCP request.

One protocol, several names

The same wire protocol has several names. They matter because each vendor's documentation uses a different one.

NameWho uses itWhat it names
Identity Assertion JWT Authorization Grant (ID-JAG)IETF OAuth working group, draft-ietf-oauth-identity-assertion-authz-grant (-04, 21 May 2026)The specification, and the JWT itself (JOSE typ oauth-id-jag+jwt)
Cross App Access (XAA)The draft's informal name for the pattern; Okta's feature (generally available in Okta release 2026.09.0)The two-hop flow: a token exchange at the IdP, then a jwt-bearer redemption at the resource's authorization server
Enterprise-Managed Authorization (EMA)The MCP extension io.modelcontextprotocol/enterprise-managed-authorization (stable since 17 June 2026; it grew out of SEP-990)The MCP profile of ID-JAG: audience is the resource authorization server's issuer, resource is the MCP server's RFC 9728 resource, and the access token is audience-restricted to that resource
"Enterprise Managed Auth"Anthropic's Claude (Claude, Claude Code and Cowork, per the MCP blog of 18 June 2026)The client-side feature
"enterprise-managed MCP authentication"VS Code (preview setting mcp.enterpriseManagedAuth.idp, VS Code 1.123)The client-side feature
Agent SSOOkta packaging (press release, 24 August 2026; included in core Okta SSO plans)Okta registering AI agents in Universal Directory and issuing ID-JAGs to them through Cross App Access
Okta for AI Agents, Okta Agent GatewaySeparate Okta productsNot needed to interoperate. Okta for AI Agents lifts the SSO-plan quota of 250 ID-JAGs per user, per resource app, per month

Nothing specific to Agent SSO is on the wire. For MCPG, "supporting Agent SSO" means being a Cross App Access resource app: the embedded authorization server redeems the ID-JAGs Okta issues.

MCPG's own terms, used on this page:

  • Embedded authorization server: governance.access.authorization_server. It is the Resource Authorization Server in ID-JAG and EMA terms, and the resource app's authorization server in Okta terms.
  • ID-JAG caller: a caller whose access token an ID-JAG redemption minted.
  • Requesting app (Okta): the MCP client. Resource app (Okta): the app that represents MCPG. Resource connection (Okta): the link from an AI agent to a resource app, which carries the resource indicator, the client ID and the scopes.

The two roles MCPG plays

RoleMCPG isWhat it doesConfigured underLicense
InboundThe MCP server and its authorization serverRedeems ID-JAGs at POST /oauth/token, verifies the tokens it minted on every MCP requestgovernance.access.authorization_serverNone: core. The opt-in DPoP needs oauth.dpop and rich authorization requests need oauth.rich_authorization (Enterprise)
OutboundThe requesting app, an MCP client of an upstream MCP server that Cross App Access protectsExchanges a user's token at the IdP for an ID-JAG for the upstream, redeems it at the upstream's authorization server, calls the upstream as the usermcp.federations[] with the dev.mcpg.credential.oauth-id-jag pluginsecrets.dynamic (Team, Enterprise)
Outbound with the stored sign-inThe same, using the IdP sign-in the gateway keeps for each userAs above, but the subject token is the user's IdP refresh token, so ID-JAG callers can be served tooThe above plus upstream.auth.subject_token and a trusted_idps[].login blocksso.interactive_login (Enterprise) in addition

MCP clients that cannot present an ID-JAG (most command-line clients today) can sign their users in at the same authorization server through the browser; see Interactive sign-in. The two paths give the same user the same principal.

How it works

Hop 1 runs between the client and Okta; MCPG never sees it. The client posts a token exchange (RFC 8693) to Okta's org token endpoint:

text
POST https://acme.okta.com/oauth2/v1/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:id-jag
&audience=https://mcp.acme.example
&resource=https://mcp.acme.example/mcp
&scope=mcp:tools
&subject_token=<the user's ID token or refresh token>
&subject_token_type=urn:ietf:params:oauth:token-type:id_token
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<the client's credential at Okta>

Okta answers with the ID-JAG in access_token, "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", "token_type": "N_A" and "expires_in": 300. An ID-JAG as Okta publishes it:

json
{
  "header": { "typ": "oauth-id-jag+jwt", "alg": "RS256", "kid": "1T4g9ux3…" },
  "payload": {
    "iss": "https://acme.okta.com",
    "sub": "00uzkk8ctx1WtQ8fy1d7",
    "aud": "https://mcp.acme.example",
    "client_id": "claude-ema",
    "resource": "https://mcp.acme.example/mcp",
    "scope": "mcp:tools",
    "email": "user@acme.example",
    "iat": 1781223753,
    "exp": 1781224053,
    "jti": "IDAAG.OmT8mh0I…"
  }
}
  • iss is the Okta org authorization server, with no path and no trailing slash.
  • sub is the Okta user ID.
  • aud is exactly the Issuer URL the Okta admin entered for the resource app: MCPG's authorization_server.issuer.
  • client_id is the AI agent's client ID registered in this app: the client as MCPG knows it, not as Okta knows it.
  • email appears only when the client requested the email scope at sign-in.
  • The draft also defines act, tenant, amr, cnf and authorization_details; MCPG reads each of them (below).

Hop 2 is the request MCPG answers:

text
POST https://mcp.acme.example/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64 of client_id:client_secret>

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<the ID-JAG>
&scope=mcp:tools
&resource=https://mcp.acme.example/mcp

scope and resource are optional. The response:

json
{
  "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImFzLTIwMjYtMDkiLCJ0eXAiOiJhdCtqd3QifQ…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "mcp:tools",
  "resource": "https://mcp.acme.example/mcp"
}

What MCPG sees, and what it does not. In the ID-JAG flow alone, MCPG never sees the user's password, ID token or IdP refresh token, and never calls Okta about the user: it fetches only Okta's discovery document and signing keys. Everything it learns about the user is in the ID-JAG's claims. It never receives the hop-1 request, so an Okta-side refusal (policy, quota, a wrong resource indicator) shows up in the client, not in MCPG's logs. With a login block this changes: MCPG signs users in at Okta and keeps each user's IdP refresh token and ID token, to use at Okta (see the IdP sign-in MCPG keeps).

What MCPG publishes

Protected resource metadata (RFC 9728), at the root /.well-known/oauth-protected-resource and at the path-aware form /.well-known/oauth-protected-resource/mcp:

json
{
  "resource": "https://mcp.acme.example/mcp",
  "authorization_servers": ["https://mcp.acme.example"],
  "scopes_supported": ["mcp:tools"],
  "bearer_methods_supported": ["header"]
}

Without resource_metadata the document answers 404 ({"error":"protected resource metadata not configured",…}): the gateway never guesses its public URL from its bind address. authorization_servers is derived when you leave it empty: the embedded issuer first, then the oidc_oauth issuers, then jwks.issuer. With interactive sign-in only the embedded issuer is listed. A list you set is published as written.

Authorization server metadata (RFC 8414), at /.well-known/oauth-authorization-server on the issuer's origin. For the configuration in Configure MCPG:

json
{
  "issuer": "https://mcp.acme.example",
  "token_endpoint": "https://mcp.acme.example/oauth/token",
  "grant_types_supported": ["urn:ietf:params:oauth:grant-type:jwt-bearer"],
  "authorization_grant_profiles_supported": ["urn:ietf:params:oauth:grant-profile:id-jag"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic"],
  "scopes_supported": ["mcp:tools"],
  "response_types_supported": [],
  "jwks_uri": "https://mcp.acme.example/oauth/jwks"
}
  • grant_types_supported with jwt-bearer is what Claude reads to decide that a server supports Enterprise Managed Auth. authorization_grant_profiles_supported with the id-jag profile is the discovery signal the EMA extension defines.
  • token_endpoint_auth_methods_supported lists the methods of the registered clients. Client ID Metadata Documents add private_key_jwt and none. token_endpoint_auth_signing_alg_values_supported appears whenever private_key_jwt is offered.
  • scopes_supported is allowed_scopes, or [] without it.
  • jwks_uri appears when a signing key is asymmetric. client_id_metadata_document_supported: true appears when metadata documents are on, dpop_signing_alg_values_supported with DPoP, and authorization_details_types_supported with rich authorization requests.
  • The trusted IdPs are never listed.
  • There is no path-inserted variant, because the issuer is a bare origin, and no /.well-known/openid-configuration: MCPG is not an OpenID Provider.

The challenge. With require_authentication on, an unauthenticated MCP request is answered:

text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.acme.example/.well-known/oauth-protected-resource/mcp", scope="mcp:tools"

scope carries resource_metadata.scopes_supported (entries that are not valid scope tokens are left out). A token that does not verify gets Bearer error="invalid_token", resource_metadata="…". An authenticated caller without a scope a tool requires gets 403 with Bearer resource_metadata="…", error="insufficient_scope", scope="…".

The MCP capability. While authorization_server is set, initialize (MCP 2025-11-25) and server/discover (MCP 2026-07-28) answer with "io.modelcontextprotocol/enterprise-managed-authorization": {} under capabilities.extensions, on HTTP only. Clients act on the metadata above, which they read before any MCP result.

What MCPG checks when it redeems an ID-JAG

POST /oauth/token runs these checks in order. The first failure answers 400 with the OAuth error and a description (except where the table says otherwise). Every description below is the exact text MCPG sends.

#CheckError when it failsWhat to fix
1The DPoP proof, while DPoP is oninvalid_dpop_proof, use_dpop_nonceSee DPoP
2grant_type is present and supportedinvalid_request "grant_type is required"; unsupported_grant_type naming the supported grantsThe client sends urn:ietf:params:oauth:grant-type:jwt-bearer
3The client authenticates with exactly one method, the one it is registered withinvalid_request for two methods, or a Basic client_id that differs from the form's; invalid_client for an unknown client, a wrong secret or the wrong method: 401 with WWW-Authenticate: Basic realm="mcpg" after HTTP Basic, else 400The client's clients[] entry, its secret or keys
4The client may redeem ID-JAGsunauthorized_client "this client's grant_types lack urn:ietf:params:oauth:grant-type:jwt-bearer"Add the jwt-bearer grant to a client that has redirect_uris
5assertion is presentinvalid_request "assertion is required"—
6It is a JWTinvalid_grant "assertion is not a well-formed JWT"—
7JOSE typ is oauth-id-jag+jwtinvalid_grant "assertion `typ` must be oauth-id-jag+jwt"The client sent an ID token or an access token instead of the ID-JAG
8The algorithm is asymmetricinvalid_grant "assertion must use an asymmetric signing algorithm"—
9iss names a trusted IdP, compared exactlyinvalid_grant "assertion issuer is not a trusted enterprise IdP" (with a note when only a trailing / differs)trusted_idps[].issuer
10The algorithm is in the IdP's allowed_algsinvalid_grant "assertion alg … is not in allowed_algs for this enterprise IdP"allowed_algs
11The signing key resolves: inline jwks, jwks_uri, or discoveryinvalid_grant "assertion signature key could not be resolved: …"; invalid_grant "the enterprise IdP's signing keys cannot be used: …"; 503 temporarily_unavailable while the IdP cannot be reached and no cached keys remainallowed_hosts, jwks_uri, network egress
12Signature, exp and nbf (with clock_skew_secs leeway); exp, aud, iss, sub, client_id, jti and iat are presentinvalid_grant "assertion signature is invalid", "assertion has expired", "assertion is not valid yet (nbf)", "assertion carries no `…` claim", "assertion claims are not valid: …", or "assertion aud … does not name this authorization server: the enterprise IdP must set aud to exactly `https://mcp.acme.example\`"Clocks; the Issuer URL at the IdP
13aud is exactly the issuer, as a string or a one-element arrayinvalid_grant "assertion aud … must name only this authorization server, …"—
14iat is not in the future, and exp − iat ≤ max_assertion_lifetime_secsinvalid_grant "assertion iat is in the future", "assertion lifetime (exp - iat) of … s exceeds …"max_assertion_lifetime_secs
15A cnf claim needs DPoP, and only a jkt is acceptedinvalid_grant "proof of possession required: …"Turn on DPoP
16authorization_details are validinvalid_grant (see rich authorization requests)—
17The client_id claim names the authenticated client; the IdP's allowed_clients and required_tenant admit itinvalid_grant "assertion client_id does not match the authenticated client", "this enterprise IdP may not issue assertions for the authenticated client", "assertion carries no tenant claim, …", "assertion tenant is not the tenant …"The client ID in the Okta resource connection; allowed_clients
18The subject claim is present; a malformed act is refusedinvalid_grantclaim_mappings.subject_claim
19resource: a requested one must be this server's and within the claim; else the first claim value that is this server's; else authorization_server.resourceinvalid_target "the requested resource does not identify this MCP server", "the requested resource is not one the assertion's resource claim names", "assertion resource does not identify this MCP server"The resource indicator at the IdP; additional_resources
20DPoP binding of the tokeninvalid_grant (see DPoP)—
21With rich authorization requests on, the request's authorization_details only narrows what the grant holdsinvalid_authorization_details "the grant carries no authorization_details for the request to narrow", "the requested authorization_details exceed what was granted"See rich authorization requests
22Scope: the ID-JAG scope ∩ allowed_scopes, narrowed by the requested scopes this server knows (allowed_scopes, resource_metadata.scopes_supported or the ID-JAG's). Requested scopes it does not know, such as openid profile, are ignored; a request that names none it knows is treated as one without scopeinvalid_scope when every requested scope this server knows is outside the grant, or when require_scope leaves nothingallowed_scopes, the scopes of the resource connection
23Single use: the (iss, jti) pair was never redeemedinvalid_grant "assertion has already been redeemed"; 503 temporarily_unavailable "replay protection is unavailable; retry shortly"A client that retries the same ID-JAG after a success

Single use is recorded last: a request refused by any earlier check does not spend the ID-JAG, so the client can retry it (for example with another scope) until it expires. Resource comparisons ignore a trailing /; issuer comparisons do not.

Refused requests spend a per-IP budget: rate_limit_per_min (120 by default) refused requests per client IP per minute across /oauth/token, /oauth/revoke and /oauth/register. A successful redemption gives back what it took. Over the budget, the answer is 429 with Retry-After and the OAuth error temporarily_unavailable.

What MCPG issues

A successful redemption returns access_token, token_type (Bearer, or DPoP for a key-bound token), expires_in, scope (when not empty), resource (always, as the draft requires) and, with rich authorization requests, authorization_details. Responses carry Cache-Control: no-store.

The access token is a JWT with JOSE typ at+jwt and the signing key's kid:

ClaimValue
issauthorization_server.issuer
subThe user, read by claim_mappings.subject_claim (default sub)
audThe one resource the token was minted for
client_idThe authenticated client
jti, iat, expUnique ID, issue time, expiry after access_token_ttl_secs (default 3600)
scopeThe granted scopes, space-separated
idpThe ID-JAG's iss
email, tenant, amr, actCopied from the ID-JAG when present
groups, roles, mcpg_attributesWhat claim_mappings read from the ID-JAG
cnf.jktThe DPoP key thumbprint, for a key-bound token
authorization_detailsThe granted details, with rich authorization requests

No refresh token. An ID-JAG redemption never returns one. The IdP must stay able to end access, and a refresh token would outlive its decision. When the access token expires, the client asks the IdP for a new ID-JAG: the IdP applies its policy again at that moment.

Where the token works. Only at this gateway: the MCP endpoint and every other endpoint that takes the embedded server's tokens. A token whose iss is the embedded issuer must verify there (by its kid, with that key's algorithm, and with aud one of the gateway's resources) and never falls through to another verifier. The IdP and the client must still be trusted when the token is used: removing the IdP or the client from the configuration, or narrowing allowed_clients or required_tenant, refuses tokens already issued.

Never forwarded. A gateway-minted token never leaves the gateway. It is not passed to identity plugins, pass_through federations refuse the call before sending anything, and oauth_impersonation refuses to exchange it (see Call Cross App Access upstreams). An ID-JAG presented to /mcp as a bearer token is refused with 401 invalid_token: it is a grant for a token endpoint, not an access token.

Signing keys. The first entry of signing_keys signs every new token; every entry verifies tokens that carry its kid. The public halves of ES256, EdDSA and RS256 keys are served at GET /oauth/jwks (Cache-Control: public, max-age=300), so another resource server can verify the tokens. An HS256 secret is never published; with only HS256 keys /oauth/jwks answers 404.

How the identity reaches policy

An ID-JAG caller is a verified caller. Policy (CEL, the trust floor, scopes, policy engines, quotas and audit) sees:

FieldValue
identity.subject_idThe subject claim (default sub: the Okta user ID)
identity.issuerThe IdP issuer; {issuer}#{tenant} when the ID-JAG carries tenant and required_tenant is unset; or principal_issuer
identity.auth_providerema, or oidc_oauth:<principal_issuer>
identity.rolesMapped roles, then client_roles[client_id]
identity.groupsMapped groups
identity.scopesThe granted scopes
identity.attributes["client_id"]The client the token was minted for
identity.attributes["idp"], ["token_issuer"]The IdP issuer; the embedded issuer
identity.attributes["grant_type"]id_jag (or authorization_code after interactive sign-in)
identity.attributes["email"], ["tenant"], ["amr"], ["actor"]From the ID-JAG when present (amr space-separated; actor from act, never the subject)
identity.attributes["dpop_jkt"]The key thumbprint of a DPoP-bound token
identity.authorization_detailsA list of maps, with rich authorization requests
Mapped attributesWhat claim_mappings.attribute_claim_mappings names

A tool that only the Claude client of your Okta org may call, and only with the granted scope:

yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
      scopes_supported: ["mcp:tools"]
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
          allowed_hosts: ["acme.okta.com"]
          allowed_algs: ["RS256"]
      clients:
        - client_id: "claude-ema"
          client_secret: "${env.CLAUDE_EMA_SECRET}"
      client_roles:
        "claude-ema": ["ai-agent"]      # every user of this client gets the role
  policy:
    tool_access:
      # Gateway-wide: callers who redeemed an ID-JAG at this gateway only through
      # the clients you name. Every other caller is unaffected.
      cel_allow_if: '!("token_issuer" in identity.attributes) || identity.attributes["grant_type"] != "id_jag" || identity.attributes["client_id"] in ["claude-ema"]'

mcp:
  capabilities:
    tools:
      - name: crm.lookup
        description: Look up a customer record.
        governance:
          minimum_trust: verified          # anonymous callers never see it
          required_scopes: ["mcp:tools"]   # 403 insufficient_scope without it
          allow_if: '"ai-agent" in identity.roles'
        backend: { kind: http, url: "https://crm.internal.example/lookup", method: post }
  • Key the rule on the token, not on identity.auth_provider. Only tokens this gateway minted carry token_issuer: the embedded server's claim mappings cannot set it, and no other identity source sets it unless you map a claim to that name. The rule so reaches exactly the callers whose token this gateway minted. grant_type is id_jag for an ID-JAG and authorization_code for an interactive sign-in; leave that clause out to limit both. identity.auth_provider is not a safe key: once the IdP sets principal_issuer, its callers carry oidc_oauth:<principal_issuer>, and a rule on auth_provider != "ema" lets every client through. The in test comes first because reading an attribute a caller does not carry is an error, which denies.
  • Key policy on the subject or the client, not on email. An email address can pass to another person; the IdP's subject ID does not.
  • required_scopes answers a caller short of a scope with the 403 insufficient_scope challenge naming the scopes. The client can then ask the IdP for a new ID-JAG with more scope; the resource connection decides whether it gets it.
  • Okta ID-JAGs carry no groups. Key policy on identity.roles from client_roles, on identity.attributes["client_id"], or on claims you copy with attribute_claim_mappings. For an IdP that sends groups, map them:
yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://idp.acme.example"
          claim_mappings:
            subject_claim: sub
            group_claim_paths: ["groups"]
            role_claim_paths: ["realm_access.roles"]
            attribute_claim_mappings:
              department: department
      clients:
        - client_id: "acme-agent"
          client_secret: "${env.ACME_AGENT_SECRET}"
  • Share principals with SSO. When the same users also reach the gateway with SSO tokens that oidc_oauth verifies, principal_issuer makes an ID-JAG caller the same principal (sessions, tasks, idempotency and quotas are shared). Both sides must name the user with the same subject. See Combine with SSO.

Supported standards

SpecificationRole in MCPGStatus
RFC 6749 (OAuth 2.0)Token endpoint, client authentication (exactly one method), scope narrowing, no-store responses, the error codes aboveImplemented
RFC 6750 (Bearer tokens)Authorization: Bearer header only; WWW-Authenticate with resource_metadata, invalid_token, insufficient_scope and scopeImplemented
RFC 7009 (Token revocation)POST /oauth/revoke with interactive sign-in only: revokes grants, and ID-JAG-minted access tokens by jti until they expire. MCPG also revokes superseded IdP refresh tokens at the IdPPartial
RFC 7515, 7517, 7518, 7519 (JWS, JWK, JWA, JWT)Verifies ID-JAGs, client assertions, login ID tokens and DPoP proofs with algorithm allow-lists and no HMAC for public keys; publishes asymmetric signing keysImplemented
RFC 7521, RFC 7523 (assertion framework, JWT profile)The jwt-bearer grant that redeems ID-JAGs; private_key_jwt client authentication (aud the issuer as its only value, optionally the token endpoint URL; jti once; exp at most 5 minutes ahead). Outbound: hop 2 of oauth-id-jagImplemented
RFC 7591 (Dynamic client registration)POST /oauth/register for interactive sign-in; registered clients are public and cannot redeem ID-JAGs. RFC 7592 (client management) is not implementedOpt-in
RFC 7636 (PKCE)S256 only at /oauth/authorize; also used toward the login IdPImplemented
RFC 7638 (JWK thumbprint)DPoP jkt binding; the default kid of asymmetric signing keysImplemented
RFC 7800 (cnf)cnf.jkt on DPoP-bound tokens; a cnf-bound ID-JAG needs DPoPOpt-in (Enterprise)
RFC 8252 (Native apps)Loopback redirect URIs match any portImplemented
RFC 8414 (AS metadata)Served at the issuer root; read from IdPs without OpenID discoveryImplemented
RFC 8693 (Token exchange)Outbound hop 1 (oauth-id-jag) and oauth-token-exchange. Inbound, an ID-JAG's act becomes identity.attributes["actor"]Implemented (outbound)
RFC 8707 (Resource indicators)resource at the token and authorize endpoints; tokens audience-restricted to one resource; invalid_targetImplemented
RFC 8725 (JWT BCP)Explicit typing checked (oauth-id-jag+jwt, at+jwt, dpop+jwt, JWT or client-authentication+jwt); algorithm allow-listsImplemented
RFC 9068 (JWT access tokens)typ at+jwt and its claim names, plus MCPG claims (idp, mcpg_attributes, gid, gty)Partial
RFC 9207 (Issuer identification)iss in every authorization response; checked on the login IdP's responsesImplemented (interactive sign-in)
RFC 9396 (Rich authorization requests)authorization_details from the ID-JAG, the authorize and the token request; typed validation; policy reads themOpt-in (Enterprise)
RFC 9449 (DPoP)Proofs at the token endpoint and the resource, nonces, cnf-bound ID-JAGs, required modeOpt-in (Enterprise)
RFC 9700 (OAuth security BCP)PKCE, exact redirect matching, refresh rotation with reuse detection, no CORS on browser endpoints, per-IP limitsApplied
RFC 9728 (Protected resource metadata)Root and path-aware documents; the resource chosen by request host; resource_metadata in every challengeImplemented
OpenID Connect Core 1.0Relying party of the login IdP: ID token checks, no UserInfo. MCPG is not an OpenID ProviderImplemented (as RP)
OpenID Connect Discovery 1.0Reads IdP discovery (issuer must match exactly); serves noneImplemented (consumer)
OAuth 2.1 (draft-ietf-oauth-v2-1)PKCE S256 only, exact redirect URIs, no implicit or password grants, refresh rotationPartial (AS profile)
OAuth Client ID Metadata Document (draft-ietf-oauth-client-id-metadata-document)URL client_ids fetched from allowed hosts; none or private_key_jwt onlyImplemented
ID-JAG (draft-ietf-oauth-identity-assertion-authz-grant-04)Resource authorization server (all checks above; resource in the response; single use by default; tenant); requesting app through oauth-id-jag. Not covered: sub_id, aud_tenant, aud_sub. MCPG never issues ID-JAGsImplemented
MCP authorization 2025-11-25 and 2026-07-28Protected resource metadata, AS metadata, 401 and 403 challenges with scope, audience validation, no token passthrough, Client ID Metadata Documents, optional DCR, RFC 9207Implemented
MCP extension io.modelcontextprotocol/enterprise-managed-authorizationResource authorization server; the id-jag grant profile; the capability in initialize and server/discover over HTTPImplemented
MCP URL-mode elicitationA link to /oauth/connect when a federated call needs the caller's stored sign-inImplemented (one purpose)
draft-ietf-oauth-identity-chainingThe pattern ID-JAG profiles; no refresh token for ID-JAG grantsInformational
OpenID Connect Enterprise Extensions (tenant)tenant namespaces the principal or is pinned by required_tenant; aud_tenant is not supportedPartial
RFC 7662 (Introspection)Not offered by the embedded server; oidc_oauth can call an external oneNot implemented
RFC 8705 (mTLS-bound tokens), RFC 9470 (Step-up), RFC 9493 (sub_id)—Not implemented

Configure MCPG

Prerequisites

  • A public https origin that reaches the gateway, such as https://mcp.acme.example. Clients fetch the metadata and post to /oauth/token there.
  • The issuer is a bare origin, scheme://host[:port], and an exact string. It becomes the ID-JAG aud, and Okta cannot change it without resetting the connection. MCPG serves the metadata and the token endpoint only at the root of its origin, so an issuer with a path is refused. A trailing / is allowed, but then it is published and compared with the /; write it the same way at the IdP.
  • resource_metadata: required with authorization_server, because it is how clients find the authorization server.
  • require_authentication: true: EMA clients start only after a 401. Without it, an anonymous initialize succeeds and they never ask the IdP.
  • The same signing keys on every replica, and in a cluster, a coordinator with a key-value store so an ID-JAG redeemed on one replica is refused on every other.

Minimal configuration

yaml
gateway:
  server:
    allowed_origins: ["https://mcp.acme.example"]
    trust_proxy_ip: true                     # behind your load balancer
    canonical_url: "https://mcp.acme.example"

governance:
  access:
    require_authentication: true             # anonymous /mcp → 401 with the metadata URL
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"   # Okta: Resource indicator
      scopes_supported: ["mcp:tools"]
    authorization_server:
      issuer: "https://mcp.acme.example"         # Okta: Issuer URL, exactly
      signing_keys:
        - alg: ES256
          kid: "as-2026-09"
          private_key: "${env.MCPG_AS_SIGNING_KEY}"   # PKCS#8 PEM
      allowed_scopes: ["mcp:tools"]
      trusted_idps:
        - issuer: "https://acme.okta.com"          # the Okta org authorization server
          allowed_hosts: ["acme.okta.com"]
          allowed_algs: ["RS256"]
      clients:
        - client_id: "claude-ema"               # Okta: AI agent's client ID registered in this app
          client_secret: "${env.CLAUDE_EMA_SECRET}"
          token_endpoint_auth_method: client_secret_basic

mcp:
  capabilities:
    tools:
      - name: crm.lookup
        description: Look up a customer record.
        governance:
          minimum_trust: verified
          required_scopes: ["mcp:tools"]
        backend: { kind: http, url: "https://crm.internal.example/lookup", method: post }

The embedded authorization server is core: it needs no plugin and no license. Interactive sign-in, the opt-in DPoP and rich authorization requests need an Enterprise license. The enterprise-managed-auth.yaml deployment example under examples/deployments/ in the distribution adds a NATS cluster, audit and client_roles to this configuration.

Key reference

governance.access.authorization_server:

KeyDefaultMeaning
issuerrequiredBare https origin; the ID-JAG aud and the iss of minted tokens. http:// draws a warning off loopback
resourceresource_metadata.resourceThe audience when neither the request nor the ID-JAG names a resource
signing_keys[]—{kid, alg, secret | private_key}. alg is HS256 (secret, ≥ 32 bytes), ES256, EdDSA or RS256 (≥ 2048 bits) with a PKCS#8 PEM private_key (RSA may be PKCS#1). kid defaults to the RFC 7638 thumbprint of an asymmetric key, and to an ID derived from the secret of an HS256 key
signing_secret—Shorthand for one HS256 key. Set exactly one of signing_secret and signing_keys
access_token_ttl_secs36001–86400; above 3600 draws a warning
clock_skew_secs60At most 300
max_assertion_lifetime_secs6001–3600; the longest exp − iat accepted
enforce_single_usetrueRefuse a second redemption of the same (iss, jti)
allowed_scopesunsetScopes that may be granted; unset lets the IdP's scopes through
require_scopefalseRefuse a redemption that grants no scope
trusted_idps[]requiredAt least one IdP
clients[]—Registered clients; may be empty when metadata documents admit clients
client_roles{}client_id → roles added to every caller of that client
client_id_metadata_documentsoffSee below
rate_limit_per_min120Refused token requests per client IP per minute; 0 disables
dpopoffSee DPoP. enabled: true needs the oauth.dpop license feature
authorization_detailsoffSee rich authorization requests. A type in types needs the oauth.rich_authorization license feature
interactive—Settings of interactive sign-in

trusted_idps[]:

KeyDefaultMeaning
issuerrequiredCompared exactly with the ID-JAG iss and with the discovery document's issuer, a trailing / included
jwks_uri / jwksdiscoveryA JWKS URL, or the keys inline (an object or a JSON string; oct keys are refused). Mutually exclusive
allowed_hosts[] (any public host)Hosts discovery and key fetches may reach, exact or parent domain
allow_private_networkfalseDevelopment only: http:// and private addresses
allowed_algsRS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 EdDSAHMAC is refused. List only the IdP's algorithm (Okta: RS256)
allowed_clients[] (every client)Clients this IdP may issue ID-JAGs for
required_tenantunsetThe tenant claim every ID-JAG must carry
claim_mappingssubject_claim: subsubject_claim, group_claim_paths, role_claim_paths, attribute_claim_mappings
principal_issuerunsetJoin this IdP's users to the principals of the oidc_oauth provider with this issuer
loginunsetTurns on interactive sign-in through this IdP (one entry at most)

clients[]:

KeyDefaultMeaning
client_idrequiredWhat the IdP puts in the ID-JAG client_id; may be a metadata document URL. May not start with mcpgdcr_
client_secret—For client_secret_basic and client_secret_post
token_endpoint_auth_methodderivedBoth secret methods with a secret, private_key_jwt with keys, else none
jwks_uri / jwks—The client's public keys for private_key_jwt
accept_token_endpoint_audiencefalseAlso accept aud = {issuer}/oauth/token in the client assertion
allow_private_networkfalseDevelopment only, for jwks_uri
grant_typesderived[jwt-bearer] without redirect_uris; [authorization_code, refresh_token] with them
redirect_uris, client_name, consent, dpop_bound_access_tokens—Interactive sign-in and DPoP

client_id_metadata_documents and resource_metadata:

KeyDefaultMeaning
client_id_metadata_documents.enabledon when a clients[].client_id is an https:// URL or allowed_hosts is setAdvertise and resolve metadata documents
client_id_metadata_documents.allowed_hosts[]Hosts whose document URLs are accepted without a clients[] entry
client_id_metadata_documents.allow_private_networkfalseDevelopment only
client_id_metadata_documents.redirect_uri_policysame_hostInteractive sign-in only
resource_metadata.resourcerequiredAbsolute http(s), no fragment, no wildcard; loopback only with allow_loopback_resource
resource_metadata.additional_resources[]Other hostnames the gateway serves
resource_metadata.authorization_serversderivedLeave empty unless you must list others; the embedded issuer must be in it
resource_metadata.scopes_supported[]Also sent as scope on the 401
resource_metadata.bearer_methods_supported["header"]—

The configuration reference has every key.

Secrets

Give keys and secrets as ${env.NAME}, or as ${secret.NAME} with gateway.secrets.dir set: ${secret.NAME} reads the file NAME in that directory and hot-reloads when it changes, which suits multi-line PEM keys.

The gateway resolves every placeholder under authorization_server when it starts and at every reload, before it checks the keys. A variable that is not set, a secret file that cannot be read, or a value that fails a check (an HS256 secret under 32 bytes, a PEM that does not load) stops the boot with an error that names the reference and never the value. The configuration the gateway reports and hashes keeps the placeholders. mcpg config check does not resolve them: it checks the rest of the block and refuses a ${secret.…} reference without gateway.secrets.dir.

yaml
gateway:
  secrets:
    dir: "/etc/mcpg/secrets"
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_keys:
        - alg: ES256
          private_key: "${secret.MCPG_AS_SIGNING_KEY}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
      clients:
        - client_id: "claude-ema"
          client_secret: "${secret.CLAUDE_EMA_SECRET}"

Clients

The client that redeems an ID-JAG must authenticate here as the client the ID-JAG names in client_id. Three kinds of client work:

  • Confidential, with a secret. client_secret with client_secret_basic (HTTP Basic) or client_secret_post (form fields). Okta's examples use Basic.
  • Confidential, with a key (private_key_jwt). The client signs a client_assertion with iss = sub = its client_id, aud = the issuer as the only value, exp at most 5 minutes ahead and a fresh jti. Clients written for Okta put the token endpoint URL in aud; accept that with accept_token_endpoint_audience: true.
yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
      clients:
        - client_id: "acme-agent"
          token_endpoint_auth_method: private_key_jwt
          jwks_uri: "https://agent.acme.example/.well-known/jwks.json"
          accept_token_endpoint_audience: true
  • Identified by a Client ID Metadata Document. The client_id is an https:// URL of a JSON document that describes the client; there is no registration. List the host under client_id_metadata_documents.allowed_hosts (an exact host or a parent domain). MCPG fetches the document without redirects and up to 5 KiB, requires its client_id to equal the URL exactly (in canonical form), and caches it for its max-age (60 seconds to 24 hours, 5 minutes by default, and up to an hour stale while the host is down). The document's token_endpoint_auth_method must be none or private_key_jwt (with jwks, or a jwks_uri on an allowed host); a shared secret is refused. A clients[] entry with the same URL wins, and its document is never fetched.
yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
      client_id_metadata_documents:
        allowed_hosts: ["claude.ai"]

Claude chooses its metadata document only when the metadata advertises client_id_metadata_document_supported: true and none among the token endpoint methods; it then authenticates as a public client. The ID-JAG draft advises against public clients for this grant, because anyone who obtains an ID-JAG could redeem it. The IdP's policy decides which client may receive an ID-JAG; keep allowed_clients on each IdP to the clients you expect.

Which clients redeem ID-JAGs. A clients[] entry without redirect_uris gets the jwt-bearer grant by default. One with redirect_uris gets [authorization_code, refresh_token] and redeems no ID-JAG unless its grant_types also lists urn:ietf:params:oauth:grant-type:jwt-bearer. Dynamically registered clients never redeem ID-JAGs: the IdP stamps a fixed client_id, which a registration cannot match.

Several IdPs, multi-tenant IdPs, unreachable IdPs

  • Several IdPs. List each under trusted_idps. Each IdP's users are separate principals, namespaced by its issuer, because a sub is unique only within its issuer.
  • Multi-tenant IdPs. Pin one tenant with required_tenant. Unset, a tenant claim joins the principal namespace (identity.issuer becomes {issuer}#{tenant}).
  • An IdP the gateway cannot reach. Give its keys inline with jwks. No discovery or key fetch is made, and a key rotation at the IdP needs a config change.
yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
          allowed_hosts: ["acme.okta.com"]
          allowed_algs: ["RS256"]
        - issuer: "https://idp.partner.example"
          jwks: "${env.PARTNER_IDP_JWKS}"      # the IdP's JWK Set as JSON
          allowed_algs: ["ES256"]
          required_tenant: "acme"
          allowed_clients: ["partner-agent"]
      clients:
        - client_id: "claude-ema"
          client_secret: "${env.CLAUDE_EMA_SECRET}"
        - client_id: "partner-agent"
          client_secret: "${env.PARTNER_AGENT_SECRET}"

Custom domains

A gateway served on several hostnames lists the others under resource_metadata.additional_resources. The resource metadata and the challenge name the identifier whose host matches the request's host, and a token is minted for whichever of them the request or the ID-JAG names. One issuer serves all of them: keep it on the canonical origin. Behind a proxy, set gateway.server.trust_proxy_ip so the first X-Forwarded-Host selects the resource (and the first X-Forwarded-For is the client IP). canonical_url redirects (308) every other host to one origin instead, except the health, readiness, /runtime and metrics paths.

yaml
gateway:
  server:
    trust_proxy_ip: true
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
      additional_resources:
        - "https://mcp.acme-eu.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
      clients:
        - client_id: "claude-ema"
          client_secret: "${env.CLAUDE_EMA_SECRET}"

Check it

bash
mcpg config check --deny-warnings gateway.yaml

A valid configuration prints ✓ gateway.yaml: valid (…). Warnings go to stderr as warning: …, and --deny-warnings turns them into exit code 1 (0 is valid, 1 invalid, 2 a usage or file error). The check reads the files only: MCPG_* environment overrides do not apply, and ${env.…} values stay unresolved. It warns when:

  • an explicit resource_metadata.authorization_servers list lacks the exact issuer;
  • the issuer is http:// off loopback, or on an origin that is not one of the resource origins;
  • authorization_server.resource is not an advertised resource;
  • require_authentication is false;
  • metadata documents are on but admit no client;
  • a principal_issuer is not the issuer of an oidc_oauth provider;
  • access_token_ttl_secs is above 3600.

It does not check plugin config: blocks, whether the plugins a configuration needs are declared, or the license: those are checked at boot.

Set up Okta

The steps follow Okta's documentation and Admin Console as of September 2026 (Cross App Access, AI agent to app). Okta moves menu items from time to time; the field names are what to look for.

Before you start.

  • Cross App Access is available to orgs with the Single Sign-On product.
  • Only the org authorization server (https://{org}.okta.com, token endpoint /oauth2/v1/token) issues ID-JAGs. Custom authorization servers (/oauth2/default and /oauth2/{id}) do not: a client that uses one gets an error that requested_token_type is unsupported.
  • SSO plans allow 250 ID-JAGs per user, per resource app, per month.

Three objects in Okta. Each has its own identifier. Mixing them up is the most common setup error.

Okta objectWhat it stands forIts identifier
Resource appMCPG as the server users reach. Okta issues ID-JAGs for itIts Issuer URL: MCPG's authorization_server.issuer
AI agent, with its linked appThe MCP client (Claude, VS Code, your own agent). It signs the user in at Okta and performs hop 1An Okta client ID, which only the client and Okta use
Resource connectionOn the AI agent: the resource app it may reach, the resource, the scopes, and which client the agent is at MCPGAI agent's client ID registered in this app: a clients[].client_id at MCPG

The resource app is not the OIDC app that MCPG signs users in with (interactive sign-in) or calls other apps with (outbound). Create it as an app of its own: it only names MCPG as a resource, and MCPG holds no credential of it.

Step 1: decide the MCPG values. Okta compares each of them as an exact string, so fix them before you open the console.

ValueExampleMCPG key
Issuerhttps://mcp.acme.exampleauthorization_server.issuer
Resourcehttps://mcp.acme.example/mcpresource_metadata.resource
Scopesmcp:toolsallowed_scopes and resource_metadata.scopes_supported
Each client's ID at MCPGclaude-emaauthorization_server.clients[].client_id, or a metadata document URL

You choose the client ID at MCPG. It is not an Okta client ID. Step 5 shows which one each client uses.

Step 2: create the resource app.

  1. In the Admin Console, go to Applications > Applications and choose Create App Integration.
  2. Choose OIDC - OpenID Connect and Web Application, then Next.
  3. Name it, for example MCPG, and keep the Authorization Code grant. MCPG never signs anyone in through this app, so a sign-in redirect URI that Okta asks for is never used: https://mcp.acme.example/ will do.
  4. Under Assignments, choose the users or groups who may reach MCPG, then Save.
  5. On the app's Resource Server tab, next to Cross App Access (XAA), choose Edit, then Enable, and set:
    • Issuer URL: the issuer from step 1, exactly (https://mcp.acme.example). It becomes the ID-JAG aud.
    • Audience/tenant ID: leave it empty. MCPG does not read a tenant audience (aud_tenant).
  6. Save.

Step 3: register the AI agent. For Claude or VS Code, follow the vendor's instructions for Okta: they say how the vendor's app authenticates at Okta. For your own agent:

  1. Go to Directory > AI agents and choose Register AI agent > Register manually.
  2. Under User access, choose Create a new OIDC app linked to this AI agent. The agent and its linked app share one client ID and one credential. You can select an existing custom OIDC app instead, but only while you register the agent.
  3. Choose how the agent authenticates at Okta: Client secret, or Public/private key. Client ID only makes it a public client.
  4. The agent gets the user's ID token by signing the user in with the linked app: allow the Authorization Code grant on it (and Refresh Token, to exchange refresh tokens), and add the agent's sign-in redirect URI.
  5. Assign users or groups to the linked app, then Activate the agent.

Step 4: add the resource connection. On the AI agent, choose Resource connections > Add resource connection, choose the resource app of step 2, Enable it, and set:

  • Resource indicator: the resource from step 1, exactly (https://mcp.acme.example/mcp). A client that sends another resource gets invalid_target ("Resource mismatch") from Okta.
  • AI agent's client ID registered in this app: the client's ID at MCPG from step 1 (claude-ema), or its metadata document URL (step 5). Okta puts it in the ID-JAG client_id, and MCPG requires it to name the client that authenticates at /oauth/token.
  • Scopes the agent may request (mcp:tools), or Allow any scope.

Step 5: register the client at MCPG, and trust Okta. Each client authenticates at MCPG's token endpoint as the client its resource connection names:

ClientAt MCPGAI agent's client ID registered in this app
Claude, with a credential you issueA clients[] entry with a secret you create. Give Claude the client ID and secret where Anthropic's Enterprise Managed Auth documentation saysclaude-ema
Claude, by its metadata documentclient_id_metadata_documents.allowed_hosts: ["claude.ai"]. Claude then authenticates as a public clienthttps://claude.ai/oauth/mcp-oauth-client-metadata
VS CodeFollow Microsoft's instructions. VS Code publishes the metadata document https://vscode.dev/oauth/client-metadata.json, which allowed_hosts: ["vscode.dev"] admitsWhat Microsoft's instructions name
Your own agentA clients[] entry with a secret, or with keys for private_key_jwtIts client_id at MCPG

The metadata document URLs are the ones the vendors publish as of September 2026. Check them against the vendor's documentation before you rely on one.

yaml
gateway:
  secrets:
    dir: "/etc/mcpg/secrets"                   # holds the file CLAUDE_EMA_SECRET
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
      scopes_supported: ["mcp:tools"]
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      allowed_scopes: ["mcp:tools"]
      trusted_idps:
        - issuer: "https://acme.okta.com"      # no trailing slash, no /oauth2/…
          allowed_hosts: ["acme.okta.com"]
          allowed_algs: ["RS256"]
          allowed_clients: ["claude-ema"]
      clients:
        - client_id: "claude-ema"              # the resource connection's client ID
          client_secret: "${secret.CLAUDE_EMA_SECRET}"
          token_endpoint_auth_method: client_secret_basic

What Okta's ID-JAGs contain. sub is the Okta user ID (00u…), the lifetime is 5 minutes, email appears only when the client asked for the email scope at sign-in, scope is the requested scopes limited by the resource connection, and there is no group claim. Key policy on the client and the scopes, or copy other claims with attribute_claim_mappings.

Quota arithmetic. One ID-JAG buys one access token of access_token_ttl_secs. A user who works 8 hours a day for 21 days with the default one-hour token spends about 168 ID-JAGs a month on one resource app: inside 250. With 15-minute tokens the same user needs about 672. Okta counts an ID-JAG when it issues one. A client can retry a refused redemption with the same ID-JAG, because MCPG records single use only after every other check has passed.

Okta admin fieldMCPG key
Resource app > Resource Server > Cross App Access > Issuer URLauthorization_server.issuer (exact string)
Resource app > Resource Server > Cross App Access > Audience/tenant IDNone: leave it empty
Resource app > AssignmentsNone: the users and groups who may reach MCPG through Okta
AI agent > Resource connection > Resource indicatorresource_metadata.resource (exact string)
AI agent > Resource connection > AI agent's client ID registered in this appauthorization_server.clients[].client_id, or a metadata document URL
AI agent > Resource connection > Scopesallowed_scopes and resource_metadata.scopes_supported
Okta domaintrusted_idps[].issuer = https://{org}.okta.com

Connect a client and verify

  • Claude. Claude looks for jwt-bearer in grant_types_supported and starts from the 401. It authenticates with an Anthropic-held credential or with its metadata document as a public client. It sends resource only when the IdP supports forwarding it. Without it, MCPG takes the resource from the ID-JAG's resource claim, else uses authorization_server.resource.
  • VS Code. Enterprise-managed MCP authentication is a preview (mcp.enterpriseManagedAuth.idp); follow Microsoft's instructions.
  • Your own client or an MCP SDK. Implement the two hops above. Discover the issuer from the resource metadata, use it as audience at the IdP, and send the resource metadata's resource.

mcpg inspector auth https://mcp.acme.example/mcp shows what a client discovers: the challenge, the discovery walk, the authorization server issuer (the hop-1 audience), and enterprise_managed_authorization: advertised when the server lists the id-jag grant profile.

Test with your own agent

Test the setup before a real client is connected, with an AI agent of your own and the MCPG inspector. Give the test its own AI agent, resource connection and MCPG client: Okta exchanges an ID token only for the app it was issued to, and a test entry can be removed without touching the clients your users rely on.

  1. In Okta, register a test AI agent as in step 3, with Client secret, the Authorization Code grant and the sign-in redirect URI http://127.0.0.1:8765/callback. Keep its client ID and secret as TEST_AGENT_ID and TEST_AGENT_SECRET, and assign yourself to its linked app.
  2. Add a resource connection from the test agent to the resource app, with the resource indicator and scopes of step 4 and mcpg-test as AI agent's client ID registered in this app.
  3. At MCPG, register mcpg-test beside the real clients, with a secret of its own, and add it to allowed_clients where the IdP entry sets them:
yaml
gateway:
  secrets:
    dir: "/etc/mcpg/secrets"
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
      scopes_supported: ["mcp:tools"]
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      allowed_scopes: ["mcp:tools"]
      trusted_idps:
        - issuer: "https://acme.okta.com"
          allowed_hosts: ["acme.okta.com"]
          allowed_algs: ["RS256"]
          allowed_clients: ["claude-ema", "mcpg-test"]
      clients:
        - client_id: "claude-ema"
          client_secret: "${secret.CLAUDE_EMA_SECRET}"
        - client_id: "mcpg-test"                 # remove after the test
          client_secret: "${secret.MCPG_TEST_SECRET}"
  1. Get an ID token for the test agent: sign yourself in with the authorization code grant and PKCE.
bash
VERIFIER="$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n')"
CHALLENGE="$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')"
echo "https://acme.okta.com/oauth2/v1/authorize?client_id=$TEST_AGENT_ID&response_type=code&scope=openid%20profile%20email&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2Fcallback&state=test&code_challenge=$CHALLENGE&code_challenge_method=S256"

Open the URL and sign in. Nothing listens on port 8765, so the browser shows an error page at http://127.0.0.1:8765/callback?code=…&state=test. Copy the code from its address bar into CODE and redeem it at once, because codes are short-lived:

bash
curl -s https://acme.okta.com/oauth2/v1/token \
  -u "$TEST_AGENT_ID:$TEST_AGENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=http://127.0.0.1:8765/callback \
  -d code_verifier="$VERIFIER" | jq -r .id_token > id_token.jwt
  1. Run both hops with the inspector. It exchanges the ID token at Okta for an ID-JAG addressed to MCPG, then redeems the ID-JAG at MCPG as mcpg-test:
bash
export MCPG_INSPECTOR_IDP_CLIENT_SECRET="$TEST_AGENT_SECRET"   # the test agent at Okta
export MCPG_INSPECTOR_CLIENT_SECRET="$MCPG_TEST_SECRET"        # mcpg-test at MCPG
TOKEN="$(mcpg inspector login https://mcp.acme.example/mcp \
  --client-id mcpg-test \
  --idp-token-url https://acme.okta.com/oauth2/v1/token \
  --idp-client-id "$TEST_AGENT_ID" \
  --subject-token-file id_token.jwt)"
mcpg inspector list https://mcp.acme.example/mcp tools --bearer "$TOKEN"

Before it redeems, the inspector reads the ID-JAG's claims and warns about what MCPG will refuse: an aud that is not the issuer, a client_id that is not mcpg-test, a resource that is not this server. --id-jag-file redeems an ID-JAG you already have, and --dpop-key PATH binds the token to a key, so a server with dpop.required on can be tested too. See Inspector authentication.

The same two hops with curl, to see each request and response:

bash
ID_JAG=$(curl -s https://acme.okta.com/oauth2/v1/token \
  -u "$TEST_AGENT_ID:$TEST_AGENT_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d requested_token_type=urn:ietf:params:oauth:token-type:id-jag \
  -d audience=https://mcp.acme.example \
  -d resource=https://mcp.acme.example/mcp \
  -d scope=mcp:tools \
  -d subject_token="$(cat id_token.jwt)" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:id_token | jq -r .access_token)

TOKEN=$(curl -s https://mcp.acme.example/oauth/token \
  -u "mcpg-test:$MCPG_TEST_SECRET" \
  -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  -d assertion="$ID_JAG" | jq -r .access_token)

Remove mcpg-test from MCPG and the test agent from Okta when you are done.

Combine with SSO, interactive clients and AAuth

  • SSO tokens (oidc_oauth). The embedded server and oidc_oauth work side by side: a token whose iss is the embedded issuer is verified by the embedded server, any other bearer by oidc_oauth. oidc_oauth needs the dev.mcpg.identity.oidc plugin declared under plugins[] with the same providers, or the gateway refuses to boot. jwks and oidc_oauth are mutually exclusive.
yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    oidc_oauth:
      providers:
        - issuer: "https://acme.okta.com/oauth2/default"    # the SSO custom authorization server
          audiences: ["api://mcpg"]
          verification: { kind: oidc_jwks, allowed_algs: ["RS256"] }
          claim_mappings: { subject_claim: uid }            # the Okta user ID, like the ID-JAG sub
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
          principal_issuer: "https://acme.okta.com/oauth2/default"
      clients:
        - client_id: "claude-ema"
          client_secret: "${env.CLAUDE_EMA_SECRET}"

plugins:
  - id: dev.mcpg.identity.oidc
    class: identity_provider
    source:
      oci: "ghcr.io/mcpg-dev/plugins/identity-oidc:protocol-1"
    granted_capabilities: [network_outbound]
    config:
      providers:
        - issuer: "https://acme.okta.com/oauth2/default"
          audiences: ["api://mcpg"]
          verification: { kind: oidc_jwks, allowed_algs: ["RS256"] }
          claim_mappings: { subject_claim: uid }
  • principal_issuer. Okta SSO access tokens from a custom authorization server name the user in uid; Okta ID-JAGs from the org authorization server name the same user in sub. With subject_claim: uid on the SSO side and principal_issuer on the IdP, both arrive as one principal. Tell them apart by identity.attributes["token_issuer"].
  • Identity plugins (mTLS, API keys, SPIFFE, AAuth) keep working. They run for external credentials only; a gateway-minted token never reaches them.
  • authorization_servers. Leave it empty so the embedded issuer is listed first. If you list servers yourself, include the exact issuer; with interactive sign-in, list it first.
  • Interactive clients. Add a login block to sign users in through the browser at the same authorization server: Interactive sign-in.
  • AAuth. AAuth agents and EMA clients share a gateway: the AAuth-Requirement and WWW-Authenticate challenges are independent.

Opt-in: DPoP-bound tokens

DPoP (RFC 9449) binds a token to a key the client proves it holds. A copied token is useless without the key.

Enterprise license. DPoP needs a license with the oauth.dpop feature (Enterprise). A gateway refuses to boot, or to reload, with dpop.enabled: true when its license does not grant the feature, unless the configuration of a gateway not attached to a control plane sets license.non_production_use: true, which admits it with a warning. A dpop block with enabled: false needs no license. On mcpg.cloud the control plane refuses to publish such a configuration (403 feature_not_licensed) for an org whose plan lacks the feature. An Enterprise license issued before the feature existed does not list it: get a new license token.

yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
      clients:
        - client_id: "claude-ema"
          client_secret: "${env.CLAUDE_EMA_SECRET}"
      dpop:
        enabled: true
        required: false            # true: only DPoP-bound tokens of this server
        nonce: token_endpoint      # off | token_endpoint | always
        proof_max_age_secs: 60     # 1–300
        nonce_lifetime_secs: 300   # 30–3600
        # allowed_algs defaults to ES256 ES384 EdDSA PS256 PS384 PS512 RS256 RS384 RS512

At the token endpoint. The request carries one DPoP proof header. A token issued with a valid proof carries cnf.jkt and token_type: DPoP; one issued without a proof stays a Bearer token. An ID-JAG the IdP bound to a key (cnf.jkt) is redeemed only with a proof of that key (invalid_grant "proof of possession required: the assertion is bound to a key (cnf)"). With DPoP off, a cnf-bound ID-JAG is refused. A client with dpop_bound_access_tokens: true (or a metadata document that declares it) must always send a proof. Interactive sign-in also reads dpop_jkt at /oauth/authorize and binds a public client's refresh token to its proof key.

At the resource. A bound token is accepted only as Authorization: DPoP <token> with a fresh DPoP proof of its key for that request (method, URL, ath, a jti used once). Bearer tokens keep working with the Bearer scheme. A token bound to a key is never accepted as Bearer, even after DPoP is turned off. The caller carries the key thumbprint as identity.attributes["dpop_jkt"], which policy can require. When the proof replay ledger cannot be reached, the request is answered 503 (JSON-RPC -32000) with Retry-After: 1.

Nonces. token_endpoint makes a proof at /oauth/token carry a current server nonce, or the answer is 400 use_dpop_nonce with a fresh DPoP-Nonce header; every token response carries the current nonce. always also covers the resource, which answers 401 with WWW-Authenticate: DPoP error="use_dpop_nonce" and a DPoP-Nonce. Nonces are derived from the signing keys, so every replica accepts every other replica's without a shared store.

required. A token request without a proof is refused (invalid_grant for an ID-JAG, invalid_dpop_proof for a code or a refresh token), the resource refuses this server's unbound tokens (also those issued before), and a 401 to a caller without a credential carries a second challenge, WWW-Authenticate: DPoP algs="…", resource_metadata="…". Tokens another verifier accepts (oidc_oauth, jwks, identity plugins) are not affected, so the resource metadata publishes dpop_bound_access_tokens_required: true only while the embedded server is the only verifier and the only listed authorization server.

Metadata. Both documents publish dpop_signing_alg_values_supported; the resource metadata also publishes dpop_bound_access_tokens_required.

Refusals at the resource carry WWW-Authenticate: DPoP with error= invalid_token, invalid_dpop_proof or use_dpop_nonce, and are audited as mcpg.auth.failed with auth_method ema_dpop.

Rollout. Turn DPoP on only after every replica runs a release that knows the block: a replica without it drops the key binding of a grant when it rotates the grant's refresh token. Turn required on only after your clients send proofs; mcpg inspector login --dpop-key PATH sends them, so it can test the server with required on.

Opt-in: rich authorization requests

Rich authorization requests (RFC 9396) limit a grant to typed authorization_details objects, such as "call this tool on this resource". MCPG checks them against the types you configure, carries them in the token, and hands them to policy.

Enterprise license. Rich authorization requests need a license with the oauth.rich_authorization feature (Enterprise). A gateway refuses to boot, or to reload, with a type in authorization_details.types when its license does not grant the feature, unless the configuration of a gateway not attached to a control plane sets license.non_production_use: true, which admits it with a warning. An authorization_details block with no type needs no license. On mcpg.cloud the control plane refuses to publish such a configuration (403 feature_not_licensed) for an org whose plan lacks the feature. An Enterprise license issued before the feature existed does not list it: get a new license token.

yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
      clients:
        - client_id: "claude-ema"
          client_secret: "${env.CLAUDE_EMA_SECRET}"
      authorization_details:
        max_entries: 16                  # 1–64; the array is also limited to 8 KiB
        types:
          - type: mcp_tool
            description: "Call a named tool"
            actions: ["tools/call"]
            locations: resource          # resource (default) | any

mcp:
  capabilities:
    tools:
      - name: crm.lookup
        description: Look up a customer record.
        governance:
          minimum_trust: verified
          allow_if: 'identity.authorization_details.exists(d, d.type == "mcp_tool" && "tools/call" in d.actions && d.identifier == tool_name)'
        backend: { kind: http, url: "https://crm.internal.example/lookup", method: post }
  • Types. type (1–256 characters, each once), description (1–120, shown on the consent page), an optional inline JSON Schema (schema, local $refs only) and optional allow-lists for actions, datatypes and privileges. locations must name one of this server's resources, or any value. Without a schema an object may carry only type, locations, actions, datatypes, identifier and privileges.
  • Sources. The ID-JAG's authorization_details claim (every object must be valid, or the ID-JAG is refused with invalid_grant); the authorization_details parameter at /oauth/authorize (interactive sign-in; the consent page is then always shown unless the client's consent is skip, and never remembered); and the parameter at /oauth/token, which may only narrow what was granted.
  • Results. The granted details are in the token response, in the access token's authorization_details claim, and in the caller's identity.authorization_details (a list of maps) and identity.attributes["authorization_details_types"].
  • Errors. A parameter that breaks a rule, asks for more than was granted, or narrows a grant without details is refused with invalid_authorization_details. With types empty (the default), an ID-JAG that carries authorization_details is refused (invalid_grant), because dropping them would mint a token wider than the IdP granted, and the parameter is ignored.
  • Audit. Records carry the types and a keyed digest of the details, never their values.
  • Rollout. As for DPoP, turn it on only after every replica runs a release that knows the block.

Call Cross App Access upstreams (outbound role)

MCPG can also be the requesting app: a federation calls an upstream MCP server that Cross App Access protects, as the user.

Why the caller's own token does not work for ID-JAG callers. The default subject token (subject_token: caller_bearer) is the caller's bearer. An ID-JAG caller's bearer is a token this gateway minted: only this gateway can validate it, and sending it anywhere would let the receiver replay it here. MCPG refuses to forward it (pass_through) or exchange it (oauth_impersonation) before any request leaves the gateway. For those callers, exchange the user's stored IdP sign-in instead: subject_token: idp_refresh_token (Okta Cross App Access takes a refresh token) or idp_id_token. The stored sign-in needs interactive sign-in (a login block, license feature sso.interactive_login); a user who has none is sent to {issuer}/oauth/connect once.

yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
          allowed_hosts: ["acme.okta.com"]
          allowed_algs: ["RS256"]
          login:
            client_id: "0oa1mcpgagent"                 # the OIDC app linked to MCPG's AI agent
            private_key: "${env.OKTA_AGENT_KEY}"
            key_id: "mcpg-2026-09"
      clients:
        - client_id: "claude-ema"
          client_secret: "${env.CLAUDE_EMA_SECRET}"

plugins:
  - id: dev.mcpg.credential.oauth-id-jag
    class: credential_issuer
    source:
      oci: "ghcr.io/mcpg-dev/plugins/credential-oauth-id-jag:protocol-1"
    granted_capabilities: [network_outbound]
    config:
      providers:
        vendor:
          idp_token_url: "https://acme.okta.com/oauth2/v1/token"   # hop 1: the org token endpoint
          idp_issuer: "https://acme.okta.com"
          client_id: "0oa1mcpgagent"                               # the login client
          client_auth: private_key_jwt                             # aud = the token endpoint URL
          private_key: "${env.OKTA_AGENT_KEY}"                     # the login key: an RSA PKCS#8 PEM
          key_id: "mcpg-2026-09"
          subject_token_type: refresh_token
          audience: "https://auth.vendor.example"                  # the upstream AS issuer
          resource: "https://mcp.vendor.example/mcp"
          scopes: ["tools.read"]
          redeem_token_url: "https://auth.vendor.example/oauth/token"   # hop 2
          redeem_client_id: "acme-mcpg"
          redeem_client_secret: "${env.VENDOR_CLIENT_SECRET}"
          redeem_client_auth: client_secret_basic

mcp:
  federations:
    - name: vendor
      upstream:
        url: "https://mcp.vendor.example/mcp"
        auth:
          mode: oauth_impersonation
          credential: "cred://dev.mcpg.credential.oauth-id-jag/vendor"
          subject_token: idp_refresh_token
  • Okta specifics. Hop 1 goes to the org token endpoint. The stored sign-in is exchanged only where it was issued: the provider's idp_token_url and client_id must be the login client's, and idp_issuer its IdP, or the call is refused before any request. private_key_jwt toward Okta uses the token endpoint URL as aud (the default assertion_audience: token_endpoint).
  • One key, one algorithm. The login block and the provider both take the signing algorithm from the key type (RSA: RS256, P-256: ES256, Ed25519: EdDSA), so the login key needs no signing_alg on the provider. A signing_alg the key cannot sign with makes the provider refuse the key ("private_key cannot sign ES256: expected a P-256 key in PKCS#8 PEM") and the plugin refuse to load.
  • The catalogue. Tool lists are imported without a caller. An upstream that requires a token to list its tools needs upstream.auth.import with a gateway credential; see Catalogue credential.
  • License. dev.mcpg.credential.oauth-id-jag needs secrets.dynamic (Team, Enterprise); the stored sign-in needs sso.interactive_login (Enterprise).
  • Metrics. mcpg_oauth_id_jag_total{provider}, mcpg_oauth_id_jag_latency_ms{provider,hop} (hop is exchange or redeem), mcpg_oauth_id_jag_error_total{provider,hop}, and mcpg_federation_subject_token_total{mode,outcome}.

The provider reference, the egress rules and the per-call limits are in Cross App Access upstreams.

Operations

Key rotation. Put the new key first in signing_keys and keep the old one until the tokens it signed have expired (access_token_ttl_secs); then remove it. To move away from signing_secret, list the new key first and the old secret as an HS256 entry without a kid after it. Every replica must carry the same keys.

Clusters and reloads. The single-use ledger lives in the cluster coordinator's key-value store off single_node, sealed with cluster.state_encryption_key_env, so a replay is refused on every replica and across reloads. A coordinator without a key-value store falls back to one ledger per replica, with a warning. On single_node the ledger lives in the process: it survives a reload but not a restart. When the ledger cannot be written, redemption answers 503 temporarily_unavailable rather than admit an unrecorded ID-JAG. Configuration changes, including new IdPs and clients, apply on hot reload.

IdP keys. Fetched keys are reused for 5 minutes. An unknown kid refetches, at most every 30 seconds. While the IdP cannot be reached (or answers with a server error, a timeout or a rate limit), the last keys keep verifying for up to an hour. Redirects and documents above 1 MiB are refused, and each fetch times out after 10 seconds (the login.timeout_ms of a login IdP).

Rate limits and client IPs. authorization_server.rate_limit_per_min counts refused requests per client IP. Behind a proxy, set gateway.server.trust_proxy_ip so the first X-Forwarded-For hop is the client IP; otherwise every client shares one budget. Clients behind one egress address (a NAT, a hosted MCP client calling from its own servers) share a budget too.

Metrics.

MetricLabelsMeaning
mcpg_ema_token_requests_totaloutcome (issued, refused, failed), error, idp, grantToken endpoint requests
mcpg_ema_token_latency_msoutcome, grantToken endpoint latency
mcpg_ema_token_rate_limited_total—Requests answered 429
mcpg_ema_jwks_refresh_totalidp, outcome (ok, failed, rejected)IdP key fetches
mcpg_ema_client_jwks_refresh_totaloutcomeClient key fetches (private_key_jwt)
mcpg_ema_client_metadata_fetch_totaloutcomeClient ID Metadata Document fetches
mcpg_ema_jti_store_errors_total—Single-use ledger write failures
mcpg_as_dpop_proofs_totalendpoint, outcome, reasonDPoP proofs checked
mcpg_as_dpop_ledger_latency_msendpointDPoP replay ledger latency
mcpg_as_dpop_bound_tokens_totalgrantKey-bound tokens issued
mcpg_as_authorization_details_totalsource, outcomeAuthorization details granted, narrowed or refused

Interactive sign-in adds the mcpg_as_* metrics listed on its page.

Audit.

ActionWhenDetails
mcpg.ema.token_issuedAn ID-JAG redemption succeededidp, subject, actor, client_id, scope, token_jti, assertion_jti, resource, expires_in, token_type, dpop_jkt, authorization_details_types, authorization_details_digest
mcpg.auth.failed, auth_method: ema_tokenA token request was refusederror, client_id, idp, dpop_presented and the description
mcpg.auth.failed, auth_method: emaA gateway-minted token did not verify on an MCP requestThe reason
mcpg.auth.failed, auth_method: ema_dpopA DPoP proof on an MCP request was refusederror
mcpg.auth.failed, auth_method: bearerAn ID-JAG was presented as a bearer tokenThe reason

Every request to /oauth/token within the rate limit is audited under the x-mcpg-request-id it answers with, except one answered only with a DPoP nonce (use_dpop_nonce), which is a normal protocol step; a client's x-request-id is recorded as the upstream request ID. No record or log line carries an ID-JAG, a client secret, a client assertion, a DPoP proof, a key or a token.

mcpg.cloud. The edge forwards /mcp, /.well-known, /oauth and /aauth to the gateway. The operator reports the AuthorizationServerAligned condition with the reasons MatchesAdvertisedIdentity, IssuerNotBareOrigin, IssuerOriginMismatch and ResourceMismatch. Give secrets as ${secret.NAME}: managed gateways receive the gateway.secrets block from the platform. The publish guard counts authorization_server as a verifier, and refuses a login block for an org whose plan lacks sso.interactive_login.

Security considerations

  • Token lifetime is the revocation window. Without interactive sign-in, there is no revocation endpoint: a minted token lives until it expires. The IdP stops issuing ID-JAGs at once, but cannot recall a token already minted. Keep access_token_ttl_secs at an hour or less. To cut off access immediately, remove the client or the IdP from the configuration, or narrow allowed_clients or required_tenant: tokens already issued are refused from the next request.
  • Single use. Leave enforce_single_use on. In a cluster, it needs the coordinator's key-value store to cover every replica.
  • Secrets. Signing keys, client secrets and PEMs come from ${env.…} or ${secret.…}, never from the YAML. An HS256 secret is never published but must be the same on every replica; asymmetric keys let other services verify tokens without holding a secret.
  • Public clients. Metadata-document clients that authenticate with none can redeem any ID-JAG issued to their client_id. The IdP's policy on that client is your control; keep allowed_clients narrow.
  • Outbound fetches. IdP discovery, IdP keys, client keys and metadata documents are fetched with no redirects, bounded sizes and host allow-lists; private and loopback addresses are refused unless allow_private_network is set. Use it for development only.
  • Audience binding. Each token names one resource of this gateway; another resource server that trusts the same IdP cannot use it.
  • No passthrough. Gateway-minted tokens never leave the gateway, as the MCP authorization specification requires.

Troubleshooting

Find the reason. A refused token request answers the client with the OAuth error and a description, but many clients show only a generic failure. The gateway keeps the same reason in three places:

  • Audit. With an audit sink configured (governance.audit), every refused token request is recorded as mcpg.auth.failed with details.auth_method ema_token (as_token for a code or a refresh token). details.error is the OAuth error, details.reason the error and the description the client received, details.client_id the client that asked, and details.idp the trusted IdP the assertion named. A token that fails on /mcp is recorded with auth_method ema and the reason.
  • Metrics. mcpg_ema_token_requests_total{outcome="refused"} counts refusals by error, idp and grant; outcome="failed" counts 5xx answers.
  • Logs. The token endpoint logs each refusal at DEBUG level, as "token request refused" with the client, the IdP, the error and the description. Turn it on for this module alone with observability.logs.level: "info,mcpg::runtime::authorization_server=debug". A token that fails on /mcp is logged at WARN ("EMA access token verification failed, rejecting with 401") with the reason.

Hop 1 never reaches MCPG: look for its refusals in the client and in Okta's System Log.

Symptom or error textCauseFix
Boot refused, naming governance.access.authorization_server and an ${env.…} or ${secret.…} referenceThe variable is not set, or the secret file cannot be readSet the variable, or mount the file under gateway.secrets.dir
Boot or reload refused: "license gate: plan `…` does not license …", naming authorization_server.dpop (oauth.dpop) or authorization_server.authorization_details (oauth.rich_authorization)DPoP or rich authorization requests are on and the license lacks the feature. An Enterprise license issued before the feature existed does not list itUse an Enterprise license (get a new token when yours is older than the feature), set license.non_production_use: true outside production on a gateway mcpg.cloud does not license, or turn the block off
invalid_grant "assertion `typ` must be oauth-id-jag+jwt"The client sent an ID token or an access token as assertionSend the token Okta returned from the token exchange
unsupported_grant_type "unsupported grant_type `…`; this server supports …"The client sent another grant, such as client_credentialsSend urn:ietf:params:oauth:grant-type:jwt-bearer
invalid_grant "assertion has expired"The ID-JAG passed its exp (Okta issues them for 5 minutes) before the client redeemed it, or the gateway's clock runs behindGet a new ID-JAG; keep the gateway's clock in sync (clock_skew_secs allows up to 300 seconds)
invalid_grant "assertion iat is in the future" or "assertion is not valid yet (nbf)"The gateway's clock is behind the IdP's by more than clock_skew_secsKeep the gateway's clock in sync
invalid_grant "assertion alg … is not in allowed_algs for this enterprise IdP"The IdP signs with an algorithm that allowed_algs does not listList the IdP's algorithm (Okta: RS256)
invalid_grant "assertion signature is invalid"The ID-JAG was changed on the way, or a key that is not the IdP's signed itCheck that the client sends the ID-JAG unchanged, and that jwks or jwks_uri hold this IdP's keys
invalid_grant "assertion issuer is not a trusted enterprise IdP"iss is not a trusted_idps[].issuerAdd the IdP. Okta issues from https://{org}.okta.com
… "a trusted_idps issuer differs from it only by a trailing `/`, and issuers compare exactly"A trailing slashWrite issuer exactly as the IdP does
invalid_grant "assertion aud … must set aud to exactly `…`"The Okta Issuer URL is not authorization_server.issuerMake both the same string; in Okta, reset the connection
invalid_grant "assertion client_id does not match the authenticated client"The Okta resource connection names another client IDSet AI agent's client ID registered in this app to the clients[].client_id
invalid_grant "this enterprise IdP may not issue assertions for the authenticated client"allowed_clients lacks the clientAdd it
invalid_target "… does not identify this MCP server"The resource is not resource_metadata.resource or an additional_resources entryAlign the Okta resource indicator and the resource metadata
invalid_grant "assertion has already been redeemed"The client reused an ID-JAG after a successful redemptionGet a new ID-JAG for each access token
invalid_grant "assertion signature key could not be resolved: …"No key with the ID-JAG's kid, or discovery failedCheck allowed_hosts, jwks_uri, and that the issuer's discovery issuer matches exactly
invalid_grant "the enterprise IdP's signing keys cannot be used: …"The IdP's keys or documents are misconfigured, or refused by a client errorFix the IdP entry; the text names the problem
503 temporarily_unavailableThe IdP's keys cannot be fetched and none are cached, or the single-use ledger cannot be writtenRestore egress to the IdP; check the cluster coordinator
401 invalid_client with WWW-Authenticate: Basic realm="mcpg"A wrong secret, an unknown client or the wrong method after HTTP BasicCheck the client's secret and token_endpoint_auth_method
400 invalid_client "client authentication required: this client authenticates with its client_secret"The client sent no credential: a public client (for example one that uses its metadata document) against a clients[] entry with a secretGive the client its secret, or remove the clients[] entry and admit the client by its metadata document
invalid_client "client uses client_secret_basic: send its credentials in the Authorization header" (or "client uses client_secret_post: …")The client sends its secret the other waySet token_endpoint_auth_method to the method the client uses, or leave it unset to accept both
unauthorized_clientThe client has redirect_uris but no jwt-bearer grantAdd urn:ietf:params:oauth:grant-type:jwt-bearer to its grant_types
429 with Retry-AfterThe per-IP budget of refused requests is spentFix the refusals; set trust_proxy_ip behind a proxy
invalid_scopeEvery requested scope this server knows is outside what the ID-JAG grants, or require_scope left none. Requested scopes it does not know, such as openid profile, are ignoredAlign the resource connection's scopes with allowed_scopes
The redemption succeeds, then /mcp answers 401 with error="invalid_token"The audit record and the WARN log name the reason: "token was minted for …" (the IdP or the client is no longer trusted), "token names an unknown signing key" (a replica without the key, or a rotation that removed it too early), "token revoked", "the access token is DPoP-bound; …" (sent as Bearer), or "this resource accepts only DPoP-bound tokens" (dpop.required)Restore the trust, give every replica the same signing_keys, or have the client use DPoP
403 with error="insufficient_scope"The token lacks a scope in the tool's required_scopesAdd the scope to the resource connection and allowed_scopes; the client then asks the IdP for a new ID-JAG
invalid_grant "proof of possession required: …"The ID-JAG carries cnfTurn on DPoP, and have the client send a proof
404 {"error":"protected resource metadata not configured"}No resource_metadataSet resource_metadata.resource
mcpg config check: "issuer must be a bare origin, scheme://host[:port], without a path"The issuer has a pathUse the origin alone
mcpg config check: "config references `${secret.…}` but gateway.secrets.dir is not set"${secret.…} without a secrets directorySet gateway.secrets.dir
The client never asks for a token; anonymous tools/list is emptyrequire_authentication is off, so there is no 401Set require_authentication: true
308 to another hostcanonical_url redirects other hostsPoint clients at the canonical origin
Boot refused: "`governance.access.oidc_oauth` configures OIDC identity, but no identity plugin is registered"oidc_oauth without the pluginDeclare dev.mcpg.identity.oidc under plugins[]
Okta: requested_token_type invalid or unsupportedThe client used a custom authorization serverUse the org authorization server
Okta: invalid_target "Resource mismatch"The client's resource differs from the resource indicatorMake the resource indicator equal resource_metadata.resource
Okta refuses after many requests in a monthThe SSO-plan quota of 250 ID-JAGs per user, resource app and monthLengthen access_token_ttl_secs, or license Okta for AI Agents

What is deliberately not implemented

  • MCPG as an IdP. It never issues ID-JAGs; your enterprise IdP does.
  • Token introspection (RFC 7662) at the embedded server. Other services verify its tokens with the published keys.
  • Revocation without interactive sign-in. Access ends at expiry, or when you remove the IdP or client from the configuration.
  • The jwt-dpop grant type (draft-parecki-oauth-jwt-dpop-grant). A DPoP proof accompanies the jwt-bearer grant instead.
  • SAML and multi-tenant subject claims (sub_id with saml-nameid, aud_tenant, aud_sub) and just-in-time account linking. Subjects come from claim_mappings.subject_claim.
  • mTLS-bound tokens (RFC 8705).
  • Encrypted ID-JAGs.
  • Stdio transports. Enterprise-managed authorization is an HTTP flow; stdio carries no bearer, and the capability is not advertised there.

Where to read next