Authorization policies for LLM gateways look simple until they fail in production. Common Expression Language (CEL) policies in agentgateway can fail in two distinct ways: silently permitting unauthorized requests (fail open) or blocking every legitimate call (fail closed). Both show Accepted and Attached in policy status. Neither reports an error.
Mike Moore documented two real gotchas in Solo Enterprise for agentgateway v2026.8.2. The first stems from how matchExpressions entries combine. The second comes from the request lifecycle: fields you expect to exist are empty at the traffic authorization phase.
The Intended Policy
Two authorization rules on a route called governed-llm:
- The caller's JWT carries a
countryclaim. Block if the country is on a restricted list (CU, IR, KP, SY). - The request body names a model. Allow only models approved by the AI governance group.
Written the obvious way:
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- "has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])"
- "json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']"
This reads as "both must hold." It does not work that way.
Gotcha One: matchExpressions is OR, and It Fails Open
Test setup: two users from the same Keycloak realm, same group, same permissions. Only difference is the country claim. Maria is US, Pat is IR.
| Caller | Model | Expected | Actual |
|---|---|---|---|
| maria (US) | approved | 200 | 200 |
| pat (IR) | approved | 403 | 200 |
| maria (US) | not approved | 403 | 200 |
| no JWT | approved | 401 | 401 |
Pat from Iran gets through. Maria requesting an unapproved model gets through. The policy fails open.
Why: matchExpressions entries are OR'ed by default. If any single expression evaluates to true, the policy allows the request. Pat's request satisfies the model check, so the country check is ignored. Maria's request satisfies the country check, so the model check is ignored.
The fix is to combine both conditions into a single expression:
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- "has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY']) && json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']"
Now both conditions must hold. The policy no longer fails open.
Gotcha Two: llm.requestModel is Empty at Traffic Phase
The single-expression fix works for the country and model checks. But agentgateway exposes a convenience field: llm.requestModel. It should simplify the policy by avoiding json(request.body).model.
Attempt two:
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- "has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY']) && llm.requestModel in ['claude-sonnet-4-6', 'claude-haiku-4-5']"
| Caller | Model | Expected | Actual |
|---|---|---|---|
| maria (US) | approved | 200 | 403 |
| pat (IR) | approved | 403 | 403 |
| maria (US) | not approved | 403 | 403 |
| no JWT | approved | 401 | 401 |
Every authenticated request is denied, including the ones that should pass. The policy fails closed.
Why: llm.requestModel is empty at the traffic authorization phase. The field is populated later in the request lifecycle, after the body is parsed and routed. At the traffic phase, the CEL expression evaluates "" in ['claude-sonnet-4-6', 'claude-haiku-4-5'], which is false. Every request fails the model check.
The working policy must use json(request.body).model at the traffic phase or move the model check to a later phase where llm.requestModel is populated.
Request Lifecycle and Field Availability
agentgateway processes requests in phases:
-
Traffic phase: HTTP request arrives. Headers and body are available. JWT claims are decoded.
llm.requestModelis not yet populated. -
Routing phase: Body is parsed. Model is extracted.
llm.requestModelis now available. - LLM phase: Request is forwarded to the upstream model provider.
Authorization policies can attach to any phase. The fields available in CEL expressions depend on which phase the policy runs in.
| Field | Traffic Phase | Routing Phase | LLM Phase |
|---|---|---|---|
request.headers |
✓ | ✓ | ✓ |
request.body |
✓ | ✓ | ✓ |
jwt.* |
✓ | ✓ | ✓ |
llm.requestModel |
✗ | ✓ | ✓ |
llm.responseModel |
✗ | ✗ | ✓ |
If you need to authorize based on the model, either parse the body yourself at the traffic phase or move the policy to the routing phase.
The Working Policy
Final version that passes all tests:
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- "has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY']) && json(request.body).model in ['claude-sonnet-4-6', 'claude-haiku-4-5']"
Or split the checks across phases:
traffic:
authorization:
action: Allow
policy:
matchExpressions:
- "has(jwt.country) && !(jwt.country in ['CU', 'IR', 'KP', 'SY'])"
routing:
authorization:
action: Allow
policy:
matchExpressions:
- "llm.requestModel in ['claude-sonnet-4-6', 'claude-haiku-4-5']"
The split approach is cleaner if you have multiple routes with different model restrictions but the same country policy.
Observability Gaps
Both failure modes are silent. The policy status shows Accepted and Attached. No error appears in logs. The only signal is unexpected 200 or 403 responses in application traffic.
To catch these issues:
- Integration tests with negative cases: Test users from restricted countries. Test unapproved models. Test combinations.
- Audit logs: Log every authorization decision with the full CEL expression result, not just allow/deny.
- Metrics: Track authorization denials by policy name and route. A sudden drop to zero denials may indicate a fail-open bug. A spike to 100% denials may indicate a fail-closed bug.
Security Boundary Implications
The fail-open gotcha is worse. An attacker who discovers the OR behavior can craft requests that satisfy one condition while violating the other. If the policy checks both JWT claims and request body fields, the attacker can omit the JWT (failing that check) but send a valid body (passing that check). The request goes through.
The fail-closed gotcha is a denial-of-service risk. Legitimate users are blocked. The blast radius depends on how many routes share the broken policy.
Both gotchas are easy to introduce during refactoring. Moving a check from one phase to another or splitting a single expression into multiple entries can silently break authorization.
Technical Verdict
Use agentgateway CEL policies when:
- You need fine-grained authorization based on JWT claims, request headers, or body fields.
- You can write integration tests that cover negative cases (blocked users, blocked models, blocked combinations).
- You can instrument authorization decisions with structured logs and metrics.
Avoid or defer when:
- You cannot test negative cases in CI.
- You need to authorize based on fields that are not available in the phase where your policy runs.
- Your policy logic is complex enough that splitting it into multiple expressions seems natural. (It will OR them, not AND them.)
Mitigation checklist:
- Combine all conditions into a single CEL expression unless you explicitly want OR semantics.
- Verify which fields are available in which phase. Do not assume convenience fields like
llm.requestModelare populated everywhere. - Log the full CEL expression result, not just the final allow/deny decision.
- Test with users who should be blocked and requests that should be denied.
Source Links
- Two agentgateway CEL Gotchas: One Fails Open, One Fails Closed by Mike Moore
- Demo repository: themsquared/agentic-demo (manifests/governance/)
Top comments (1)
The OR-vs-AND thing is what makes this actually dangerous rather than just annoying - the policy status shows healthy so you have no reason to look harder. You can test with a dozen authorized callers, everything looks fine, and then the one restricted caller that slips through on day three is what tells you something is wrong. The llm-requestModel being empty at traffic phase took me a while to trace: I had the field wired up in the spec, the expression was correct, and it took diffing two working variants to notice that enrichment had not happened yet at that stage. Knowing the request lifecycle order is not optional for this kind of policy work.