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 three surfaces:

  1. 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 signed dev.mcpg.identity.oidc plugin declared under plugins[].
  2. 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.
  3. 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)

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.

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

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

yaml
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 carry cnf.jkt, and its one DPoP proof must pass every check of RFC 9449 §4.3: typ, an allowed asymmetric alg, a public key only, the signature, htm and htu against the request under a resource_metadata resource, iat within proof_max_age_secs, and ath. The proof must be of the token's key, and each proof is accepted once, across every replica.
  • A token with cnf sent as Bearer is refused. A Bearer token without cnf keeps working, so clients without DPoP keep working, unless you set required: true.
  • A refusal is 401 with WWW-Authenticate: DPoP error="invalid_token" or error="invalid_dpop_proof", and the accepted algorithms in algs.
  • The caller carries the key's thumbprint as the dpop_jkt attribute, which a policy can require. The protected resource metadata lists allowed_algs as dpop_signing_alg_values_supported.
  • nonce stays off for these tokens. Tokens of the embedded authorization server follow its own dpop block.

Enterprise license. dpop.enabled: true needs a license with the oauth.dpop feature, 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).

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

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

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

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
    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_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. A binding that names no floor takes governance.policy.tool_access.default_minimum_trust, which is header_asserted unless you set it. 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/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):

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

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

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

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