Interactive sign-in — Okta, Entra ID and Keycloak at the gateway
Let MCP clients that cannot present an ID-JAG sign their users in through your enterprise IdP at MCPG's embedded authorization server — authorization code with PKCE, consent, rotating refresh tokens, and the per-user IdP sign-in that federations use.
Enterprise license, one IdP. Interactive sign-in needs a license with the
sso.interactive_loginfeature (Enterprise). A gateway refuses to boot, or to reload, with atrusted_idps[].loginor aninteractiveblock its license does not grant, unless the configuration of a gateway mcpg.cloud does not license setslicense.non_production_use: true, which admits it with a warning. A gateway mcpg.cloud delivers a license to ignores that declaration and runs on that license. On mcpg.cloud the control plane refuses to publish such a configuration for an org whose plan lacks the feature. Onetrusted_idpsentry at most carries theloginblock: a gateway signs users in through one IdP.
Enterprise-managed authorization works when the MCP client can ask the enterprise IdP for an ID-JAG. Many clients cannot yet, command-line agents and editors among them. Interactive sign-in gives them the standard MCP authorization flow instead: the client opens a browser at the gateway's embedded authorization server, the user signs in at the enterprise IdP, and the client receives an access token and a refresh token for the gateway. The gateway is always a confidential client of the IdP, and it keeps each user's IdP sign-in so that federations can act for the user.
When to use it
| Way in | What the client does | Who decides access | Use it for |
|---|---|---|---|
| Enterprise-managed authorization (ID-JAG) | Asks the IdP for an ID-JAG, redeems it at /oauth/token | The IdP's policy on the client and the resource | Claude, VS Code and other clients with Enterprise Managed Auth |
| Interactive sign-in (this page) | Authorization code with PKCE at /oauth/authorize, sign-in at the IdP through the gateway | The IdP authenticates the user; the gateway's consent page and policy decide | Clients that implement standard MCP authorization |
SSO bearer (oidc_oauth) | Presents an access token of your IdP | The IdP's audience and scopes | Services and clients that already hold a token of your IdP |
The same user is the same principal whichever way they come in: the IdP's
claim mappings, allowed_clients, required_tenant and principal_issuer
apply to sign-in exactly as to ID-JAGs. Sessions, tasks, quotas and policy see one
user.
How it works
sequenceDiagram autonumber participant U as User (browser) participant C as MCP client participant G as MCPG participant I as Enterprise IdP C->>G: POST /mcp (no token) G-->>C: 401 WWW-Authenticate: Bearer resource_metadata="…" C->>G: GET resource metadata, then AS metadata C->>U: open /oauth/authorize?client_id, redirect_uri,<br/>code_challenge (S256), resource, scope, state U->>G: GET /oauth/authorize G-->>U: consent page (when the client needs one) U->>G: POST /oauth/consent (approve) G-->>U: redirect to the IdP<br/>(gateway client, its own PKCE, state, nonce) U->>I: sign in I-->>U: redirect to /oauth/callback?code, state, iss U->>G: GET /oauth/callback G->>I: redeem the code (client authentication) I-->>G: ID token, IdP refresh token G->>G: check iss, the ID token and claim mappings<br/>store the IdP sign-in sealed, per user G-->>U: redirect to the client with code, state, iss C->>G: POST /oauth/token (authorization_code, code_verifier) G-->>C: access token and refresh token C->>G: POST /mcp, Authorization: Bearer
- The client's PKCE challenge must be
S256, and each challenge is used once. - A request names at most one
resource, which must be one of the gateway's resource identifiers (a trailing/is ignored). Without it, the token is forresource_metadata.resource. - The scopes granted are those requested that the gateway grants
(
allowed_scopes, elseresource_metadata.scopes_supported), withoutopenidandoffline_access; a request that names none gets all of them, unless it carriesauthorization_details: such a request is granted no scope. - Every authorization response, error or success, carries
iss(RFC 9207). - The gateway's own request to the IdP uses its own PKCE verifier,
stateandnonce, and is bound to the browser with a cookie. - At the callback,
issmust be the login IdP's issuer, and must be present when the IdP advertisesauthorization_response_iss_parameter_supported. The ID token must have thatiss, anaudof only the gateway's client (and a matchingazp), a validiat, thenonce, anauth_timewithinmax_agewhenmax_age_secsis set, and asub. Claims come from the ID token only; UserInfo is not called. - Authorization codes start with
mcpg_ac_and refresh tokens withmcpg_rt_, so secret scanners can find leaked ones.
What changes in the metadata
With a login block, the authorization server metadata adds:
{
"grant_types_supported": [
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"authorization_code",
"refresh_token"
],
"authorization_endpoint": "https://mcp.acme.example/oauth/authorize",
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true,
"revocation_endpoint": "https://mcp.acme.example/oauth/revoke",
"revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}
refresh_token is listed while refresh_tokens.enabled is on,
registration_endpoint while dynamic client registration is on, and
revocation_endpoint_auth_methods_supported repeats the token endpoint's
methods. The resource metadata then lists only the embedded issuer in
authorization_servers (when you leave the list empty), because a client signs in
at the first entry. The enterprise-managed-authorization capability does not
change, and ID-JAG redemption keeps working.
Configure
Prerequisites
- An
httpsissuer. With aloginblock,authorization_server.issuermust behttps://(loopbackhttp://excepted). - One host. The sign-in pages answer only on the issuer's host (
400elsewhere). Setgateway.server.canonical_urlto the issuer's origin so other hostnames redirect there, andtrust_proxy_ipbehind a proxy so the forwarded host and client IP are used. - A place for state (grants, refresh tokens, stored IdP sign-ins) and a key that seals it: see Where state lives.
- The license feature
sso.interactive_login.
The login block
The login block on one trusted_idps entry is the gateway's OIDC client at that
IdP. Register {issuer}/oauth/callback as its redirect URI at the IdP;
mcpg config check prints it.
| Key | Default | Meaning |
|---|---|---|
client_id | required | The client ID the IdP issued to the gateway (1–255 characters) |
client_secret | — | For client_secret_basic or client_secret_post; at least 16 bytes |
private_key | — | PEM (PKCS#8, or PKCS#1 for RSA) for private_key_jwt; trial-signed at load |
client_auth | from the credential | private_key_jwt with private_key, client_secret_basic with client_secret. Exactly one credential |
key_id | — | The kid of the client assertion (≤ 128 characters) |
signing_alg | from the key | RS256, PS256, ES256 or EdDSA |
assertion_audience | token_endpoint | The assertion aud: the endpoint URL it is posted to (Okta), or issuer |
scopes | [openid, profile, email, offline_access] | Must include openid; at most 20 |
max_age_secs | unset | 0–86400; sent as max_age, and the ID token must carry auth_time |
authorize_params | {} | Extra IdP parameters such as acr_values or domain_hint; the ones the gateway sets are refused |
display_name | the IdP host | Shown on the consent and connect pages (1–60 characters) |
authorization_endpoint, token_endpoint, revocation_endpoint | discovered | Discovered from {issuer}/.well-known/openid-configuration (then the RFC 8414 form) unless all three are set; each must be https on allowed_hosts |
timeout_ms | 5000 | 500–10000, per request to the IdP |
Without offline_access the IdP issues no refresh token: the gateway then issues
no refresh token either (while revalidate_with_idp is on), and federations that
present the stored sign-in cannot work. mcpg config check warns about it.
Clients
Three kinds of client sign users in.
Registered clients (clients[] with redirect_uris). https:// redirect URIs
match byte for byte. Loopback http://127.0.0.1, http://[::1] and
http://localhost match any port (RFC 8252), so register them without one. At most
10 URIs. Such a client gets [authorization_code, refresh_token] by default and
redeems no ID-JAG unless its grant_types lists the jwt-bearer grant. consent
is auto (skip the page when every redirect URI is https://), always, or
skip (refused with a loopback redirect URI).
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"]
login:
client_id: "0oa1mcpgagent"
private_key: "${env.OKTA_AGENT_KEY}"
clients:
- client_id: "acme-mcp-cli"
client_name: "Acme MCP CLI"
token_endpoint_auth_method: none # a public client
redirect_uris: ["http://127.0.0.1/callback"]
- client_id: "acme-portal"
client_name: "Acme Portal"
client_secret: "${env.PORTAL_SECRET}"
redirect_uris: ["https://portal.acme.example/oauth/callback"]
consent: auto # https only: no consent page
Metadata-document clients. A client that identifies with a Client ID Metadata
Document URL on a host in client_id_metadata_documents.allowed_hosts needs no
registration. It signs users in only when its document carries client_name and
redirect_uris, response_types is absent or ["code"], and grant_types
([authorization_code] when absent) holds authorization_code; it gets refresh
tokens only when grant_types also holds refresh_token.
redirect_uri_policy: same_host (the default) accepts https:// redirect URIs on
exactly the document's host; allowed_hosts accepts any admitted host. Loopback
redirect URIs are always allowed.
Dynamically registered clients (RFC 7591), off by default. Turn it on for clients that have neither a registration nor a metadata document:
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"]
login:
client_id: "0oa1mcpgagent"
private_key: "${env.OKTA_AGENT_KEY}"
interactive:
dynamic_client_registration:
enabled: true
initial_access_tokens: ["${env.MCPG_DCR_TOKEN}"] # each ≥ 32 bytes
allowed_redirect_hosts: [] # empty = loopback redirect URIs only
client_ttl_secs: 2592000 # removed after 30 days unused
max_clients: 1000
registrations_per_hour_per_ip: 20
- A registration needs
Authorization: Bearer <initial access token>unlessallow_open: true. Only SHA-256 digests of the tokens are kept. - A registered client is public, gets
authorization_code(andrefresh_tokenwhen it asks), always sees the consent page, never redeems ID-JAGs, and never satisfies a non-emptyallowed_clients. Itsclient_idstarts withmcpgdcr_. https://redirect URIs on hosts not inallowed_redirect_hostsare left out of the registration; one with none left is refused (invalid_redirect_uri).- Over
max_clientsthe answer is503; over the hourly budget,429withRetry-After; a bad initial access token,401with a Bearer challenge. - MCP
2026-07-28deprecates dynamic registration in favour of metadata documents.
The interactive block
Every key has a default; the block is needed only to change one. It is refused
without a login block.
| Key | Default | Range |
|---|---|---|
access_token_ttl_secs | 900 | 60–3600, and at most refresh_tokens.idle_ttl_secs |
authorization_code_ttl_secs | 60 | 10–600 |
transaction_ttl_secs | 600 | 60–1800: time to consent and sign in |
rate_limit_per_min | 120 | Requests per client IP across /oauth/authorize, /oauth/consent, /oauth/callback, /oauth/connect; 0 disables |
revocation_check_interval_secs | 10 | 2–60: how soon other replicas refuse a revoked grant |
consent.remember_days | 30 | 0–365 |
consent.service_name | the issuer host | 1–80 characters |
consent.scope_descriptions | {} | Keys must be grantable scopes; ≤ 200 characters each |
refresh_tokens.enabled | true | — |
refresh_tokens.idle_ttl_secs | 1209600 (14 days) | 3600–7776000 |
refresh_tokens.absolute_ttl_secs | 2592000 (30 days) | idle_ttl_secs–90 days |
refresh_tokens.reuse_grace_secs | 0 | 0–60 |
refresh_tokens.max_grants_per_principal | 50 | 1–1000 |
refresh_tokens.revalidate_with_idp | true | — |
refresh_tokens.revalidate_interval_secs | access_token_ttl_secs | 60–86400 |
refresh_tokens.idp_unavailable_grace_secs | 3600 | 0–86400 |
idp_sessions.revoke_superseded | true | — |
idp_sessions.max_age_secs | refresh_tokens.absolute_ttl_secs | 3600–31536000 |
idp_sessions.connect_page | true | — |
dynamic_client_registration | off | See above |
store | derived | See Where state lives |
state_keys | derived | See Where state lives |
Check
mcpg config check gateway.yaml
With a login block the check prints, after the ✓ line:
interactive sign-in through https://acme.okta.com (client `0oa1mcpgagent`); needs a license with the `sso.interactive_login` feature
register this sign-in redirect URI at the IdP: https://mcp.acme.example/oauth/callback
IdP endpoints not configured are discovered at boot from https://acme.okta.com/.well-known/openid-configuration
IdP scopes: openid profile email offline_access
sign-in state: cluster coordinator (nats)
state key: derived from cluster.state_encryption_key_env (`MCPG_STATE_KEY`)
It also warns when the embedded issuer is not first in an explicit
authorization_servers list, when scopes lacks offline_access, when
canonical_url is on another origin than the issuer, when the store is process
memory on a single node, when a redirect URI uses localhost (prefer
127.0.0.1), when an Okta custom authorization server is the login IdP of
federations that present the stored sign-in, and when such a federation's issuer
plugin is not configured with the login client. The license is checked at boot.
Okta
Use the org authorization server, https://{org}.okta.com, as the trusted
IdP. It signs users in and issues ID-JAGs, so one trusted_idps entry serves
both. A custom authorization server (/oauth2/…) issues no ID-JAGs, which breaks
the federations that present the stored sign-in.
The gateway signs users in with an OIDC Web Application of its own, the login app. It is not the resource app of enterprise-managed authorization, which only names the gateway as a resource. For sign-in alone, the login app is all you need. When federations also call upstreams with Cross App Access, the login app is also the app linked to the gateway's AI agent in Okta: a federation exchanges the stored sign-in only with the login app's client ID.
Step 1: create the login key. The gateway authenticates to Okta with
private_key_jwt, so no shared secret leaves the gateway. Generate an RSA key as
a PKCS#8 PEM, and print its public half as the JWK Okta asks for:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out okta-agent.pem
python3 - "$(openssl rsa -in okta-agent.pem -noout -modulus | cut -d= -f2)" <<'EOF'
import base64, json, sys
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
print(json.dumps({"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "mcpg-2026-09",
"e": "AQAB", "n": b64(bytes.fromhex(sys.argv[1]))}))
EOF
openssl genpkey uses the public exponent 65537, which is AQAB in the JWK. The
PEM in okta-agent.pem is OKTA_AGENT_KEY: give it to the gateway as
${env.OKTA_AGENT_KEY}, or as ${secret.OKTA_AGENT_KEY} from the secrets
directory, and keep no other copy. A P-256 key works too: the login block and
the Cross App Access provider both sign ES256 with it.
Step 2: create the login app.
- In the Admin Console, go to Applications > Applications and choose Create App Integration.
- Choose OIDC - OpenID Connect and Web Application, then Next.
- Under Grant type, keep Authorization Code and add Refresh Token.
- Set Sign-in redirect URIs to
https://mcp.acme.example/oauth/callback, the issuer followed by/oauth/callback(mcpg config checkprints it). - Under Assignments, choose the users or groups who sign in to the gateway, then Save.
- On the General tab, under Client Credentials, choose Edit, set
Client authentication to Public key / Private key, choose Add key,
paste the JWK of step 1, and save. Okta lists the key with its KID:
login.key_idmust be that value, exactly. - Leave Require Demonstrating Proof of Possession (DPoP) header in token requests off: the gateway sends no DPoP proof to the IdP.
- Copy the Client ID: it is
login.client_id.
Step 3, for Cross App Access upstreams only: register the gateway as an AI agent. Go to Directory > AI agents, choose Register AI agent > Register manually, and under User access choose Select an existing app and the login app (Okta offers this only while you register the agent). For each upstream, add a resource connection to the upstream's resource app, with the values its operator gives you, then Activate the agent.
| Okta | MCPG |
|---|---|
Okta domain, https://{org}.okta.com | trusted_idps[].issuer, and the provider's idp_issuer |
Org token endpoint, https://{org}.okta.com/oauth2/v1/token | The provider's idp_token_url |
| Login app > Client ID | login.client_id, and the provider's client_id |
| Login app > public key > KID | login.key_id, and the provider's key_id |
| The private key of step 1 | login.private_key, and the provider's private_key (the same PEM) |
| Login app > Sign-in redirect URIs | {issuer}/oauth/callback |
| Upstream resource connection > Resource indicator | The provider's resource |
| Upstream resource connection > AI agent's client ID registered in this app | The provider's redeem_client_id: the gateway's client at the upstream |
"The provider" is the dev.mcpg.credential.oauth-id-jag provider of the
federation; see
Cross App Access upstreams.
Keep offline_access in the login scopes (the default): without it Okta issues no
refresh token, and federations that present the stored sign-in cannot work. Okta
wants the token endpoint URL as the assertion audience, which is the default
assertion_audience: token_endpoint.
gateway:
server:
canonical_url: "https://mcp.acme.example"
trust_proxy_ip: true
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_keys:
- alg: ES256
kid: "as-2026-09"
private_key: "${env.MCPG_AS_SIGNING_KEY}"
allowed_scopes: ["mcp:tools"]
trusted_idps:
- issuer: "https://acme.okta.com"
allowed_hosts: ["acme.okta.com"]
allowed_algs: ["RS256"]
login:
client_id: "0oa1mcpgagent" # the login app's Client ID
private_key: "${env.OKTA_AGENT_KEY}" # the RSA PEM of step 1
key_id: "mcpg-2026-09" # the KID Okta lists for its public key
display_name: "Acme Okta"
client_id_metadata_documents:
allowed_hosts: ["claude.ai", "vscode.dev"]
clients:
- client_id: "claude-ema" # ID-JAG redemption
client_secret: "${env.CLAUDE_EMA_SECRET}"
- client_id: "acme-mcp-cli" # interactive sign-in
client_name: "Acme MCP CLI"
token_endpoint_auth_method: none
redirect_uris: ["http://127.0.0.1/callback"]
interactive:
consent:
service_name: "Acme MCP Gateway"
scope_descriptions:
"mcp:tools": "Use the tools this gateway exposes"
The interactive-login-okta.yaml deployment example under
examples/deployments/ in the distribution is the complete version, with a NATS
cluster and a Cross App Access federation.
Microsoft Entra ID
- Issuer:
https://login.microsoftonline.com/<tenant-id>/v2.0, with your tenant ID. Thecommonandorganizationsendpoints do not work: their discovery document does not name one issuer. - App registration: add a Web platform with the redirect URI
https://mcp.acme.example/oauth/callback, and create a client secret. The gateway cannot authenticate with an Entra certificate credential, which needs anx5theader in the client assertion. - Subject: Entra's
subdiffers for every application;oididentifies the user across the tenant. Setsubject_claim: oid. - Groups: add the groups claim to the ID token in Token configuration. When a user belongs to more groups than Entra puts in a token, Entra sends an overage indicator instead of the list, and the gateway reads no groups for that user: it reads the ID token only.
- Keep
offline_accessin the scopes. - Entra ID issues no ID-JAGs (as of September 2026, per Microsoft): use it for interactive sign-in only, not for enterprise-managed authorization or outbound Cross App Access.
gateway:
server:
canonical_url: "https://mcp.acme.example"
trust_proxy_ip: true
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://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0"
allowed_hosts: ["login.microsoftonline.com"]
allowed_algs: ["RS256"]
claim_mappings:
subject_claim: oid
group_claim_paths: ["groups"]
login:
client_id: "11111111-1111-1111-1111-111111111111"
client_secret: "${env.ENTRA_CLIENT_SECRET}"
client_id_metadata_documents:
allowed_hosts: ["claude.ai", "vscode.dev"]
Keycloak
- Issuer:
https://sso.acme.example/realms/<realm>. - Client: an OpenID Connect client with Client authentication on
(confidential) and the Standard flow; add the valid redirect URI
https://mcp.acme.example/oauth/callback, and copy the client secret. - Groups and roles: add a Group Membership mapper with Add to ID token, and turn on Add to ID token for the realm roles mapper, so the ID token carries what the claim mappings read.
- Keycloak advertises
authorization_response_iss_parameter_supported, so the gateway requiresisson every callback. - Keep
offline_access: Keycloak then issues an offline token, which outlives the browser session. - Keycloak issues no ID-JAGs (its 26.7 support is experimental and covers the receiving side only): use it for interactive sign-in only.
gateway:
server:
canonical_url: "https://mcp.acme.example"
trust_proxy_ip: 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://sso.acme.example/realms/acme"
allowed_hosts: ["sso.acme.example"]
allowed_algs: ["RS256"]
claim_mappings:
group_claim_paths: ["groups"]
role_claim_paths: ["realm_access.roles"]
login:
client_id: "mcpg"
client_secret: "${env.KEYCLOAK_CLIENT_SECRET}"
clients:
- client_id: "acme-mcp-cli"
client_name: "Acme MCP CLI"
token_endpoint_auth_method: none
redirect_uris: ["http://127.0.0.1/callback"]
Consent
The consent page shows the client's name (and, for a metadata-document client,
the document's host), the redirect URI, the resource, the scopes with their
scope_descriptions, any authorization details, and the IdP the user signs in
at. It appears:
- for every dynamically registered client and every metadata-document client;
- for a registered client with
consent: always, or withconsent: autoand a redirect URI that is nothttps://(a loopback one, for example); - for any request with
authorization_details, unless the client is registered withconsent: skip; - whenever the request carries
prompt=consent.
An approval can be remembered for remember_days in a cookie of the approving
browser (__Host-mcpg_consent, sealed with the state key, so it cannot be read or
forged). Only approvals for an https:// redirect URI of a registered or
metadata-document client are remembered, for that exact client, redirect URI,
resource and set of scopes. Loopback redirect URIs and dynamically registered
clients ask every time, because any program on the user's computer can receive a
loopback redirect and a registration is self-asserted. A replaced state key
forgets every remembered approval.
Tokens, refresh and revocation
- Access tokens last
access_token_ttl_secs(15 minutes by default) and carry the grant's ID (gid),gtyauthorization_codeandauth_time. Policy seesidentity.attributes["grant_type"]=authorization_codeand["grant_id"]. - Codes last
authorization_code_ttl_secsand are redeemed once, with the client's PKCE verifier. A second redemption revokes the grant the code issued. - Refresh tokens rotate on every use. A spent one presented again revokes the
whole grant, unless it arrives within
reuse_grace_secs, which lets a client that lost an answer receive the same successor. A grant expires afteridle_ttl_secswithout use andabsolute_ttl_secsafter sign-in ("the sign-in reached its absolute lifetime; sign in again"). A user holds at mostmax_grants_per_principalgrants; a new one beyond it revokes the oldest. A refresh'sscopeandauthorization_detailsmay narrow the new access token, never the grant. - Revalidation. With
revalidate_with_idp(the default), a refresh redeems the user's stored IdP refresh token at the IdP at most everyrevalidate_interval_secs. A user the IdP no longer accepts loses every grant. While the IdP cannot be reached, refreshes keep working foridp_unavailable_grace_secspast the interval; after that they answer503and the token stays unspent, so the client can retry. - Revocation.
POST /oauth/revoke(RFC 7009) takes a refresh token or an access token of a grant, and revokes the grant; an access token an ID-JAG redemption minted is revoked by itsjtiuntil it expires. The client authenticates as at the token endpoint and shares its rate limit. This replica refuses the revoked tokens at once, the others withinrevocation_check_interval_secs. When the user holds no other grant, the stored IdP sign-in ends too, and its IdP refresh token is revoked at the IdP. - Configuration revokes too. Removing the IdP or the client, or narrowing
allowed_clientsorrequired_tenant, refuses their tokens from the next request. - DPoP. With DPoP on,
/oauth/authorizereadsdpop_jkt, the code is then redeemed only with a proof of that key, and a public client's refresh token is bound to its proof key.
The IdP sign-in MCPG keeps for each user
When a user signs in, the gateway keeps their IdP sign-in (the IdP refresh token
and ID token), sealed, one per user: every MCP client of that user shares it.
It is used only at the IdP's token and revocation endpoints: to check the
sign-in on refresh, and as the subject token of a federation with
subject_token: idp_refresh_token or idp_id_token. It is never given to an MCP
client.
revoke_supersededrevokes, at the IdP, a replaced IdP refresh token of another IdP subject that the claim mappings name as the same user. A replaced token of the same subject is never revoked, because some IdPs (Keycloak, PingFederate) revoke per user and client and would end the new sign-in too.max_age_secsbounds how long a stored sign-in is kept; each successful refresh at the IdP restarts it.- The connect page. A user who reaches the gateway with an ID-JAG, or with an
SSO token that
principal_issuerjoins to the IdP, has no stored sign-in until they sign in once at{issuer}/oauth/connect(connect_page, on by default). The page stores the sign-in only when the IdP names the same user; otherwise nothing is stored andmcpg.as.connect_refusedis audited. - Links in MCP. A federated tool call that needs the stored sign-in, from a client that declares URL-mode elicitation, is answered with a link to the connect page for that caller alone. See A link to store the sign-in.
- Federations pick the sign-in by the caller's principal and exchange it only at the IdP that issued it: The caller's stored enterprise sign-in.
Where state lives
Grants, refresh tokens, codes, registrations and stored IdP sign-ins live in the sign-in store, every record sealed with XChaCha20-Poly1305 under the state key.
interactive.store | Where | When |
|---|---|---|
unset, cluster.kind not single_node | The cluster coordinator's key-value store | Every replica shares the state |
unset, single_node | A file store in $MCPG_STATE_DIR/oauth, else /var/lib/mcpg/oauth where /var/lib/mcpg exists, else ~/.mcpg/oauth | Users stay signed in across restarts |
kind: file, dir: … | That directory | Single node only |
kind: memory | Process memory | Single node only; every restart signs everyone out (mcpg config check warns) |
kind: cluster | The coordinator (process memory on single_node) | — |
memory and file are refused when cluster.kind is not single_node: they
hold one replica's state only. A file store is created readable by the gateway's
user only, is opened by one gateway process at a time (a second refuses to start),
flushes every write before it counts, and keeps at most 200000 records (256 MiB);
sign-ins beyond that answer 503. A gateway that cannot open the directory
refuses to start: on a read-only root filesystem, mount a writable volume or set
store.dir.
The state key. interactive.state_keys lists {kid, secret} entries (the
secret is 32 bytes, URL-safe base64: openssl rand -base64 32 | tr '+/' '-_'),
newest first: the first seals, every entry opens. To rotate, prepend a new key and
keep the old one for refresh_tokens.absolute_ttl_secs. Unset, the key derives
from cluster.state_encryption_key_env; a file store without either generates one
on first start at state.key in its directory (mode 0600, never logged). Back it
up with the store: the stored sign-ins cannot be opened without it. To move it
into the configuration, list it with kid generated and the file's contents as
its secret. The cluster store refuses to start without a key.
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://sso.acme.example/realms/acme"
allowed_hosts: ["sso.acme.example"]
login:
client_id: "mcpg"
client_secret: "${env.KEYCLOAK_CLIENT_SECRET}"
clients:
- client_id: "acme-mcp-cli"
token_endpoint_auth_method: none
redirect_uris: ["http://127.0.0.1/callback"]
interactive:
store:
kind: file
dir: "/var/lib/mcpg/oauth"
state_keys:
- kid: "2026-09"
secret: "${env.MCPG_AS_STATE_KEY}"
- kid: "generated" # the key the store generated before
secret: "${env.MCPG_AS_OLD_STATE_KEY}"
Operations
Metrics.
| Metric | Labels |
|---|---|
mcpg_as_authorize_total | outcome, error, client_kind |
mcpg_as_consent_total | decision, client_kind |
mcpg_as_callback_total | outcome, reason |
mcpg_as_connect_total | purpose, outcome |
mcpg_as_refresh_total | outcome |
mcpg_as_revocations_total | token_type, outcome |
mcpg_as_grants_revoked_total | reason, outcome |
mcpg_as_idp_requests_total | op, outcome |
mcpg_as_idp_request_latency_ms | op |
mcpg_as_idp_sessions_total | op, outcome |
mcpg_as_dcr_total | outcome |
mcpg_as_rate_limited_total | endpoint |
mcpg_as_revocation_poll_total | outcome |
mcpg_as_state_errors_total | op |
The token endpoint's mcpg_ema_token_requests_total counts sign-in grants too,
with grant authorization_code or refresh_token.
Audit. mcpg.as.login, mcpg.as.consent, mcpg.as.token_issued,
mcpg.as.code_replay_detected, mcpg.as.refresh_reuse_detected,
mcpg.as.grant_revoked, mcpg.as.token_revoked, mcpg.as.idp_session_removed,
mcpg.as.client_registered and mcpg.as.connect_refused. Refusals are
mcpg.auth.failed with auth_method as_authorize, as_callback, as_token,
as_revoke or as_register. No record carries a code, a verifier, a state, a
token, a cookie or a key.
Rate limits. interactive.rate_limit_per_min covers the browser endpoints
(429 with a page); authorization_server.rate_limit_per_min covers
/oauth/token, /oauth/revoke and /oauth/register. Both find the client IP as
trust_proxy_ip says. The browser endpoints never answer CORS.
Reloads and clusters. Changes apply on hot reload. In a cluster, every replica
needs the same signing keys and state key; revocations reach the other replicas
within revocation_check_interval_secs.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
404 "Sign-in is not offered here" at /oauth/authorize | No login block | Add one to a trusted_idps entry |
400 "The request did not arrive at the address this sign-in service is published at." | The request's host is not the issuer's | Set canonical_url to the issuer origin; set trust_proxy_ip behind a proxy |
| "redirect_uri is not registered for this client; …" | The client's redirect URI is not in redirect_uris or its document | Register it exactly; loopback URIs without a port |
| "code_challenge reused: every authorization request needs a new one" | The client reused a PKCE challenge | Fix the client |
| The callback is refused: "the authorization response names another issuer than the login IdP" or "carries no iss, which the login IdP always sends" | A mix-up, or a proxy that drops the query | Check the IdP and the callback URL |
The sign-in fails after the IdP (ID token audience, azp or nonce) | Another client's ID token, or a stale browser tab | Register {issuer}/oauth/callback on the login client itself |
| No refresh token is issued | offline_access is missing, or the IdP issued no refresh token | Keep offline_access; allow the Refresh Token grant at the IdP |
| "the sign-in reached its absolute lifetime; sign in again" | absolute_ttl_secs passed | Expected; the user signs in again |
| "the refresh token was already used", and the user is signed out | A spent refresh token was replayed, so the grant was revoked | Check the client stores the rotated token; consider reuse_grace_secs |
| "This sign-in expired or was already used" | transaction_ttl_secs passed, or the page was reloaded | Start again from the client |
Boot refused naming sso.interactive_login | The license lacks the feature | Use an Enterprise license, or license.non_production_use: true outside production on a gateway mcpg.cloud does not license |
| Config refused: "the cluster store needs a key to seal sign-in state" | A cluster store without state_keys or cluster.state_encryption_key_env | Set one of them |
| Config refused: "store.kind `file` keeps sign-in state on one replica" | A file or memory store in a cluster | Remove store, or use kind: cluster |
What is not implemented
- More than one login IdP, or an IdP chooser.
- Pushed authorization requests (PAR), JWT-secured authorization requests (JAR),
response_mode=form_postandprompt=none. - Back-channel logout and the Shared Signals Framework.
- Private-use URI scheme redirects (
com.example.app:/callback). - RFC 7592 client management, and confidential dynamically registered clients.
- Claims from the UserInfo endpoint.
- Entra ID certificate client authentication.
- A command that revokes every grant of a user.
- MCPG as an OpenID Provider (no
openid-configuration, no ID tokens for clients). - CORS on the sign-in pages (
/oauth/authorize,/oauth/consent,/oauth/callback,/oauth/connect)./oauth/tokenand the other endpoints answer CORS only for the origins ingateway.server.cors.
Where to read next
- Enterprise-managed authorization: ID-JAG redemption, the checks, DPoP and rich authorization requests.
- MCP federation: calling upstreams with the stored sign-in.
- Identity and Policy.
- Inspector authentication: signing in with the inspector.
- Configuration reference.