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 extensionio.modelcontextprotocol/enterprise-managed-authorizationis stable, and MCPG serves it under both MCP authorization revisions it speaks,2025-11-25and2026-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 runmcpg config checkafter 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.
| Name | Who uses it | What 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 SSO | Okta 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 Gateway | Separate Okta products | Not 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
| Role | MCPG is | What it does | Configured under | License |
|---|---|---|---|---|
| Inbound | The MCP server and its authorization server | Redeems ID-JAGs at POST /oauth/token, verifies the tokens it minted on every MCP request | governance.access.authorization_server | None: core. The opt-in DPoP needs oauth.dpop and rich authorization requests need oauth.rich_authorization (Enterprise) |
| Outbound | The requesting app, an MCP client of an upstream MCP server that Cross App Access protects | Exchanges 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 user | mcp.federations[] with the dev.mcpg.credential.oauth-id-jag plugin | secrets.dynamic (Team, Enterprise) |
| Outbound with the stored sign-in | The same, using the IdP sign-in the gateway keeps for each user | As above, but the subject token is the user's IdP refresh token, so ID-JAG callers can be served too | The above plus upstream.auth.subject_token and a trusted_idps[].login block | sso.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
sequenceDiagram autonumber participant U as User participant C as MCP client participant O as Okta (org authorization server) participant G as MCPG U->>C: sign in with Okta SSO C->>G: POST /mcp (no token) G-->>C: 401 WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp" C->>G: GET /.well-known/oauth-protected-resource/mcp G-->>C: resource, authorization_servers: the MCPG issuer C->>G: GET /.well-known/oauth-authorization-server G-->>C: jwt-bearer grant, id-jag grant profile, token_endpoint C->>O: POST /oauth2/v1/token (token exchange)<br/>audience = MCPG issuer, resource = MCPG resource<br/>subject_token = the user's ID token or refresh token O->>O: Okta policy: user, AI agent, resource connection, scopes O-->>C: ID-JAG (typ oauth-id-jag+jwt, aud = MCPG issuer, 5 minutes) C->>G: POST /oauth/token<br/>grant_type = jwt-bearer, assertion = ID-JAG, client authentication G->>O: GET discovery and JWKS (cached 5 minutes) G->>G: verify the ID-JAG, record its (iss, jti) once G-->>C: access token (typ at+jwt, aud = MCPG resource), no refresh token C->>G: POST /mcp<br/>Authorization: Bearer access token G->>G: verify the token, apply the trust floor, scopes and policy G-->>C: tools/list, tools/call results
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:
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:
{
"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…"
}
}
issis the Okta org authorization server, with no path and no trailing slash.subis the Okta user ID.audis exactly the Issuer URL the Okta admin entered for the resource app: MCPG'sauthorization_server.issuer.client_idis the AI agent's client ID registered in this app: the client as MCPG knows it, not as Okta knows it.emailappears only when the client requested theemailscope at sign-in.- The draft also defines
act,tenant,amr,cnfandauthorization_details; MCPG reads each of them (below).
Hop 2 is the request MCPG answers:
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:
{
"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:
{
"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:
{
"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_supportedwithjwt-beareris what Claude reads to decide that a server supports Enterprise Managed Auth.authorization_grant_profiles_supportedwith theid-jagprofile is the discovery signal the EMA extension defines.token_endpoint_auth_methods_supportedlists the methods of the registered clients. Client ID Metadata Documents addprivate_key_jwtandnone.token_endpoint_auth_signing_alg_values_supportedappears wheneverprivate_key_jwtis offered.scopes_supportedisallowed_scopes, or[]without it.jwks_uriappears when a signing key is asymmetric.client_id_metadata_document_supported: trueappears when metadata documents are on,dpop_signing_alg_values_supportedwith DPoP, andauthorization_details_types_supportedwith 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:
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.
| # | Check | Error when it fails | What to fix |
|---|---|---|---|
| 1 | The DPoP proof, while DPoP is on | invalid_dpop_proof, use_dpop_nonce | See DPoP |
| 2 | grant_type is present and supported | invalid_request "grant_type is required"; unsupported_grant_type naming the supported grants | The client sends urn:ietf:params:oauth:grant-type:jwt-bearer |
| 3 | The client authenticates with exactly one method, the one it is registered with | invalid_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 400 | The client's clients[] entry, its secret or keys |
| 4 | The client may redeem ID-JAGs | unauthorized_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 |
| 5 | assertion is present | invalid_request "assertion is required" | — |
| 6 | It is a JWT | invalid_grant "assertion is not a well-formed JWT" | — |
| 7 | JOSE typ is oauth-id-jag+jwt | invalid_grant "assertion `typ` must be oauth-id-jag+jwt" | The client sent an ID token or an access token instead of the ID-JAG |
| 8 | The algorithm is asymmetric | invalid_grant "assertion must use an asymmetric signing algorithm" | — |
| 9 | iss names a trusted IdP, compared exactly | invalid_grant "assertion issuer is not a trusted enterprise IdP" (with a note when only a trailing / differs) | trusted_idps[].issuer |
| 10 | The algorithm is in the IdP's allowed_algs | invalid_grant "assertion alg … is not in allowed_algs for this enterprise IdP" | allowed_algs |
| 11 | The signing key resolves: inline jwks, jwks_uri, or discovery | invalid_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 remain | allowed_hosts, jwks_uri, network egress |
| 12 | Signature, exp and nbf (with clock_skew_secs leeway); exp, aud, iss, sub, client_id, jti and iat are present | invalid_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 |
| 13 | aud is exactly the issuer, as a string or a one-element array | invalid_grant "assertion aud … must name only this authorization server, …" | — |
| 14 | iat is not in the future, and exp − iat ≤ max_assertion_lifetime_secs | invalid_grant "assertion iat is in the future", "assertion lifetime (exp - iat) of … s exceeds …" | max_assertion_lifetime_secs |
| 15 | A cnf claim needs DPoP, and only a jkt is accepted | invalid_grant "proof of possession required: …" | Turn on DPoP |
| 16 | authorization_details are valid | invalid_grant (see rich authorization requests) | — |
| 17 | The client_id claim names the authenticated client; the IdP's allowed_clients and required_tenant admit it | invalid_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 |
| 18 | The subject claim is present; a malformed act is refused | invalid_grant | claim_mappings.subject_claim |
| 19 | resource: 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.resource | invalid_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 |
| 20 | DPoP binding of the token | invalid_grant (see DPoP) | — |
| 21 | With rich authorization requests on, the request's authorization_details only narrows what the grant holds | invalid_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 |
| 22 | Scope: 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 scope | invalid_scope when every requested scope this server knows is outside the grant, or when require_scope leaves nothing | allowed_scopes, the scopes of the resource connection |
| 23 | Single use: the (iss, jti) pair was never redeemed | invalid_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:
| Claim | Value |
|---|---|
iss | authorization_server.issuer |
sub | The user, read by claim_mappings.subject_claim (default sub) |
aud | The one resource the token was minted for |
client_id | The authenticated client |
jti, iat, exp | Unique ID, issue time, expiry after access_token_ttl_secs (default 3600) |
scope | The granted scopes, space-separated |
idp | The ID-JAG's iss |
email, tenant, amr, act | Copied from the ID-JAG when present |
groups, roles, mcpg_attributes | What claim_mappings read from the ID-JAG |
cnf.jkt | The DPoP key thumbprint, for a key-bound token |
authorization_details | The 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:
| Field | Value |
|---|---|
identity.subject_id | The subject claim (default sub: the Okta user ID) |
identity.issuer | The IdP issuer; {issuer}#{tenant} when the ID-JAG carries tenant and required_tenant is unset; or principal_issuer |
identity.auth_provider | ema, or oidc_oauth:<principal_issuer> |
identity.roles | Mapped roles, then client_roles[client_id] |
identity.groups | Mapped groups |
identity.scopes | The 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_details | A list of maps, with rich authorization requests |
| Mapped attributes | What 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:
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 carrytoken_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_typeisid_jagfor an ID-JAG andauthorization_codefor an interactive sign-in; leave that clause out to limit both.identity.auth_provideris not a safe key: once the IdP setsprincipal_issuer, its callers carryoidc_oauth:<principal_issuer>, and a rule onauth_provider != "ema"lets every client through. Theintest 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_scopesanswers a caller short of a scope with the403 insufficient_scopechallenge 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.rolesfromclient_roles, onidentity.attributes["client_id"], or on claims you copy withattribute_claim_mappings. For an IdP that sends groups, map them:
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_oauthverifies,principal_issuermakes 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
| Specification | Role in MCPG | Status |
|---|---|---|
| RFC 6749 (OAuth 2.0) | Token endpoint, client authentication (exactly one method), scope narrowing, no-store responses, the error codes above | Implemented |
| RFC 6750 (Bearer tokens) | Authorization: Bearer header only; WWW-Authenticate with resource_metadata, invalid_token, insufficient_scope and scope | Implemented |
| 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 IdP | Partial |
| 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 keys | Implemented |
| 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-jag | Implemented |
| 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 implemented | Opt-in |
| RFC 7636 (PKCE) | S256 only at /oauth/authorize; also used toward the login IdP | Implemented |
| RFC 7638 (JWK thumbprint) | DPoP jkt binding; the default kid of asymmetric signing keys | Implemented |
RFC 7800 (cnf) | cnf.jkt on DPoP-bound tokens; a cnf-bound ID-JAG needs DPoP | Opt-in (Enterprise) |
| RFC 8252 (Native apps) | Loopback redirect URIs match any port | Implemented |
| RFC 8414 (AS metadata) | Served at the issuer root; read from IdPs without OpenID discovery | Implemented |
| 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_target | Implemented |
| RFC 8725 (JWT BCP) | Explicit typing checked (oauth-id-jag+jwt, at+jwt, dpop+jwt, JWT or client-authentication+jwt); algorithm allow-lists | Implemented |
| 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 responses | Implemented (interactive sign-in) |
| RFC 9396 (Rich authorization requests) | authorization_details from the ID-JAG, the authorize and the token request; typed validation; policy reads them | Opt-in (Enterprise) |
| RFC 9449 (DPoP) | Proofs at the token endpoint and the resource, nonces, cnf-bound ID-JAGs, required mode | Opt-in (Enterprise) |
| RFC 9700 (OAuth security BCP) | PKCE, exact redirect matching, refresh rotation with reuse detection, no CORS on browser endpoints, per-IP limits | Applied |
| RFC 9728 (Protected resource metadata) | Root and path-aware documents; the resource chosen by request host; resource_metadata in every challenge | Implemented |
| OpenID Connect Core 1.0 | Relying party of the login IdP: ID token checks, no UserInfo. MCPG is not an OpenID Provider | Implemented (as RP) |
| OpenID Connect Discovery 1.0 | Reads IdP discovery (issuer must match exactly); serves none | Implemented (consumer) |
OAuth 2.1 (draft-ietf-oauth-v2-1) | PKCE S256 only, exact redirect URIs, no implicit or password grants, refresh rotation | Partial (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 only | Implemented |
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-JAGs | Implemented |
MCP authorization 2025-11-25 and 2026-07-28 | Protected resource metadata, AS metadata, 401 and 403 challenges with scope, audience validation, no token passthrough, Client ID Metadata Documents, optional DCR, RFC 9207 | Implemented |
MCP extension io.modelcontextprotocol/enterprise-managed-authorization | Resource authorization server; the id-jag grant profile; the capability in initialize and server/discover over HTTP | Implemented |
| MCP URL-mode elicitation | A link to /oauth/connect when a federated call needs the caller's stored sign-in | Implemented (one purpose) |
draft-ietf-oauth-identity-chaining | The pattern ID-JAG profiles; no refresh token for ID-JAG grants | Informational |
OpenID Connect Enterprise Extensions (tenant) | tenant namespaces the principal or is pinned by required_tenant; aud_tenant is not supported | Partial |
| RFC 7662 (Introspection) | Not offered by the embedded server; oidc_oauth can call an external one | Not implemented |
RFC 8705 (mTLS-bound tokens), RFC 9470 (Step-up), RFC 9493 (sub_id) | — | Not implemented |
Configure MCPG
Prerequisites
- A public
httpsorigin that reaches the gateway, such ashttps://mcp.acme.example. Clients fetch the metadata and post to/oauth/tokenthere. - The issuer is a bare origin,
scheme://host[:port], and an exact string. It becomes the ID-JAGaud, 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 withauthorization_server, because it is how clients find the authorization server.require_authentication: true: EMA clients start only after a401. Without it, an anonymousinitializesucceeds 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
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:
| Key | Default | Meaning |
|---|---|---|
issuer | required | Bare https origin; the ID-JAG aud and the iss of minted tokens. http:// draws a warning off loopback |
resource | resource_metadata.resource | The 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_secs | 3600 | 1–86400; above 3600 draws a warning |
clock_skew_secs | 60 | At most 300 |
max_assertion_lifetime_secs | 600 | 1–3600; the longest exp − iat accepted |
enforce_single_use | true | Refuse a second redemption of the same (iss, jti) |
allowed_scopes | unset | Scopes that may be granted; unset lets the IdP's scopes through |
require_scope | false | Refuse a redemption that grants no scope |
trusted_idps[] | required | At 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_documents | off | See below |
rate_limit_per_min | 120 | Refused token requests per client IP per minute; 0 disables |
dpop | off | See DPoP. enabled: true needs the oauth.dpop license feature |
authorization_details | off | See rich authorization requests. A type in types needs the oauth.rich_authorization license feature |
interactive | — | Settings of interactive sign-in |
trusted_idps[]:
| Key | Default | Meaning |
|---|---|---|
issuer | required | Compared exactly with the ID-JAG iss and with the discovery document's issuer, a trailing / included |
jwks_uri / jwks | discovery | A 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_network | false | Development only: http:// and private addresses |
allowed_algs | RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 EdDSA | HMAC is refused. List only the IdP's algorithm (Okta: RS256) |
allowed_clients | [] (every client) | Clients this IdP may issue ID-JAGs for |
required_tenant | unset | The tenant claim every ID-JAG must carry |
claim_mappings | subject_claim: sub | subject_claim, group_claim_paths, role_claim_paths, attribute_claim_mappings |
principal_issuer | unset | Join this IdP's users to the principals of the oidc_oauth provider with this issuer |
login | unset | Turns on interactive sign-in through this IdP (one entry at most) |
clients[]:
| Key | Default | Meaning |
|---|---|---|
client_id | required | What 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_method | derived | Both 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_audience | false | Also accept aud = {issuer}/oauth/token in the client assertion |
allow_private_network | false | Development only, for jwks_uri |
grant_types | derived | [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:
| Key | Default | Meaning |
|---|---|---|
client_id_metadata_documents.enabled | on when a clients[].client_id is an https:// URL or allowed_hosts is set | Advertise 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_network | false | Development only |
client_id_metadata_documents.redirect_uri_policy | same_host | Interactive sign-in only |
resource_metadata.resource | required | Absolute http(s), no fragment, no wildcard; loopback only with allow_loopback_resource |
resource_metadata.additional_resources | [] | Other hostnames the gateway serves |
resource_metadata.authorization_servers | derived | Leave 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.
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_secretwithclient_secret_basic(HTTP Basic) orclient_secret_post(form fields). Okta's examples use Basic. - Confidential, with a key (
private_key_jwt). The client signs aclient_assertionwithiss=sub= itsclient_id,aud= the issuer as the only value,expat most 5 minutes ahead and a freshjti. Clients written for Okta put the token endpoint URL inaud; accept that withaccept_token_endpoint_audience: true.
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_idis anhttps://URL of a JSON document that describes the client; there is no registration. List the host underclient_id_metadata_documents.allowed_hosts(an exact host or a parent domain). MCPG fetches the document without redirects and up to 5 KiB, requires itsclient_idto equal the URL exactly (in canonical form), and caches it for itsmax-age(60 seconds to 24 hours, 5 minutes by default, and up to an hour stale while the host is down). The document'stoken_endpoint_auth_methodmust benoneorprivate_key_jwt(withjwks, or ajwks_urion an allowed host); a shared secret is refused. Aclients[]entry with the same URL wins, and its document is never fetched.
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 asubis unique only within its issuer. - Multi-tenant IdPs. Pin one tenant with
required_tenant. Unset, atenantclaim joins the principal namespace (identity.issuerbecomes{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.
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.
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
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_serverslist lacks the exact issuer; - the issuer is
http://off loopback, or on an origin that is not one of the resource origins; authorization_server.resourceis not an advertised resource;require_authenticationisfalse;- metadata documents are on but admit no client;
- a
principal_issueris not the issuer of anoidc_oauthprovider; access_token_ttl_secsis 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/defaultand/oauth2/{id}) do not: a client that uses one gets an error thatrequested_token_typeis 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 object | What it stands for | Its identifier |
|---|---|---|
| Resource app | MCPG as the server users reach. Okta issues ID-JAGs for it | Its Issuer URL: MCPG's authorization_server.issuer |
| AI agent, with its linked app | The MCP client (Claude, VS Code, your own agent). It signs the user in at Okta and performs hop 1 | An Okta client ID, which only the client and Okta use |
| Resource connection | On the AI agent: the resource app it may reach, the resource, the scopes, and which client the agent is at MCPG | AI 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.
| Value | Example | MCPG key |
|---|---|---|
| Issuer | https://mcp.acme.example | authorization_server.issuer |
| Resource | https://mcp.acme.example/mcp | resource_metadata.resource |
| Scopes | mcp:tools | allowed_scopes and resource_metadata.scopes_supported |
| Each client's ID at MCPG | claude-ema | authorization_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.
- In the Admin Console, go to Applications > Applications and choose Create App Integration.
- Choose OIDC - OpenID Connect and Web Application, then Next.
- 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. - Under Assignments, choose the users or groups who may reach MCPG, then Save.
- 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-JAGaud. - Audience/tenant ID: leave it empty. MCPG does not read a tenant audience
(
aud_tenant).
- Issuer URL: the issuer from step 1, exactly (
- 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:
- Go to Directory > AI agents and choose Register AI agent > Register manually.
- 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.
- Choose how the agent authenticates at Okta: Client secret, or Public/private key. Client ID only makes it a public client.
- 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.
- 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 anotherresourcegetsinvalid_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-JAGclient_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:
| Client | At MCPG | AI agent's client ID registered in this app |
|---|---|---|
| Claude, with a credential you issue | A clients[] entry with a secret you create. Give Claude the client ID and secret where Anthropic's Enterprise Managed Auth documentation says | claude-ema |
| Claude, by its metadata document | client_id_metadata_documents.allowed_hosts: ["claude.ai"]. Claude then authenticates as a public client | https://claude.ai/oauth/mcp-oauth-client-metadata |
| VS Code | Follow Microsoft's instructions. VS Code publishes the metadata document https://vscode.dev/oauth/client-metadata.json, which allowed_hosts: ["vscode.dev"] admits | What Microsoft's instructions name |
| Your own agent | A clients[] entry with a secret, or with keys for private_key_jwt | Its 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.
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 field | MCPG key |
|---|---|
| Resource app > Resource Server > Cross App Access > Issuer URL | authorization_server.issuer (exact string) |
| Resource app > Resource Server > Cross App Access > Audience/tenant ID | None: leave it empty |
| Resource app > Assignments | None: the users and groups who may reach MCPG through Okta |
| AI agent > Resource connection > Resource indicator | resource_metadata.resource (exact string) |
| AI agent > Resource connection > AI agent's client ID registered in this app | authorization_server.clients[].client_id, or a metadata document URL |
| AI agent > Resource connection > Scopes | allowed_scopes and resource_metadata.scopes_supported |
| Okta domain | trusted_idps[].issuer = https://{org}.okta.com |
Connect a client and verify
- Claude. Claude looks for
jwt-beareringrant_types_supportedand starts from the401. It authenticates with an Anthropic-held credential or with its metadata document as a public client. It sendsresourceonly when the IdP supports forwarding it. Without it, MCPG takes the resource from the ID-JAG'sresourceclaim, else usesauthorization_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
audienceat the IdP, and send the resource metadata'sresource.
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.
- 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 asTEST_AGENT_IDandTEST_AGENT_SECRET, and assign yourself to its linked app. - Add a resource connection from the test agent to the resource app, with the
resource indicator and scopes of step 4 and
mcpg-testas AI agent's client ID registered in this app. - At MCPG, register
mcpg-testbeside the real clients, with a secret of its own, and add it toallowed_clientswhere the IdP entry sets them:
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}"
- Get an ID token for the test agent: sign yourself in with the authorization code grant and PKCE.
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:
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
- 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:
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:
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 andoidc_oauthwork side by side: a token whoseissis the embedded issuer is verified by the embedded server, any other bearer byoidc_oauth.oidc_oauthneeds thedev.mcpg.identity.oidcplugin declared underplugins[]with the same providers, or the gateway refuses to boot.jwksandoidc_oauthare mutually exclusive.
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 inuid; Okta ID-JAGs from the org authorization server name the same user insub. Withsubject_claim: uidon the SSO side andprincipal_issueron the IdP, both arrive as one principal. Tell them apart byidentity.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
loginblock 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-RequirementandWWW-Authenticatechallenges 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.dpopfeature (Enterprise). A gateway refuses to boot, or to reload, withdpop.enabled: truewhen its license does not grant the feature, unless the configuration of a gateway not attached to a control plane setslicense.non_production_use: true, which admits it with a warning. Adpopblock withenabled: falseneeds 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.
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_authorizationfeature (Enterprise). A gateway refuses to boot, or to reload, with a type inauthorization_details.typeswhen its license does not grant the feature, unless the configuration of a gateway not attached to a control plane setslicense.non_production_use: true, which admits it with a warning. Anauthorization_detailsblock 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.
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 foractions,datatypesandprivileges.locationsmust name one of this server's resources, oranyvalue. Without a schema an object may carry onlytype,locations,actions,datatypes,identifierandprivileges. - Sources. The ID-JAG's
authorization_detailsclaim (every object must be valid, or the ID-JAG is refused withinvalid_grant); theauthorization_detailsparameter at/oauth/authorize(interactive sign-in; the consent page is then always shown unless the client'sconsentisskip, 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_detailsclaim, and in the caller'sidentity.authorization_details(a list of maps) andidentity.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. Withtypesempty (the default), an ID-JAG that carriesauthorization_detailsis 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.
sequenceDiagram autonumber participant C as Caller participant G as MCPG participant O as Okta (org authorization server) participant A as Upstream authorization server participant M as Upstream MCP server C->>G: tools/call vendor.search G->>O: token exchange<br/>subject_token = the user's stored refresh token<br/>audience = upstream issuer, requested_token_type = id-jag O-->>G: ID-JAG for the upstream G->>A: POST token endpoint<br/>grant_type = jwt-bearer, assertion = ID-JAG A-->>G: upstream access token (cached per user) G->>M: tools/call with the upstream token M-->>G: result G-->>C: result
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.
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_urlandclient_idmust be the login client's, andidp_issuerits IdP, or the call is refused before any request.private_key_jwttoward Okta uses the token endpoint URL asaud(the defaultassertion_audience: token_endpoint). - One key, one algorithm. The
loginblock and the provider both take the signing algorithm from the key type (RSA:RS256, P-256:ES256, Ed25519:EdDSA), so the login key needs nosigning_algon the provider. Asigning_algthe 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.importwith a gateway credential; see Catalogue credential. - License.
dev.mcpg.credential.oauth-id-jagneedssecrets.dynamic(Team, Enterprise); the stored sign-in needssso.interactive_login(Enterprise). - Metrics.
mcpg_oauth_id_jag_total{provider},mcpg_oauth_id_jag_latency_ms{provider,hop}(hopisexchangeorredeem),mcpg_oauth_id_jag_error_total{provider,hop}, andmcpg_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.
| Metric | Labels | Meaning |
|---|---|---|
mcpg_ema_token_requests_total | outcome (issued, refused, failed), error, idp, grant | Token endpoint requests |
mcpg_ema_token_latency_ms | outcome, grant | Token endpoint latency |
mcpg_ema_token_rate_limited_total | — | Requests answered 429 |
mcpg_ema_jwks_refresh_total | idp, outcome (ok, failed, rejected) | IdP key fetches |
mcpg_ema_client_jwks_refresh_total | outcome | Client key fetches (private_key_jwt) |
mcpg_ema_client_metadata_fetch_total | outcome | Client ID Metadata Document fetches |
mcpg_ema_jti_store_errors_total | — | Single-use ledger write failures |
mcpg_as_dpop_proofs_total | endpoint, outcome, reason | DPoP proofs checked |
mcpg_as_dpop_ledger_latency_ms | endpoint | DPoP replay ledger latency |
mcpg_as_dpop_bound_tokens_total | grant | Key-bound tokens issued |
mcpg_as_authorization_details_total | source, outcome | Authorization details granted, narrowed or refused |
Interactive sign-in adds the mcpg_as_* metrics listed on
its page.
Audit.
| Action | When | Details |
|---|---|---|
mcpg.ema.token_issued | An ID-JAG redemption succeeded | idp, 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_token | A token request was refused | error, client_id, idp, dpop_presented and the description |
mcpg.auth.failed, auth_method: ema | A gateway-minted token did not verify on an MCP request | The reason |
mcpg.auth.failed, auth_method: ema_dpop | A DPoP proof on an MCP request was refused | error |
mcpg.auth.failed, auth_method: bearer | An ID-JAG was presented as a bearer token | The 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_secsat an hour or less. To cut off access immediately, remove the client or the IdP from the configuration, or narrowallowed_clientsorrequired_tenant: tokens already issued are refused from the next request. - Single use. Leave
enforce_single_useon. 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
nonecan redeem any ID-JAG issued to theirclient_id. The IdP's policy on that client is your control; keepallowed_clientsnarrow. - 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_networkis 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 asmcpg.auth.failedwithdetails.auth_methodema_token(as_tokenfor a code or a refresh token).details.erroris the OAuth error,details.reasonthe error and the description the client received,details.client_idthe client that asked, anddetails.idpthe trusted IdP the assertion named. A token that fails on/mcpis recorded withauth_methodemaand the reason. - Metrics.
mcpg_ema_token_requests_total{outcome="refused"}counts refusals byerror,idpandgrant;outcome="failed"counts5xxanswers. - 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/mcpis 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 text | Cause | Fix |
|---|---|---|
Boot refused, naming governance.access.authorization_server and an ${env.…} or ${secret.…} reference | The variable is not set, or the secret file cannot be read | Set 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 it | Use 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 assertion | Send 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_credentials | Send 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 behind | Get 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_secs | Keep 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 list | List 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 it | Check 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[].issuer | Add 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 slash | Write issuer exactly as the IdP does |
invalid_grant "assertion aud … must set aud to exactly `…`" | The Okta Issuer URL is not authorization_server.issuer | Make 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 ID | Set 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 client | Add it |
invalid_target "… does not identify this MCP server" | The resource is not resource_metadata.resource or an additional_resources entry | Align the Okta resource indicator and the resource metadata |
invalid_grant "assertion has already been redeemed" | The client reused an ID-JAG after a successful redemption | Get 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 failed | Check 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 error | Fix the IdP entry; the text names the problem |
503 temporarily_unavailable | The IdP's keys cannot be fetched and none are cached, or the single-use ledger cannot be written | Restore 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 Basic | Check 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 secret | Give 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 way | Set token_endpoint_auth_method to the method the client uses, or leave it unset to accept both |
unauthorized_client | The client has redirect_uris but no jwt-bearer grant | Add urn:ietf:params:oauth:grant-type:jwt-bearer to its grant_types |
429 with Retry-After | The per-IP budget of refused requests is spent | Fix the refusals; set trust_proxy_ip behind a proxy |
invalid_scope | Every 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 ignored | Align 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_scopes | Add 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 cnf | Turn on DPoP, and have the client send a proof |
404 {"error":"protected resource metadata not configured"} | No resource_metadata | Set resource_metadata.resource |
mcpg config check: "issuer must be a bare origin, scheme://host[:port], without a path" | The issuer has a path | Use the origin alone |
mcpg config check: "config references `${secret.…}` but gateway.secrets.dir is not set" | ${secret.…} without a secrets directory | Set gateway.secrets.dir |
The client never asks for a token; anonymous tools/list is empty | require_authentication is off, so there is no 401 | Set require_authentication: true |
308 to another host | canonical_url redirects other hosts | Point clients at the canonical origin |
| Boot refused: "`governance.access.oidc_oauth` configures OIDC identity, but no identity plugin is registered" | oidc_oauth without the plugin | Declare dev.mcpg.identity.oidc under plugins[] |
Okta: requested_token_type invalid or unsupported | The client used a custom authorization server | Use the org authorization server |
Okta: invalid_target "Resource mismatch" | The client's resource differs from the resource indicator | Make the resource indicator equal resource_metadata.resource |
| Okta refuses after many requests in a month | The SSO-plan quota of 250 ID-JAGs per user, resource app and month | Lengthen 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-dpopgrant type (draft-parecki-oauth-jwt-dpop-grant). A DPoP proof accompanies thejwt-bearergrant instead. - SAML and multi-tenant subject claims (
sub_idwithsaml-nameid,aud_tenant,aud_sub) and just-in-time account linking. Subjects come fromclaim_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
- Interactive sign-in: browser sign-in through Okta, Entra ID or Keycloak at the same authorization server, and the stored IdP sign-in.
- Identity and Identity and authorization: every identity source and the order they run in.
- Policy: trust floors,
required_scopesand CEL overidentity.*. - MCP federation: calling upstreams as the user.
- Configuration reference: every key.
- Inspector authentication: what
authandloginreport. - External: the ID-JAG draft, the MCP enterprise-managed authorization extension, and Okta Cross App Access.