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.
oauth2.accessTokenValidation.introspection chained into
opaAuth, both built in. Custom plugins
(pluginAuth) exist but are the rarest path.
2 · The request flow
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"
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
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>"].
| Expression | Behaviour |
|---|---|
"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
AuthConfig
and attach it.
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.
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.
pluginAuth)
loaded from a directory at start-up. Rarest path, tightest
coupling, hardest to ship — try the three above first.
Where to go next
-
JWT claims to HTTP headers —
the
oauth2.accessTokenValidation.jwt+claimsToHeaderspattern in detail, including multi-tenant routing on the verified claim. - JWT, OIDC and on-behalf-of — the four overlapping auth flows in Istio Ambient, including RFC 8693 token exchange.
-
JWT token exchange — Keycloak +
agentgateway STS, hop by hop — interactive walkthrough of an
A2A token-exchange chain where each hop nests an
actclaim.