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 two surfaces:
- Built-in inbound verification under
governance.access— verifies JWT bearer tokens against an IdP's JWKS (live discovery or static keys). No plugin required. - 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)
The simplest path needs no plugin — point governance.access.oidc_oauth at your IdP:
governance:
access:
oidc_oauth:
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.
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.
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/source-code/plugins/identity-mtls:1.0.0"
config:
sources:
- kind: forwarded_header
header_name: 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/source-code/plugins/identity-workload:1.0.0"
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/source-code/plugins/identity-aauth:1.0.0"
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
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
governance:
minimum_trust: verified
required_scopes: ["tools:write"] # consent needed — a person token alone is refused
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. Bindings default to
header_asserted. 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/source-code/plugins/identity-api-key:1.0.0"
config:
token_sources:
- kind: header
header_name: x-api-key
keys:
- 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/source-code/plugins/identity-ldap:1.0.0"
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/source-code/plugins/identity-kerberos:1.0.0"
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/source-code/plugins/identity-saml:1.0.0"
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/source-code/plugins/identity-workload:1.0.0"
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/source-code/plugins/identity-api-key:1.0.0"
config:
token_sources: [{ kind: header, header_name: x-api-key }]
keys: [{ id: ci-bot, secret: "${env.CI_BOT_KEY}" }]
Order matters: the first plugin to return a resolved identity wins. Built-in
governance.access verification runs on the bearer-token path independently of this chain.
After identity: policy
Identity resolution produces a caller with subject_id, attributes, roles, and
groups. Policy consumes this. See the policy guide next.