MastertheMesh
agentgateway · kgateway · Reference
Visual reference

Solo external auth service — plugin chain & AuthConfig

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

There is one Solo external auth service. The same binary ships bundled inside both agentgateway enterprise and kgateway enterprise (and gloo-mesh-enterprise before it). Same plugin set, same extauth.solo.io/AuthConfig CRD — only the wrapper policy CRD differs. This page covers what the service can do, the request flow, the CRDs that wire it, and an OAuth2 introspection + OPA Rego sample for per-subject MCP tool authorization.

ext-auth-service AuthConfig JWT · JWKS OAuth2 introspection OPA · Rego booleanExpr

People often miss that Solo already has its own production ext_authz server and reach straight for a custom gRPC service. The plugin set is wider than just OAuth — and OPA is in it, both in-process and via an external OPA server. Before standing up a parallel ext_authz, it's worth knowing exactly what ext-auth-service can already do and how to chain it.

1 · What the service can do

An AuthConfig declares one or more auth configs — each one a named block of YAML that selects a built-in capability. Multiple configs can be chained with a booleanExpr (see §6). The capabilities the service exposes:

Click each capability to see the AuthConfig snippet.

oauth2.oidcAuthorizationCode Full OIDC authorization-code flow — IdP login, callback handling, session cookie stored in Redis. Use for browser-based access.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: oidc-login
  namespace: gloo-system
spec:
  configs:
  - oauth2:
      oidcAuthorizationCode:
        appUrl: https://app.example.com
        callbackPath: /callback
        clientId: my-app
        clientSecretRef:
          name: oidc-client-secret
          namespace: gloo-system
        issuerUrl: https://login.example.com/
        scopes:
        - openid
        - profile
        - email
        session:
          redis:
            cookieName: session
            options:
              host: redis.gloo-system.svc:6379
oauth2.accessTokenValidation.introspection Opaque access-token introspection (RFC 7662). The service calls the IdP's introspection endpoint and caches the response in memory.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: token-introspection
  namespace: gloo-system
spec:
  configs:
  - name: oauth
    oauth2:
      accessTokenValidation:
        introspection:
          introspectionUrl: https://idp.example.com/oauth2/introspect
          clientId: gateway
          clientSecretRef:
            name: idp-client-secret
            namespace: gloo-system
        cacheTimeout: 10m
        userIdAttributeName: sub
oauth2.accessTokenValidation.jwt Local JWT signature verification against a JWKS (remote or local), plus issuer/audiences checks and claimsToHeaders for downstream propagation.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: jwt-verify
  namespace: gloo-system
spec:
  configs:
  - name: jwt
    oauth2:
      accessTokenValidation:
        jwt:
          remoteJwks:
            url: https://idp.example.com/.well-known/jwks.json
            refreshInterval: 1h
          issuer: https://idp.example.com/
          audiences:
          - mcp-gateway
          claimsToHeaders:
          - claim: sub
            header: x-user-id
          - claim: tenant
            header: x-tenant
opaAuth In-process OPA. Rego policy loaded from a Kubernetes ConfigMap, evaluated per request inside the service — no extra hop.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: opa-allow
  namespace: gloo-system
spec:
  configs:
  - name: opa
    opaAuth:
      modules:
      - name: policy
        namespace: gloo-system
      query: "data.mcp.allow == true"

The referenced ConfigMap holds the Rego under a policy.rego key — see §4 for the full sample.

opaServerAuth Talks to an external OPA server (sidecar or standalone). Use when you already operate an OPA control plane that pulls bundles — the policy lives in OPA, ext-auth-service just queries it.

Use-case: you already run OPA with a bundle server (Styra DAS, an S3 bundle, an internal control plane) and want ext-auth-service to delegate the decision rather than embed the Rego itself. The in-process opaAuth above is the right call if you don't already have OPA infrastructure — it removes a hop.

Exact field shape (server URL, package, query) varies by version — see the Solo OPA AuthConfig docs for the schema that ships with your install.

apiKeyAuth API-key auth backed by Kubernetes Secrets, matched either by label selector or direct secretRefs.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: api-key
  namespace: gloo-system
spec:
  configs:
  - apiKeyAuth:
      headerName: x-api-key
      labelSelector:
        team: platform
---
apiVersion: v1
kind: Secret
type: extauth.solo.io/apikey
metadata:
  name: customer-a-key
  namespace: gloo-system
  labels:
    team: platform
data:
  api-key: <base64-of-key>
basicAuth HTTP Basic auth with Apache htpasswd-style (APR1) hashes.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: basic
  namespace: gloo-system
spec:
  configs:
  - basicAuth:
      realm: gateway
      apr:
        users:
          alice:
            salt: TYiryv0/
            hashedPassword: 8BvzLUO9IfGPGGsPnAgSu1
ldap Bind / search against an LDAP directory, optionally restricted to specific groups.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: ldap
  namespace: gloo-system
spec:
  configs:
  - ldap:
      address: "ldap://ldap.default.svc.cluster.local:389"
      userDnTemplate: "uid=%s,ou=people,dc=solo,dc=io"
      membershipAttributeName: "memberOf"
      allowedGroups:
      - "cn=managers,ou=groups,dc=solo,dc=io"
      - "cn=developers,ou=groups,dc=solo,dc=io"
      searchFilter: "(objectClass=*)"
      disableGroupChecking: false
      groupLookupSettings:
        checkGroupsWithServiceAccount: true
        credentialsSecretRef:
          name: ldapcredentials
          namespace: gloo-system
hmacAuth HMAC request signing — caller signs with a shared secret, the service verifies using SHA-1 over the parameters carried in the request headers.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: hmac
  namespace: gloo-system
spec:
  configs:
  - hmacAuth:
      secretRefs:
        secretRefs:
        - name: hmac-shared-secret
          namespace: gloo-system
      parametersInHeaders: {}        # selects the HeaderConfig variant
---
apiVersion: v1
kind: Secret
type: extauth.solo.io/hmac
metadata:
  name: hmac-shared-secret
  namespace: gloo-system
stringData:
  username: caller-a
  password: <shared-secret>
passThroughAuth The escape hatch. Calls your own HTTP or gRPC service and slots its decision into the same chain. You write business logic, not Envoy ext_authz plumbing.
apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: custom-grpc
  namespace: gloo-system
spec:
  configs:
  - passThroughAuth:
      grpc:
        address: my-authz.svc.cluster.local:9001
        connectionTimeout: 5s
portalAuth Dev-portal API-key auth — bound to the Solo developer portal's subscription / usage-plan model rather than the plain apiKeyAuth table.

Pick this when you're running the Solo developer portal and want ext-auth-service to honour portal-managed subscriptions (per-app keys, per-plan rate limits, lifecycle events from the portal). For plain API-key auth without the portal, use apiKeyAuth above.

Wired up by the dev-portal install — see the Solo developer portal docs for the schema and lifecycle.

Net Net. If your "we need a custom ext_authz" spec is OIDC introspection + a per-subject allowlist, you almost certainly don't need custom code — that's oauth2.accessTokenValidation.introspection chained into opaAuth, both built in. Custom plugins (pluginAuth) exist but are the rarest path.

2 · The request flow

Client / Agent Authorization: Bearer <token> agentgateway / kgateway data plane · agw=Rust · kg=Envoy ext_authz call Check() over gRPC on OK: inject headers, forward to upstream on DENY: short-circuit 401/403, backend never sees request Solo external auth service bundled by the enterprise chart 1 · oauth2 plugin introspect or JWT verify writes state["oauth"] 2 · opa plugin reads state["oauth"] runs Rego, allow/deny booleanExpr: "oauth && opa" both must succeed → OK + claims-to-headers AuthConfig (CRD) extauth.solo.io/v1 Rego ConfigMap policy.rego Upstream MCP / LLM / API trusts injected headers Check() gRPC OK + headers on allow → forward to upstream cyan = data plane · purple = Solo external auth service · green = OPA · plain arrows = request path

How to read it. The gateway data plane (agentgateway is Rust; kgateway is Envoy — both speak the ext_authz gRPC API) fires Check() at the Solo external auth service with the request headers. The service walks the configs[] array on the matching AuthConfig in the order set by booleanExpr. Each config can write into input.state keyed by its name — so the OPA config can read the introspected claims the oauth2 config just produced. On allow, the service returns OK plus any headers to inject (claims-to-headers, minted tokens, anything the config populated) and the gateway forwards to the upstream; on deny, the gateway short-circuits the request and the upstream never sees it.

3 · How it's wired in each product

Same auth service, same AuthConfig. Only the wrapper policy CRD differs — and the wrapper just references the AuthConfig by name and namespace. You can write the AuthConfig once and reference it from both gateways.

Product Wrapper CRD Field pointing at ext-auth Bundled by default?
agentgateway-enterprise (2.1+) EnterpriseAgentgatewayPolicy spec.traffic.entExtAuth.{authConfigRef, backendRef} Yes — EnterpriseAgentgatewayParameters.spec.sharedExtensions.extAuth.enabled defaults to true
kgateway enterprise (2.1+) EnterpriseKgatewayTrafficPolicy spec.entExtAuth.authConfigRef Yes — installed by the enterprise chart
gloo-mesh-enterprise ExtAuthPolicy spec.config.glooAuth → translates to AuthConfig Yes — same Helm chart bundles the auth service

4 · Sample — OAuth2 introspection chained into OPA

Scenario: an agent calls a custom MCP server through agentgateway. The IdP issues opaque access tokens (not JWTs), so we use introspection rather than local signature verification. After authentication, OPA checks that the calling subject is permitted to invoke the specific MCP tool named in the request. The MCP tool name is surfaced on an x-mcp-tool request header.

Step 1 Secret for the introspection client

ext-auth-service uses these credentials to authenticate to the IdP's introspection endpoint. Type is extauth.solo.io/oauth — the OAuth2 plugin knows to look up client-secret inside.

apiVersion: v1
kind: Secret
metadata:
  name: introspection-client
  namespace: agentgateway-system
type: extauth.solo.io/oauth
stringData:
  client-secret: <introspection-client-secret>

Step 2 Rego policy as a ConfigMap

The opa plugin loads this ConfigMap as Rego source. The oauth plugin's introspected claims arrive in input.state["oauth"] as a JSON string. This policy layers five checks on top of that: a deny-list that takes precedence over everything, a tenant-isolation rule, a per-subject tool allowlist, a per-tool scope requirement, and a business-hours window for the highest-risk tools.

apiVersion: v1
kind: ConfigMap
metadata:
  name: mcp-tool-allowlist
  namespace: agentgateway-system
data:
  policy.rego: |
    package authz

    import future.keywords.if
    import future.keywords.in

    default allow = false

    # ── Inputs ──────────────────────────────────────────────────────
    claims  := json.unmarshal(input.state["oauth"])
    tool    := input.http_request.headers["x-mcp-tool"]
    tenant  := input.http_request.headers["x-tenant"]
    scopes  := split(claims.scope, " ")     # space-delimited OAuth2 scopes

    # Caller-provided values that MUST match what the IdP issued.
    subject_tenant := claims.tenant

    # ── 1. Hard deny list (subjects we never trust, even with a token)
    deny_subjects := {"svc-agent-decommissioned", "svc-agent-quarantined"}

    # ── 2. Per-subject tool allowlist
    allowlist := {
      "svc-agent-research": {"search", "summarize", "fetch-doc"},
      "svc-agent-ops":      {"search", "restart", "scale", "drain-node"},
      "svc-agent-finance":  {"search", "summarize", "ledger-read"},
    }

    # ── 3. Scope required to invoke each tool
    required_scope := {
      "search":      "mcp:read",
      "summarize":   "mcp:read",
      "fetch-doc":   "mcp:read",
      "ledger-read": "mcp:read",
      "restart":     "mcp:write",
      "scale":       "mcp:write",
      "drain-node":  "mcp:admin",
    }

    # ── 4. High-risk tools — only allowed in business hours (UTC 08–18)
    high_risk := {"restart", "scale", "drain-node"}

    business_hours if {
      h := time.clock([time.now_ns(), "UTC"])[0]
      h >= 8
      h < 18
    }

    # ── 5. Decision -------------------------------------------------
    allow if {
      not claims.sub in deny_subjects                # rule 1
      tenant == subject_tenant                       # rule 2 — tenant isolation
      tools := allowlist[claims.sub]                 # rule 3 — subject known
      tools[tool]                                    # rule 4 — tool allowed for subject
      required_scope[tool] in scopes                 # rule 5 — token has the scope
      not (tool in high_risk and not business_hours) # rule 6 — high-risk → hours gate
    }

    # ── Audit trail — surfaced into ext-auth-service logs
    reason := r if {
      not claims.sub in deny_subjects
      tools := allowlist[claims.sub]
      tools[tool]
      r := sprintf("allow: sub=%s tool=%s tenant=%s", [claims.sub, tool, tenant])
    } else := "deny"

Step 3's AuthConfig wires the introspected claims into state["oauth"], which is what input.state["oauth"] above unmarshals. The x-mcp-tool and x-tenant headers are set by agentgateway from the upstream MCP method dispatch — they arrive in input.http_request.headers.

Step 3 AuthConfig chaining introspection → OPA

booleanExpr says both named configs must succeed. oauth runs first, calls the IdP's introspection endpoint, and on success writes the claims into state["oauth"] for OPA to read. claimsToHeaders surfaces the introspected sub and scope to the upstream so the MCP server can audit who called it.

apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: mcp-introspect-and-opa
  namespace: agentgateway-system
spec:
  booleanExpr: "oauth && opa"
  configs:
  - name: oauth
    oauth2:
      accessTokenValidation:
        introspection:
          introspectionUrl: https://keycloak.example.com/realms/agents/protocol/openid-connect/token/introspect
          clientId: gateway-introspection
          clientSecretRef:
            name: introspection-client
            namespace: agentgateway-system
        claimsToHeaders:
        - { claim: sub,   header: x-agent-subject }
        - { claim: scope, header: x-agent-scope }
  - name: opa
    opaAuth:
      modules:
      - name: mcp-tool-allowlist
        namespace: agentgateway-system
      query: "data.authz.allow == true"

Step 4a Attach via EnterpriseAgentgatewayPolicy

Agentgateway wrapper. backendRef points at the bundled ext-auth-service Service — the default name follows the pattern ext-auth-service-enterprise-agentgateway in the install namespace.

apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
  name: mcp-auth
  namespace: agentgateway-system
spec:
  targetRefs:
  - { group: gateway.networking.k8s.io, kind: Gateway, name: agentgateway-proxy }
  traffic:
    entExtAuth:
      authConfigRef:
        name: mcp-introspect-and-opa
        namespace: agentgateway-system
      backendRef:
        name: ext-auth-service-enterprise-agentgateway
        namespace: agentgateway-system
        port: 8083

Step 4b Same AuthConfig, via EnterpriseKgatewayTrafficPolicy

Kgateway wrapper. The AuthConfig from step 3 is reused unchanged — only the wrapper CRD differs.

apiVersion: enterprisekgateway.solo.io/v1alpha1
kind: EnterpriseKgatewayTrafficPolicy
metadata:
  name: mcp-auth
  namespace: kgateway-system
spec:
  targetRefs:
  - { group: gateway.networking.k8s.io, kind: Gateway, name: http }
  entExtAuth:
    authConfigRef:
      name: mcp-introspect-and-opa
      namespace: agentgateway-system

Step 5 JWT variant — local verification instead of introspection

If your IdP issues JWTs rather than opaque tokens, swap the oauth2.accessTokenValidation block to use jwt.remoteJwks. ext-auth-service fetches the IdP's public keys from the JWKS endpoint, caches them, and verifies the signature locally — no round-trip to the IdP per request. Everything else in the chain is unchanged: the validated claims land in state["oauth"] in the same shape, so the Rego policy from Step 2 works without modification.

apiVersion: extauth.solo.io/v1
kind: AuthConfig
metadata:
  name: mcp-jwt-and-opa
  namespace: agentgateway-system
spec:
  booleanExpr: "oauth && opa"
  configs:
  - name: oauth
    oauth2:
      accessTokenValidation:
        jwt:
          remoteJwks:
            url: https://keycloak.example.com/realms/agents/protocol/openid-connect/certs
            cacheDuration: 300s
          issuer:   https://keycloak.example.com/realms/agents
          audiences: [ mcp-gateway ]
        claimsToHeaders:
        - { claim: sub,   header: x-agent-subject }
        - { claim: scope, header: x-agent-scope }
  - name: opa
    opaAuth:
      modules:
      - name: mcp-tool-allowlist
        namespace: agentgateway-system
      query: "data.authz.allow == true"
Introspection vs JWT — when to use which. Introspection is the right call when the IdP issues opaque tokens, or when you need real-time revocation (the token can be invalidated server-side and the next request fails immediately). Cost: one HTTP call to the IdP per request, mitigated by in-memory caching. JWT is the right call when the IdP issues signed JWTs and you can tolerate revocation latency up to the token's exp — verification is local and zero-cost after the JWKS fetch (cached for cacheDuration). The rest of the chain — booleanExpr, OPA, claims-to-headers — is identical either way.

5 · Verifying

Mint a client-credentials token for an agent service identity, then call an allowed and a disallowed tool. The disallowed call must come back 403 from OPA — not 200, and not 401 (the token itself is valid, the authorization is what failed).

# Mint a token for the research agent
TOKEN=$(curl -s -X POST "https://keycloak.example.com/realms/agents/protocol/openid-connect/token" \
  -d "grant_type=client_credentials&client_id=svc-agent-research&client_secret=…" \
  | jq -r .access_token)

# Allowed tool → 200
curl -i -H "Authorization: Bearer $TOKEN" -H "x-mcp-tool: search" \
  https://gateway.example.com/mcp/research

# Disallowed tool → 403 from OPA
curl -i -H "Authorization: Bearer $TOKEN" -H "x-mcp-tool: restart" \
  https://gateway.example.com/mcp/research

# Tail decisions in real time
kubectl logs -n agentgateway-system deploy/ext-auth-service-enterprise-agentgateway -f
Why this chain matters. The MCP server never sees the agent's raw IdP token. It receives x-agent-subject and x-agent-scope headers written post-introspection by ext-auth-service, plus the request itself only if OPA also said yes. Authentication and authorization are decoupled — the IdP authenticates the agent, OPA authorizes the specific tool call, and the MCP server stays auth-naïve.

6 · booleanExpr — chaining configs

Each entry in configs[] gets a name. booleanExpr combines them with &&, ||, !, and parentheses. State written by an earlier config (introspected claims, validated JWT) is visible to later configs as input.state["<name>"].

ExpressionBehaviour
"oauth && opa"Introspection first, then OPA on the resulting claims. The pattern in §4.
"(jwt || oidc) && opa"Accept either a JWT (programmatic) or an OIDC session (browser), then run OPA. Covers mixed-client APIs.
"jwt || oidc"Pure authentication, no policy — accept either flow.
"(basic || jwt) && !blocklist"Authenticate via Basic or JWT and reject if the subject is on a blocklist (modelled as an inverted plugin).

7 · When to reach for what

OAuth2/OIDC, JWT, API keys, Basic, LDAP, HMAC. Built-in plugins, no custom code. Write an AuthConfig and attach it.
Policy decisions on claims, headers, or external data. opaAuth (in-process Rego) or opaServer (external OPA). Loaded from a ConfigMap, evaluated per request, decision reasons can be returned in dynamic metadata for access logs.
You already operate an authz service over HTTP. The passthrough plugin calls it for you — no need to rebuild Envoy ext_authz on your side. ext-auth-service still owns the chain, your service answers a single HTTP question.
You truly need behaviour none of the above covers. The service supports a custom-plugin path (pluginAuth) loaded from a directory at start-up. Rarest path, tightest coupling, hardest to ship — try the three above first.

Where to go next