Identity — OIDC, JWKS, mTLS, SPIFFE, API keys
Configure inbound identity for humans, workloads and AI agents. The gateway verifies JWT bearers natively; richer or chained identity loads as plugins.
Identity is the first decision MCPG makes on every request. There are three surfaces:
- Bearer verification under
governance.access— verifies JWT bearer tokens of your IdP.jwks(static keys or a JWKS URL) needs no plugin;oidc_oauth(live discovery, several providers, introspection) needs the signeddev.mcpg.identity.oidcplugin declared underplugins[]. - The embedded authorization server under
governance.access.authorization_server— redeems enterprise ID-JAGs (Okta Cross App Access), optionally signs users in through your IdP, and verifies the tokens it mints. See Enterprise-managed authorization. - Identity plugins loaded under
plugins[]withclass: identity_provider— for mTLS, SPIFFE workloads, API keys, basic auth, and chained resolution.
The identity plugins
| Plugin id | When to use |
|---|---|
dev.mcpg.identity.api-key | Internal services, simple bootstrap, no IdP |
dev.mcpg.identity.basic | Legacy systems migrating from htpasswd |
dev.mcpg.identity.mtls | Service-to-service with X.509 certs from upstream TLS termination |
dev.mcpg.identity.oidc | Human users with Google / Okta / Auth0 / Entra |
dev.mcpg.identity.workload | Kubernetes / SPIFFE workloads with X.509-SVID + JWT-SVID |
dev.mcpg.identity.ldap | Enterprise directory auth — Basic credentials verified against LDAP / Active Directory, groups → roles |
dev.mcpg.identity.kerberos | Windows / enterprise SSO — Authorization: Negotiate (Kerberos / SPNEGO) verified against a service keytab |
dev.mcpg.identity.saml | Enterprise SSO — a SAML 2.0 assertion whose IdP signature is verified (XSW-resistant) |
dev.mcpg.identity.aauth | AI agents with their own cryptographic identity — per-request, proof-of-possession, issued by an AAuth Agent Provider |
Identity plugins resolve in chain order. The first plugin to resolve an identity wins; the rest short-circuit.
Built-in OIDC verification (Google, Okta, Auth0, Entra)
Point governance.access.oidc_oauth at your IdP, and declare the OIDC identity plugin
with the same providers. The plugin ships as a signed artifact that the gateway image
does not carry; a gateway with oidc_oauth and no dev.mcpg.identity.oidc entry
refuses to boot.
governance:
access:
oidc_oauth:
providers:
- issuer: "https://accounts.google.com"
audiences: ["mcpg-prod"]
verification:
kind: oidc_jwks
allowed_algs: ["RS256"]
plugins:
- id: dev.mcpg.identity.oidc
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-oidc:protocol-1"
granted_capabilities: [network_outbound] # discovery + JWKS
config:
providers:
- issuer: "https://accounts.google.com"
audiences: ["mcpg-prod"]
verification:
kind: oidc_jwks
allowed_algs: ["RS256"]
The verifier pulls JWKS via discovery on first use, refreshes on an interval, and
validates JWT bearer tokens on every request. SSRF guards block private/loopback issuer
URLs unless allow_private_issuer: true (dev only). Add more providers[] entries to
accept tokens from multiple issuers. With verification.kind: hybrid, the token's
shape picks the path: a JWT is verified against the JWKS and that verdict is final; an
opaque token goes to introspection.
Enterprise-managed authorization
governance.access.authorization_server turns the gateway into the authorization
server of its own MCP endpoint. It needs no plugin and no license.
- Enterprise-managed authorization (the MCP extension that Okta ships as Cross App Access and Agent SSO): an MCP client presents an ID-JAG your IdP issued, and the gateway mints an access token for itself. Enterprise-managed authorization covers the flow, the checks, the Okta setup and the options.
- Interactive sign-in (Enterprise license): clients that cannot present an ID-JAG sign their users in through your IdP in the browser. See Interactive sign-in for Okta, Entra ID and Keycloak.
Both work next to oidc_oauth or jwks and the identity plugins: a token whose iss
is the embedded issuer is verified by the embedded server, and any other credential by
the other verifiers.
Static JWKS (air-gapped)
When the gateway can't reach the IdP's discovery endpoint, pre-stage the JWK Set:
governance:
access:
jwks:
keys_json: '{"keys":[ ... your IdP JWK Set ... ]}'
issuer: "https://idp.internal"
audience: "mcpg-prod"
No discovery call is made — the gateway validates against the supplied keys directly.
A gateway reached on several hostnames accepts a token bound to any of them:
audience plus audiences: [...] (OIDC providers take the list under
audiences directly).
DPoP for tokens from your IdP
DPoP (RFC 9449) binds a token to a key its client holds, so a copied token is
useless without the key. When your authorization server issues DPoP-bound tokens
(with a cnf.jkt claim, in the JWT or in the introspection response), turn on
DPoP for the tokens oidc_oauth or jwks verifies:
governance:
access:
resource_metadata:
resource: "https://mcp.example.com/mcp"
oidc_oauth:
providers:
- issuer: "https://auth.example.com"
audiences: ["https://mcp.example.com/mcp"]
dpop:
enabled: true
allowed_algs: ["ES256"]
- A token sent as
Authorization: DPoP <token>is verified like a Bearer token. It must carrycnf.jkt, and its oneDPoPproof must pass every check of RFC 9449 §4.3:typ, an allowed asymmetricalg, a public key only, the signature,htmandhtuagainst the request under aresource_metadataresource,iatwithinproof_max_age_secs, andath. The proof must be of the token's key, and each proof is accepted once, across every replica. - A token with
cnfsent asBeareris refused. A Bearer token withoutcnfkeeps working, so clients without DPoP keep working, unless you setrequired: true. - A refusal is
401withWWW-Authenticate: DPoP error="invalid_token"orerror="invalid_dpop_proof", and the accepted algorithms inalgs. - The caller carries the key's thumbprint as the
dpop_jktattribute, which a policy can require. The protected resource metadata listsallowed_algsasdpop_signing_alg_values_supported. noncestaysofffor these tokens. Tokens of the embedded authorization server follow its owndpopblock.
Enterprise license.
dpop.enabled: trueneeds a license with theoauth.dpopfeature, as for the embedded authorization server.
What an unauthenticated client gets
With a bearer verifier configured (oidc_oauth, jwks, or the embedded
authorization_server), the gateway tells an OAuth-capable client where to
sign in. A call that presents no credential and is refused by policy — a tool
whose minimum_trust the anonymous caller does not meet, or an allow_if
rule — is answered with 401 and WWW-Authenticate: Bearer resource_metadata="…", pointing at the RFC 9728 document under
/.well-known/oauth-protected-resource. When
resource_metadata.scopes_supported is set, the challenge also carries
scope="…" with those scopes. A presented token that does not verify gets the
same pointer next to error="invalid_token". An authenticated caller that is
still refused keeps a bare 403: signing in again would not change the
answer.
By default an anonymous initialize and */list succeed, listing only what
the caller may see, so a client that authenticates only after a 401 never
receives one. Set governance.access.require_authentication: true to refuse
every MCP request from a caller below verified — POST, GET and DELETE
on the MCP path, every method including initialize and server/discover —
with that same 401 challenge. A request whose Origin is not allowed is
still refused with 403 first. Only the MCP path is covered: the well-known
metadata documents, the embedded authorization server's endpoints
(/oauth/token, /oauth/jwks, /oauth/revoke, /oauth/register, and the
sign-in pages /oauth/authorize, /oauth/consent, /oauth/callback and
/oauth/connect), the AAuth resource endpoints, health, readiness, metrics,
/runtime, /v0.1/servers, the /webhooks/* callbacks and plugin HTTP routes
(/plugins/{id}/{entity} and override paths) stay open to anonymous callers,
each gated as it is without the key. Config validation refuses the key unless
something can verify a caller. Enterprise-managed authorization clients start
their flow only from this 401, so set the key with the embedded authorization
server; see Configure MCPG.
With the embedded server's DPoP
on, a caller that presented a DPoP-bound token gets the challenge in the DPoP
scheme with its algs, and with dpop.required every other 401 carries a second
WWW-Authenticate: DPoP algs="…", resource_metadata="…" challenge.
The pointer, and the resource in the document, name the hostname the request
arrived on. governance.access.resource_metadata.resource is the canonical
identifier; list the other hostnames the gateway serves under
additional_resources, and each is answered with its own — a client compares
the published resource with the URL it connected to (RFC 9728 §3.3).
governance:
access:
resource_metadata:
resource: "https://gateway.example.com/mcp"
additional_resources:
- "https://mcp.acme.com/mcp"
authorization_servers: ["https://idp.example.com"]
mTLS
When MCPG sits behind a TLS-terminating proxy that injects client cert info as headers, load the mTLS identity plugin:
plugins:
- id: dev.mcpg.identity.mtls
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-mtls:protocol-1"
config:
sources:
- kind: xfcc
header: x-forwarded-client-cert
extraction:
mode: subject_cn
resolution:
trust_level: verified
auth_provider_label: mtls-front-door
For direct mTLS (no proxy), enable client-cert acceptance on the gateway listener itself
(gateway.server.tls.client_cert_required: mandatory); the plugin reads from the negotiated
session.
SPIFFE workload identity
For Kubernetes / SPIRE deployments, load the workload identity plugin:
plugins:
- id: dev.mcpg.identity.workload
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-workload:protocol-1"
config:
trustDomain: example.org
bundle:
kind: workload_api
socketPath: unix:/run/spire/agent.sock
sources:
- kind: x509_svid
- kind: jwt_svid_bearer
audiences:
- mcpg-prod
The plugin streams from the SPIRE Workload API, hot-reloads trust bundles, and stamps each
request with the resolved SPIFFE ID + selectors. Policy plugins downstream can then
authorize on spiffe://example.org/ns/prod/sa/payments.
AAuth agent identity
AAuth is an IETF draft protocol and MCPG's support is experimental; the AAuth guide walks through every access mode, the agent-provider and person-server set-ups, and the nuances. This section is the option reference.
Every identity source above answers "which human or which workload". AAuth answers "which agent" — the AI agent gets its own cryptographic identity and proves it on every request, instead of borrowing a human's OAuth token or sharing a static API key. See aauth.dev for the protocol and agentprovider.dev for running the Agent Provider that issues agent identities.
MCPG is the Resource in AAuth terms, in three of the protocol's access modes:
- Agent identity — the plugin verifies the agent token; the agent is the principal.
- Person identity (
person_tokens:) — a person server the operator trusts issuesaa-person+jwttokens naming the person an agent acts for; the person is the principal (a directedsubunder that person server). - PS authorization (
auth_tokens:+server.aauth_resource_metadata) — the gateway issues resource tokens naming the scopes it wants consented, the person server turns them intoaa-auth+jwtgrants after the person consents, and the grant'sscopebecomes the caller's scopes for tool policy.
Southbound signing, sub-agents, and session tokens (resource-managed mode) are out of
scope. Rejected credentials carry the machine-readable Signature-Error header back;
an unauthenticated caller at a declared AAuth resource gets 401 +
AAuth-Requirement naming which credential to bring.
plugins:
- id: dev.mcpg.identity.aauth
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-aauth:protocol-1"
granted_capabilities:
- network_outbound # metadata + JWKS discovery
config:
trusted_issuers: # exact `iss`, scheme included, no trailing slash
- https://sandbox.agentprovider.dev
signature_window_secs: 60 # ±created window; needs NTP-synced clocks
# expected_authority: mcp.example.com # pin when a proxy rewrites Host
That points at the agentprovider.dev sandbox, which needs no account — enrol an agent, get a token, and call your gateway. Sandbox agents are wiped at the daily 03:00 UTC reset, agent tokens live 300 s, and the signature window is 60 s, so keep the clock right. Swap the issuer for your own Agent Provider in production.
Per request the plugin enforces the RFC 9421 covered components
@method @authority @path signature-key and the created window, extracts the
aa-agent+jwt token, verifies it against the Agent Provider's JWKS — discovered from
{iss}/.well-known/aauth-agent.json, Ed25519 or ES256, SSRF-egress-admitted and
cached — then enforces proof-of-possession: the request signature must verify under the
token's cnf.jwk. A verified agent resolves to
subject_id = aauth:k7q3p9n2@sandbox.agentprovider.dev at trust_level: verified, with
ps / parent_agent / jti recorded as attributes.
Person server: whose agent, and what it may do
To accept a person server — the reference implementation is
personserver.dev (psd), with a public sandbox at
https://sandbox.personserver.dev — enable the modes on the plugin and declare the
gateway's resource role:
gateway:
server:
aauth_resource_metadata:
issuer: https://mcp.example.com # this gateway's AAuth identifier
access_mode: auth-token # tell agents to expect the consent flow
signing_key: { seed: "${env.AAUTH_RESOURCE_SEED}" } # signs resource tokens
scope_descriptions:
"tools:read": "Read-only tools"
"tools:write": "Tools that change things"
plugins:
- id: dev.mcpg.identity.aauth
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-aauth:protocol-1"
granted_capabilities: [network_outbound]
config:
trusted_issuers: ["https://sandbox.agentprovider.dev"]
person_tokens:
enabled: true
resource_identifier: https://mcp.example.com
trusted_person_servers: ["https://sandbox.personserver.dev"]
auth_tokens:
enabled: true
mcp:
capabilities:
tools:
- name: deploy.rollback
description: Roll back a production deployment.
governance:
minimum_trust: verified
required_scopes: ["tools:write"] # consent needed — a person token alone is refused
backend: { kind: http, url: "https://deploy.internal/rollback", method: post }
What happens on the wire: an agent presents a person token; tools that need only
identity work; a tool with required_scopes answers 401 with
AAuth-Requirement: requirement=auth-token; resource-token="…" — a resource token the
gateway signed, naming the missing scopes; the agent takes it to the person server, the
person consents, the agent returns with an auth token, and the call goes through with
identity.scopes holding the grant. When the person later revokes the agent, the person
server POSTs the revocation to the gateway's revocation_endpoint and that token is
refused from then on. The gateway also serves /.well-known/aauth-resource.json (with
jwks_uri, authorization_endpoint, revocation_endpoint, scope_descriptions) so
an agent that knows only the hostname can plan the flow. mcpg inspector drives all of
it as the agent: --aauth-person-server, --aauth-credential person|auth,
--aauth-scopes.
For policy, an AAuth caller looks like any verified identity: identity.issuer is the
person server (or agent provider), identity.subject_id the directed sub,
identity.scopes the grant, and identity.attributes["aauth.token_type"],
["aauth.mission_s256"], ["aauth.tenant"], ["aauth.ps"] are there for CEL — key
person identities on (issuer, subject_id), never subject_id alone.
Five things to get right:
- The trust list is fail-closed. An empty
trusted_issuersrefuses to load. Theallow_any_issueropt-in trusts any self-asserted agent identity — if you set it, downstream authorization must key on(issuer, subject_id), neversubject_idalone. - Proxies break signatures. The signature covers
@authorityand@path. PreserveHostand the path at every hop, and don't stripSignature-*. Useexpected_authoritywhen a TLS-terminating proxy rewritesHost. - Set the trust floor. A binding that names no floor takes
governance.policy.tool_access.default_minimum_trust, which isheader_assertedunless you set it. AAuth resolves atverified, so setgovernance.minimum_trust: verifiedon a tool to admit signed agents only. - Rung 1 binds the envelope, not the body. Method, authority and path are covered;
the MCP request body is not. For state-changing calls require
content-digestviaadditional_covered_componentsand verify that digest against the body downstream. - Fully-specified algorithms, never
EdDSA. AAuth forbids the polymorphic identifier: the JOSEalgmust be the fully-specifiedEd25519(orES256for P-256 hardware keys) in the token header, incnf.jwk, and on thehwkSignature-Key scheme — and it must agree with the key'skty/crv. A token signedEdDSAis rejected, and there is no flag to re-admit it. The RFC 9421algparameter insideSignature-Inputis ignored entirely, as the drafts require — the key alone signals the algorithm.
API key
Simplest option for internal services:
plugins:
- id: dev.mcpg.identity.api-key
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-api-key:protocol-1"
config:
token_sources:
- kind: header
header: x-api-key
keys:
- key_id: alice
secret: "${env.ALICE_API_KEY}"
attributes:
team: platform
resolution:
trust_level: verified
auth_provider_label: api-key
Secrets are resolved through the gateway's secret-resolver pre-walk (here from an env var)
and compared in constant time. Pull the literal secret out of YAML with a secret-provider
plugin or ${env.X} rather than committing it.
LDAP / Active Directory
Verify Authorization: Basic credentials against a directory by binding as the
caller — the directory checks the password — and map the caller's groups to roles.
The Active Directory case uses search-then-bind (look the user up by sAMAccountName,
then re-bind as their DN):
plugins:
- id: dev.mcpg.identity.ldap
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-ldap:protocol-1"
config:
url: "ldaps://dc1.corp.example.com:636"
bind:
mode: search
bind_dn: "cn=svc-mcpg,ou=svc,dc=corp,dc=example,dc=com"
bind_password: "${env.LDAP_SVC_PASSWORD}"
base_dn: "ou=people,dc=corp,dc=example,dc=com"
user_filter: "(sAMAccountName={username})" # {username} is RFC 4515 filter-escaped
subject_attribute: sAMAccountName
group_attribute: memberOf # group DNs → groups, their CNs → roles
attributes: [mail, displayName]
For a directory with predictable DNs, skip the service account with mode: direct:
bind:
mode: direct
user_dn_template: "uid={username},ou=people,dc=example,dc=org" # {username} is RFC 4514 DN-escaped
The caller's password is used only for the bind and is never logged; the service-account
password resolves through the gateway secret-resolver. A successful bind is verified
trust. A wrong password and an unknown user both return the same generic rejection (no user
enumeration), and an unreachable directory fails closed (rejected, never passed through).
This is the directory-backed counterpart to dev.mcpg.identity.basic (which checks Basic
credentials against a static hash registry) and pairs with the dev.mcpg.backend.ldap
backend (which searches the directory with a service account).
Kerberos / SPNEGO (Negotiate)
Windows-integrated / enterprise SSO. The caller (browser or service) presents
an Authorization: Negotiate GSSAPI token; the plugin verifies it against the
gateway's service keytab and resolves the Kerberos principal. Verification
is local (the keytab decrypts the service ticket) — no call to the KDC.
plugins:
- id: dev.mcpg.identity.kerberos
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-kerberos:protocol-1"
config:
keytab: "${env.MCPG_KEYTAB}" # path to the service keytab
# service_name: "HTTP@gateway.corp.example.com" # optional
strip_realm: true # alice@CORP → subject "alice"
Builds against the system GSSAPI (
libkrb5); the plugin image ships it. Active Directory group membership (the ticket PAC) is a follow-on — pair with an LDAP lookup for groups in the meantime.
SAML 2.0
Enterprise SSO via signed SAML assertions. A SAML SP front-end (or proxy) terminates the Web SSO POST and passes the assertion to mcpg in a header; the plugin verifies the IdP's XML signature against the configured certificate, enforces signature-wrapping defenses, validates conditions + audience, and maps the subject + attributes:
plugins:
- id: dev.mcpg.identity.saml
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-saml:protocol-1"
config:
idp_certificate: "${file:///etc/mcpg/idp.crt}" # PEM — the trust anchor
idp_entity_id: "https://idp.corp.example.com/saml2"
audience: "mcpg-gateway"
role_attribute: "http://schemas.example.com/role" # → roles
group_attribute: "memberOf" # → groups
# assertion_header: X-SAML-Assertion # default
The trust anchor is your configured IdP certificate — never the one embedded in the message. The plugin is XSW-resistant (exactly one assertion, a direct-child signature that covers that assertion's id) and rejects anything outside the standard signing profile (exclusive-C14N, enveloped, RSA-SHA256). Verification uses libxml2 for canonicalization + pure-Rust RSA — no OpenSSL.
Chaining
When you have multiple paths to identity, list the plugins in priority order — the first to resolve wins:
plugins:
- id: dev.mcpg.identity.workload # try SPIFFE first (service-to-service)
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-workload:protocol-1"
config: { trustDomain: example.org }
- id: dev.mcpg.identity.api-key # fall back to API key (CI bots)
class: identity_provider
source:
oci: "ghcr.io/mcpg-dev/plugins/identity-api-key:protocol-1"
config:
token_sources: [{ kind: header, header: x-api-key }]
keys: [{ key_id: ci-bot, secret: "${env.CI_BOT_KEY}" }]
Order matters: the first plugin to return a resolved identity wins. The chain runs after
the built-in governance.access verification, for external credentials only: a
resolved plugin identity replaces what the built-in verification found, and a plugin
that rejects the presented credential ends the request with 401. A token the gateway
minted itself (from the embedded authorization server, or the supervised inspector's
credential) never reaches the chain, so a plugin cannot see or replay it.
After identity: policy
Identity resolution produces a caller with subject_id, attributes, roles, and
groups. Policy consumes this. See the policy guide next.