Security
Securitybeta18 min

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_login feature (Enterprise). A gateway refuses to boot, or to reload, with a trusted_idps[].login or an interactive block its license does not grant, unless the configuration of a gateway mcpg.cloud does not license sets license.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. One trusted_idps entry at most carries the login block: 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 inWhat the client doesWho decides accessUse it for
Enterprise-managed authorization (ID-JAG)Asks the IdP for an ID-JAG, redeems it at /oauth/tokenThe IdP's policy on the client and the resourceClaude, 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 gatewayThe IdP authenticates the user; the gateway's consent page and policy decideClients that implement standard MCP authorization
SSO bearer (oidc_oauth)Presents an access token of your IdPThe IdP's audience and scopesServices 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

  • 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 for resource_metadata.resource.
  • The scopes granted are those requested that the gateway grants (allowed_scopes, else resource_metadata.scopes_supported), without openid and offline_access; a request that names none gets all of them, unless it carries authorization_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, state and nonce, and is bound to the browser with a cookie.
  • At the callback, iss must be the login IdP's issuer, and must be present when the IdP advertises authorization_response_iss_parameter_supported. The ID token must have that iss, an aud of only the gateway's client (and a matching azp), a valid iat, the nonce, an auth_time within max_age when max_age_secs is set, and a sub. Claims come from the ID token only; UserInfo is not called.
  • Authorization codes start with mcpg_ac_ and refresh tokens with mcpg_rt_, so secret scanners can find leaked ones.

What changes in the metadata

With a login block, the authorization server metadata adds:

json
{
  "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 https issuer. With a login block, authorization_server.issuer must be https:// (loopback http:// excepted).
  • One host. The sign-in pages answer only on the issuer's host (400 elsewhere). Set gateway.server.canonical_url to the issuer's origin so other hostnames redirect there, and trust_proxy_ip behind 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.

KeyDefaultMeaning
client_idrequiredThe 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_authfrom the credentialprivate_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_algfrom the keyRS256, PS256, ES256 or EdDSA
assertion_audiencetoken_endpointThe 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_secsunset0–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_namethe IdP hostShown on the consent and connect pages (1–60 characters)
authorization_endpoint, token_endpoint, revocation_endpointdiscoveredDiscovered from {issuer}/.well-known/openid-configuration (then the RFC 8414 form) unless all three are set; each must be https on allowed_hosts
timeout_ms5000500–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).

yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
          allowed_hosts: ["acme.okta.com"]
          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:

yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://acme.okta.com"
          allowed_hosts: ["acme.okta.com"]
          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> unless allow_open: true. Only SHA-256 digests of the tokens are kept.
  • A registered client is public, gets authorization_code (and refresh_token when it asks), always sees the consent page, never redeems ID-JAGs, and never satisfies a non-empty allowed_clients. Its client_id starts with mcpgdcr_.
  • https:// redirect URIs on hosts not in allowed_redirect_hosts are left out of the registration; one with none left is refused (invalid_redirect_uri).
  • Over max_clients the answer is 503; over the hourly budget, 429 with Retry-After; a bad initial access token, 401 with a Bearer challenge.
  • MCP 2026-07-28 deprecates 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.

KeyDefaultRange
access_token_ttl_secs90060–3600, and at most refresh_tokens.idle_ttl_secs
authorization_code_ttl_secs6010–600
transaction_ttl_secs60060–1800: time to consent and sign in
rate_limit_per_min120Requests per client IP across /oauth/authorize, /oauth/consent, /oauth/callback, /oauth/connect; 0 disables
revocation_check_interval_secs102–60: how soon other replicas refuse a revoked grant
consent.remember_days300–365
consent.service_namethe issuer host1–80 characters
consent.scope_descriptions{}Keys must be grantable scopes; ≤ 200 characters each
refresh_tokens.enabledtrue—
refresh_tokens.idle_ttl_secs1209600 (14 days)3600–7776000
refresh_tokens.absolute_ttl_secs2592000 (30 days)idle_ttl_secs–90 days
refresh_tokens.reuse_grace_secs00–60
refresh_tokens.max_grants_per_principal501–1000
refresh_tokens.revalidate_with_idptrue—
refresh_tokens.revalidate_interval_secsaccess_token_ttl_secs60–86400
refresh_tokens.idp_unavailable_grace_secs36000–86400
idp_sessions.revoke_supersededtrue—
idp_sessions.max_age_secsrefresh_tokens.absolute_ttl_secs3600–31536000
idp_sessions.connect_pagetrue—
dynamic_client_registrationoffSee above
storederivedSee Where state lives
state_keysderivedSee Where state lives

Check

bash
mcpg config check gateway.yaml

With a login block the check prints, after the ✓ line:

text
  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:

bash
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.

  1. In the Admin Console, go to Applications > Applications and choose Create App Integration.
  2. Choose OIDC - OpenID Connect and Web Application, then Next.
  3. Under Grant type, keep Authorization Code and add Refresh Token.
  4. Set Sign-in redirect URIs to https://mcp.acme.example/oauth/callback, the issuer followed by /oauth/callback (mcpg config check prints it).
  5. Under Assignments, choose the users or groups who sign in to the gateway, then Save.
  6. 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_id must be that value, exactly.
  7. Leave Require Demonstrating Proof of Possession (DPoP) header in token requests off: the gateway sends no DPoP proof to the IdP.
  8. 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.

OktaMCPG
Okta domain, https://{org}.okta.comtrusted_idps[].issuer, and the provider's idp_issuer
Org token endpoint, https://{org}.okta.com/oauth2/v1/tokenThe provider's idp_token_url
Login app > Client IDlogin.client_id, and the provider's client_id
Login app > public key > KIDlogin.key_id, and the provider's key_id
The private key of step 1login.private_key, and the provider's private_key (the same PEM)
Login app > Sign-in redirect URIs{issuer}/oauth/callback
Upstream resource connection > Resource indicatorThe provider's resource
Upstream resource connection > AI agent's client ID registered in this appThe 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.

yaml
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. The common and organizations endpoints 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 an x5t header in the client assertion.
  • Subject: Entra's sub differs for every application; oid identifies the user across the tenant. Set subject_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_access in 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.
yaml
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 requires iss on 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.
yaml
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 with consent: auto and a redirect URI that is not https:// (a loopback one, for example);
  • for any request with authorization_details, unless the client is registered with consent: 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), gty authorization_code and auth_time. Policy sees identity.attributes["grant_type"] = authorization_code and ["grant_id"].
  • Codes last authorization_code_ttl_secs and 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 after idle_ttl_secs without use and absolute_ttl_secs after sign-in ("the sign-in reached its absolute lifetime; sign in again"). A user holds at most max_grants_per_principal grants; a new one beyond it revokes the oldest. A refresh's scope and authorization_details may 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 every revalidate_interval_secs. A user the IdP no longer accepts loses every grant. While the IdP cannot be reached, refreshes keep working for idp_unavailable_grace_secs past the interval; after that they answer 503 and 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 its jti until 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 within revocation_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_clients or required_tenant, refuses their tokens from the next request.
  • DPoP. With DPoP on, /oauth/authorize reads dpop_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_superseded revokes, 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_secs bounds 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_issuer joins 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 and mcpg.as.connect_refused is 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.storeWhereWhen
unset, cluster.kind not single_nodeThe cluster coordinator's key-value storeEvery replica shares the state
unset, single_nodeA file store in $MCPG_STATE_DIR/oauth, else /var/lib/mcpg/oauth where /var/lib/mcpg exists, else ~/.mcpg/oauthUsers stay signed in across restarts
kind: file, dir: …That directorySingle node only
kind: memoryProcess memorySingle node only; every restart signs everyone out (mcpg config check warns)
kind: clusterThe 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.

yaml
governance:
  access:
    require_authentication: true
    resource_metadata:
      resource: "https://mcp.acme.example/mcp"
    authorization_server:
      issuer: "https://mcp.acme.example"
      signing_secret: "${env.MCPG_AS_SIGNING_SECRET}"
      trusted_idps:
        - issuer: "https://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.

MetricLabels
mcpg_as_authorize_totaloutcome, error, client_kind
mcpg_as_consent_totaldecision, client_kind
mcpg_as_callback_totaloutcome, reason
mcpg_as_connect_totalpurpose, outcome
mcpg_as_refresh_totaloutcome
mcpg_as_revocations_totaltoken_type, outcome
mcpg_as_grants_revoked_totalreason, outcome
mcpg_as_idp_requests_totalop, outcome
mcpg_as_idp_request_latency_msop
mcpg_as_idp_sessions_totalop, outcome
mcpg_as_dcr_totaloutcome
mcpg_as_rate_limited_totalendpoint
mcpg_as_revocation_poll_totaloutcome
mcpg_as_state_errors_totalop

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

SymptomCauseFix
404 "Sign-in is not offered here" at /oauth/authorizeNo login blockAdd 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'sSet 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 documentRegister it exactly; loopback URIs without a port
"code_challenge reused: every authorization request needs a new one"The client reused a PKCE challengeFix 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 queryCheck 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 tabRegister {issuer}/oauth/callback on the login client itself
No refresh token is issuedoffline_access is missing, or the IdP issued no refresh tokenKeep offline_access; allow the Refresh Token grant at the IdP
"the sign-in reached its absolute lifetime; sign in again"absolute_ttl_secs passedExpected; the user signs in again
"the refresh token was already used", and the user is signed outA spent refresh token was replayed, so the grant was revokedCheck 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 reloadedStart again from the client
Boot refused naming sso.interactive_loginThe license lacks the featureUse 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_envSet one of them
Config refused: "store.kind `file` keeps sign-in state on one replica"A file or memory store in a clusterRemove 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_post and prompt=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/token and the other endpoints answer CORS only for the origins in gateway.server.cors.

Where to read next