Research + implementation plan (no live change) on whether Cilium 1.20.1's
native Gateway API ExternalAuth filter (GEP-1494) can replace the un-wired
forward-auth CEC template and Crafty's oauth2-proxy sidecar.
Verdict: DEFER. The filter exists in 1.20.1 code (shipped 1.20.0, PR #45739) but
is EXPERIMENTAL-channel Gateway API; the cluster runs the standard-channel CRDs
(verified: HTTPRoute filters[].type enum lacks ExternalAuth), so it is not even
installable today. Also: fail-open bug (#47178, fixed #47265) in a brand-new
security filter, no 1.20 docs, and the ext_authz hop would hit the same
enforce_policy_on_l7lb hairpin re-evaluation just fixed for Keycloak (68690e5).
Plan is phased/reversible (pihole canary first) and keeps the working sidecar.
Resolves the DECISIONS-LOG open item on ext_authz workaround relevance under 1.20.1.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
19 KiB
Evaluation: Cilium 1.20.1 native Gateway API ExternalAuth vs. the oauth2-proxy design
Status: Research + plan only — NO live change proposed for tonight. Reviewable evaluation.
Author: WS-NET/WS-SEC investigation (Claude Opus 4.8), 2026-08-31.
Scope: Whether Cilium 1.20.1's native Gateway API ExternalAuth filter (GEP-1494) can replace
(a) the hand-authored, un-wired forward-auth CiliumClusterwideEnvoyConfig
(platform/oauth2-proxy/forward-auth-cec.yaml) and (b) Crafty's per-pod oauth2-proxy sidecar.
Answers the open item from DECISIONS-LOG (2026-08-30): "the Cilium ext_authz/forward-auth
workaround relevance under 1.20.1."
TL;DR — Verdict: DEFER (partial-feasible, not viable as-is tonight)
Native ExternalAuth exists in the Cilium 1.20.1 codebase and could eventually replace both
mechanisms with a single, Gateway-API-native, per-route filter. But it is NOT usable on this
cluster in its current state, and adopting it is a non-trivial, elevated-risk change to the exact
shared Gateway that SSO was just stabilized on. Recommendation: keep the current design, do not
adopt now. Track native ExternalAuth as the long-term target and revisit after the blocking
conditions below clear.
Three hard blockers, in order of severity:
- The cluster's Gateway API CRDs are the
standardchannel — theExternalAuthHTTPRoute filter is not even installable. Verified live: thehttproutes.gateway.networking.k8s.ioCRD isgateway.networking.k8s.io/channel: standard, bundlev1.6.1, and itsfilters[].typeenum is exactly[RequestHeaderModifier, ResponseHeaderModifier, RequestMirror, RequestRedirect, URLRewrite, ExtensionRef, CORS]—ExternalAuthis absent. GEP-1494 is an experimental Gateway API feature; using it requires swapping the cluster-wide Gateway API CRDs to the experimental channel bundle. That is a breaking, cluster-wide CRD change, and it must be kept in lock-step with the Ansible-bootstrap ↔ Flux parity contract. - The feature is young and shipped with a fail-open security bug.
ExternalAuthlanded in Cilium 1.20.0 (2026-07-29, PR #45739). Issue #47178 "Basic externalauth setup fails open" was filed against 1.20.0-rc.0 — the ext_authz filter silently produced no Envoy entry and let unauthenticated traffic through. The fix (PR #47265) was backported to the 1.20 branch (~2026-07-21) and should be in 1.20.0/1.20.1, but a fail-open regression in a brand-new security-critical filter is exactly the failure mode R-IDP-02 forbids. Confidence that today's filter is robust is low, and documentation is thin (Cilium ships noexternal-authdocs page in 1.20 — the dedicated doc URL 404s; the stable Gateway API doc page makes no mention of it). enforce_policy_on_l7lb(the CVE-2025-30162 hairpin behavior) will bite the ext_authz hop too. Commit68690e5just fixed cluster-wide SSO breakage: after the 1.16.5→1.20.1 jump, the shared Gateway's L7 proxy re-evaluates a pod's egress policy against the real backend identity when it hairpins (enforce_policy_on_l7lb), so every NetworkPolicy'd app needed an explicittoEndpointsegress allow to the Keycloak backend. A Gateway-levelExternalAuthfilter makes the Gateway's Envoy hairpin to the oauth2-proxy backend cluster for the auth check — the identical class of policy re-evaluation. Expect to need new egress/identity allowances for the ext_authz path, and to re-validate it does not regress the freshly-fixed Keycloak egress.
Given SSO "was just painfully fixed," the cost/benefit does not favor touching it now.
Current state (verified — live cluster + repo)
Cluster: Cilium v1.20.1 (quay.io/cilium/cilium:v1.20.1), Gateway API CRDs bundle v1.6.1
standard channel, k3s v1.36.4. Shared Gateway gateway/shared (class cilium, VIP
192.168.1.53, Programmed), backed by the machine-owned CiliumEnvoyConfig gateway/cilium-gateway-shared (the only CEC/CCEC on the cluster). cilium-config has
enable-gateway-api: true and no external-auth flag (mesh-auth-* is the unrelated SPIFFE
mutual-auth feature and is disabled).
Auth design today (three mechanisms, one of them dead):
| Mechanism | Where | Apps | Status |
|---|---|---|---|
Native OIDC (auth.mode: oidc) |
in-app OIDC client → Keycloak | gitea, nextcloud, semaphore, grafana(native) | Live, working. Not in scope to change. |
| Native/other | app-specific | seerr (own OIDC), plex | Live. Out of scope. |
forward-auth CEC (auth.mode: forward-auth) |
platform/oauth2-proxy/forward-auth-cec.yaml — hand-authored CiliumClusterwideEnvoyConfig with an ext_authz HTTP filter → oauth2-proxy /oauth2/auth |
jackett, pihole, qbittorrent, radarr, sonarr | NOT DEPLOYED. A TEMPLATE, not referenced by platform/oauth2-proxy/kustomization.yaml. Proven in a throwaway envoy pod (2026-08-28) but never wired. These 5 UIs are currently reachable on the shared Gateway un-gated. |
| oauth2-proxy sidecar (per-pod reverse-proxy, "Option B") | platform/crafty/deployment.yaml container oauth2-proxy (v7.8.1, :4180 → https://127.0.0.1:8443) |
crafty | Live, working. In-data-path fail-closed gate. |
The renderer contract (catalog/render.py): auth.mode ∈ {oidc, forward-auth, native, none}
(AUTH_MODES, line 53); default is forward-auth for any app with ingressRoutes else none
(lines 76-77). _auth_annotations() (line 605) stamps
homelab.keithjackson.dev/auth-mode + /auth-groups onto the HTTPRoute for oidc/forward-auth,
and pods carry an auth-mode label enforced by a Kyverno require-auth-mode-label policy
(R-IDP-02). Since commit 68690e5, the renderer also emits a toEndpoints egress allow to the
Keycloak backend for oidc/forward-auth apps (line 539). No renderer field today expresses an
ext_authz backendRef — the forward-auth annotations are consumed by the (un-wired) platform
enforcement, not by a Gateway-API filter.
Why the CEC exists / why it's not on the Gateway (per its own header + README): on Cilium 1.16.5
there was no supported hook to inject ext_authz into the machine-regenerated shared-Gateway Envoy
program, and native ExternalAuth "ships in NO released Cilium version yet (PoC only)." That premise
is now partially stale — see below — which is precisely why this evaluation exists.
Does Cilium 1.20.1 support native Gateway API ExternalAuth? (research)
Yes, in the code — as an experimental Gateway API feature that is not enabled on this cluster.
- Shipped in 1.20.0. The v1.20.0 release notes list: "🔐 External Authorization: HTTPRoute
requests can now be authenticated and authorized through an external service before they reach the
application, using the Gateway API
ExternalAuthfilter from GEP-1494." (PR cilium/cilium#45739). Isovalent's 1.20 launch post headlines "Gateway API ExternalAuth." - Shape (GEP-1494 experimental filter). An HTTPRoute rule gains a filter
type: ExternalAuthwithexternalAuth.protocol: GRPC|HTTP, abackendRefto the auth service, andgrpc.allowedHeaders/http.{path,allowedHeaders,allowedResponseHeaders}/forwardBody. It maps onto Envoy'sext_authzprotocol — the same mechanism the hand-authored CEC uses, but declared per-route on the standard shared Gateway instead of a separate listener. Example:apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute spec: rules: - filters: - type: ExternalAuth externalAuth: protocol: GRPC # or HTTP (oauth2-proxy /oauth2/auth is HTTP) backendRef: { kind: Service, name: oauth2-proxy, port: 80 } http: { path: /oauth2/auth, allowedHeaders: [cookie, authorization] } backendRefs: [{ name: <app>, port: <port> }] - It is EXPERIMENTAL-channel. GEP-1494 status is Experimental
(gateway-api.sigs.k8s.io/geps/gep-1494). Its
fields live only in the Gateway API experimental CRD bundle. This cluster runs the
standardchannel (verified), so the filter type is not present in the HTTPRoute schema — a manifest using it would be rejected at admission today. - Maturity risk. Fail-open bug #47178 → fix
#47265 (backported to 1.20, ~2026-07-21). Cilium
ships no dedicated ExternalAuth documentation page in 1.20 (the
/en/v1.20/.../external-auth/and/en/latest/.../external-auth/URLs both 404; the stable Gateway API support page does not mention it). Feature is real but young and thinly documented.
Bottom line: the CEC/sidecar READMEs' claim that native ExternalAuth "ships in no released Cilium version" is now outdated — it ships in 1.20.x — but it is experimental, off by default here (wrong CRD channel), and not battle-tested.
Would it cleanly replace the two mechanisms?
(a) The un-wired forward-auth CEC — YES, in principle, and more cleanly. This is the strongest
case for native ExternalAuth: the CEC is a hand-authored TLS listener + routes + EDS clusters on a
separate VIP that requires moving hosts off the shared Gateway and repointing DNS. A native
ExternalAuth filter would instead be one filter block on each app's existing HTTPRoute on the
existing shared Gateway/VIP/DNS — no second ingress, no DNS churn, no hand-authored TLS. Since
the CEC is not deployed, there is no rip-out risk here: adopting native ExternalAuth for the 5
forward-auth apps (jackett/pihole/qbittorrent/radarr/sonarr) would complete an unfinished story
rather than replace a working one. The renderer already carries the auth-mode/auth-groups signal;
it would need a new emitter that turns auth.mode: forward-auth into the HTTPRoute ExternalAuth
filter (see plan).
(b) Crafty's sidecar — PARTIAL, and lower value. Native ExternalAuth could drop the per-pod
oauth2-proxy container and gate crafty.home at the Gateway like the others. But Crafty's UI is
HTTPS self-signed on :8443; the sidecar terminates that in-pod (--ssl-upstream-insecure-skip- verify). Moving the gate to the Gateway means the shared Gateway's Envoy must speak HTTPS to Crafty's
self-signed :8443 upstream — requiring a BackendTLSPolicy with insecureSkipVerify (or a real
cert), and the crafty Service would target :8443 directly. That is net-new Gateway plumbing to
remove a small, working, fail-closed sidecar. Low priority; do Crafty last, if at all.
Native ExternalAuth cannot and should not touch the oidc apps (gitea/nextcloud/semaphore/
grafana) — those use in-app OIDC and are out of scope.
Proposed migration plan (do NOT execute now — for review)
Phased, reversible, lowest-blast-radius first. Precondition gates P1-P3 must clear before ANY cluster change. Each step names the files it touches.
Phase 0 — Preconditions (research/verify only, no cluster change)
- P1. Confirm the fail-open fix is in 1.20.1. Verify PR #47265 shipped in the running
v1.20.1(changelog /git tag --contains), and reproduce a fail-closed ext_authz on a throwaway route (the 2026-08-28 CEC proof, redone with the native filter on a scratch HTTPRoute) BEFORE trusting it for R-IDP-02. - P2. Decide the Gateway API channel swap. Adopting GEP-1494 requires the experimental CRD
bundle cluster-wide. Assess blast radius (every Gateway/HTTPRoute revalidated against experimental
schemas) and reconcile the Ansible-bootstrap ↔ Flux parity contract — the Ansible bootstrap
that installs Gateway API CRDs must install the same channel. Files: wherever the Gateway API CRD
bundle is declared for both bootstrap and Flux (search
gateway.networking.k8s.ioCRD source;platform/gateway/+ the Ansible bootstrap CRD step). This is the single biggest change and its own reviewable sub-plan. - P3. Confirm the Cilium enablement path. Determine whether Cilium honors the
ExternalAuthfilter on experimental-CRD presence alone or needs an agent/helm flag; if a flag exists it is a[parity]value inplatform/cilium/helmrelease.yamland the Ansible Cilium values.
Phase 1 — One canary app (pihole), additive, reversible
- Install experimental Gateway API CRDs (P2) + any Cilium flag (P3).
- Add the
ExternalAuthfilter to pihole's HTTPRoute only,backendRef→oauth2-proxyService,http.path: /oauth2/auth, header allow-list mirroring the CEC's proven stanza. KeepfailureMode/deny semantics fail-closed. Files:catalog/pihole.yaml(unchanged intent) + a new renderer emitter incatalog/render.py(_httproute()path) that renders the filter fromauth.mode: forward-auth; re-render pihole golden. Add the ext_authz egress allow to the oauth2-proxy backend for the app (theenforce_policy_on_l7lbmitigation, mirroring the Keycloak fix inrender.py). - Browser test (the human): incognito
pihole.home→ 302 to Keycloak → loginplatform-admins→ PiHole UI; non-platform-admins→ 403; oauth2-proxy down → denied, never the bare UI. - Rollback: delete the filter block (revert the HTTPRoute) → back to un-gated shared Gateway. No data, no DNS, no VIP involved.
Phase 2 — Roll out to the remaining forward-auth apps
- Repeat for jackett, qbittorrent, radarr, sonarr via the renderer (they already declare
auth.mode: forward-auth; the emitter does it uniformly). Re-render 4 goldens. - Delete
platform/oauth2-proxy/forward-auth-cec.yamland its README section describing the separate-VIP interim — now obsolete. (It was never wired, so this is doc/template cleanup.)
Phase 3 — Crafty (optional, last)
- Add
ExternalAuthtocrafty's HTTPRoute; point thecraftyService at:8443; add aBackendTLSPolicyfor the self-signed upstream; remove the oauth2-proxy sidecar fromplatform/crafty/deployment.yamland its SOPS creds. Verify game-port L4 path (192.168.1.55) untouched. Higher effort for marginal gain — defer unless consolidating.
Risks & rollback
| Risk | Severity | Mitigation / Rollback |
|---|---|---|
| Fail-open regression (a new/edge ext_authz bug lets traffic through un-authed — R-IDP-02 violation) | High | P1 reproduction on a scratch route before any real app; canary (pihole) first with explicit browser deny-path test; keep un-gated-but-internal apps behind LAN until proven. |
| Experimental-channel CRD swap breaks existing Gateways/HTTPRoutes | High | Treat P2 as its own reviewed change; snapshot/backup CRDs; parity with Ansible bootstrap; validate all 16 existing HTTPRoutes still Programmed after the swap. |
enforce_policy_on_l7lb blocks the ext_authz hop (Gateway hairpins to oauth2-proxy backend; NetworkPolicy'd apps 403 the auth check — the CVE-2025-30162 pattern from commit 68690e5) |
Med-High | Emit a toEndpoints egress allow to the oauth2-proxy backend for forward-auth apps, exactly as done for Keycloak; re-validate the Keycloak egress is not regressed. |
| Parity-contract drift (Flux vs Ansible disagree on CRD channel / Cilium flag) | Med | Land CRD-channel + Cilium-flag changes in both bootstrap and Flux in one PR; gate (make -C ci gate). |
| Thin docs / churn (experimental API fields change across Gateway API/Cilium minors) | Med | Pin bundle version; Renovate watch; keep the CEC template in git history as a fallback recipe. |
| Regressing freshly-fixed SSO | High | Do nothing to oidc apps; canary + reversible filter-only changes; never touch the shared Gateway CEC directly. |
Global rollback: every proposed change is additive at the HTTPRoute level and reverts by removing
the filter block. The nuclear fallback if native ExternalAuth proves flaky is the already-proven
Option B (per-app oauth2-proxy reverse-proxy, as Crafty uses today) — Gateway-API-native, no CRD
channel swap, just a backendRef to a per-app proxy.
Recommendation
DEFER. Native ExternalAuth is the right long-term target and would let us delete both the dead CEC
template and (eventually) Crafty's sidecar in favor of one uniform, Gateway-API-native filter. But
tonight it is not viable: the enabling CRD channel isn't installed, the feature is experimental
and recently fail-open-buggy, it is undocumented in 1.20, and it would perturb the shared Gateway and
the just-fixed enforce_policy_on_l7lb egress that SSO now depends on.
Concretely:
- Do NOT adopt now. Keep Crafty's working sidecar and leave the 5 forward-auth UIs as-is.
- When revisiting (next maintenance window, not tonight): execute Phase 0 preconditions, then a pihole-only canary (Phase 1). Only after a clean fail-closed browser proof, roll out Phase 2 and retire the CEC template.
- Cheaper interim if gating the 5 UIs becomes urgent before then: adopt Option B (per-app oauth2-proxy reverse-proxy, the Crafty pattern) — no experimental CRDs, fully reversible — rather than the separate-VIP CEC or a rushed native-ExternalAuth switch.
This resolves the DECISIONS-LOG open item: native ExternalAuth is relevant and code-present under 1.20.1, but blocked on an experimental CRD-channel swap + maturity/fail-open concerns; the interim workaround stays.
Sources
- Cilium v1.20.0 release notes — "External Authorization" / GEP-1494 (PR #45739): https://github.com/cilium/cilium/releases/tag/v1.20.0
- Cilium v1.20.1 release: https://github.com/cilium/cilium/releases/tag/v1.20.1
- Isovalent — "Cilium 1.20: Gateway API ExternalAuth…": https://isovalent.com/blog/post/cilium-1-20/
- GEP-1494 (HTTP Auth in Gateway API), status Experimental: https://gateway-api.sigs.k8s.io/geps/gep-1494/
- Issue #47178 "Basic externalauth setup fails open": https://github.com/cilium/cilium/issues/47178
- Fix PR #47265 (backported to 1.20): https://github.com/cilium/cilium/pull/47265
- Issue #45704 (GEP-1494 filter support, closed as dup of the implementing PR): https://github.com/cilium/cilium/issues/45704
- Cilium 1.20.1 Gateway API support page (no ExternalAuth mention — feature undocumented in 1.20): https://docs.cilium.io/en/stable/network/servicemesh/gateway-api/gateway-api/
- Live cluster (read-only): Cilium
v1.20.1; HTTPRoute CRDchannel: standard, bundlev1.6.1,filters[].typeenum lacksExternalAuth;cilium-confighas no external-auth flag. - Repo:
platform/oauth2-proxy/forward-auth-cec.yaml(+ README),platform/crafty/deployment.yaml(+ README),catalog/render.py(AUTH_MODES,_auth_annotations, keycloak-egress emitter), commit68690e5(CVE-2025-30162 /enforce_policy_on_l7lbegress fix).