Security
Security6 min

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:

  1. Built-in inbound verification under governance.access — verifies JWT bearer tokens against an IdP's JWKS (live discovery or static keys). No plugin required.
  2. Identity plugins loaded under plugins[] with class: identity_provider — for mTLS, SPIFFE workloads, API keys, basic auth, and chained resolution.

The identity plugins

Plugin idWhen to use
dev.mcpg.identity.api-keyInternal services, simple bootstrap, no IdP
dev.mcpg.identity.basicLegacy systems migrating from htpasswd
dev.mcpg.identity.mtlsService-to-service with X.509 certs from upstream TLS termination
dev.mcpg.identity.oidcHuman users with Google / Okta / Auth0 / Entra
dev.mcpg.identity.workloadKubernetes / SPIFFE workloads with X.509-SVID + JWT-SVID
dev.mcpg.identity.ldapEnterprise directory auth — Basic credentials verified against LDAP / Active Directory, groups → roles
dev.mcpg.identity.kerberosWindows / enterprise SSO — Authorization: Negotiate (Kerberos / SPNEGO) verified against a service keytab
dev.mcpg.identity.samlEnterprise SSO — a SAML 2.0 assertion whose IdP signature is verified (XSW-resistant)
dev.mcpg.identity.aauthAI 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:

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

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

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

yaml
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 issues aa-person+jwt tokens naming the person an agent acts for; the person is the principal (a directed sub under 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 into aa-auth+jwt grants after the person consents, and the grant's scope becomes 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.

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

yaml
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_issuers refuses to load. The allow_any_issuer opt-in trusts any self-asserted agent identity — if you set it, downstream authorization must key on (issuer, subject_id), never subject_id alone.
  • Proxies break signatures. The signature covers @authority and @path. Preserve Host and the path at every hop, and don't strip Signature-*. Use expected_authority when a TLS-terminating proxy rewrites Host.
  • Set the trust floor. Bindings default to header_asserted. AAuth resolves at verified, so set governance.minimum_trust: verified on 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-digest via additional_covered_components and verify that digest against the body downstream.
  • Fully-specified algorithms, never EdDSA. AAuth forbids the polymorphic identifier: the JOSE alg must be the fully-specified Ed25519 (or ES256 for P-256 hardware keys) in the token header, in cnf.jwk, and on the hwk Signature-Key scheme — and it must agree with the key's kty/crv. A token signed EdDSA is rejected, and there is no flag to re-admit it. The RFC 9421 alg parameter inside Signature-Input is ignored entirely, as the drafts require — the key alone signals the algorithm.

API key

Simplest option for internal services:

yaml
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):

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

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

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

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

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