Keymate Logo
← Back to Blog

Centralized Policy Enforcement with Istio

Buket ÇobanBuket Çoban · DevOps Engineer
October 2026 · 10 min read
Centralized Policy Enforcement with Istio

TL;DR

  • Centralized policy enforcement with Istio means the sidecar enforces and a separate service decides. In Keymate that service is the Access Gateway, and the mesh is one enforcement point next to SDKs and APISIX plugins.
  • There are two ways to put that check into the sidecar: a Lua filter delivered through an EnvoyFilter, or a Wasm module delivered through a WasmPlugin. Istio's built-in alternative is ext_authz through an AuthorizationPolicy with action CUSTOM.
  • Istio and Envoy both fail closed by default when the authorization service cannot be reached. Istio documents a Wasm plugin's failStrategy for plugin failures, not for a failed call to that service. The plugin code has to handle that case.
  • The quiet risk is the contract between enforcer and decision service. If the enforcer header drifts on one side only, a fail-closed enforcer denies requests the policy would have allowed.
  • Istio ambient mode (ztunnel and waypoint proxies) is out of scope here.

What a sidecar does when the decision service does not answer

A request reaches a service in the mesh. Before it gets anywhere near the application, the sidecar stops it and sends a question to another service: is this caller allowed to do this? Then the answer does not come.

In the Lua variant of Keymate's Istio plugin, the call to the decision service can come back with no response at all. Then the request ends right there with a 502 and {"error": "Authorization Service Unavailable"}. The application never sees it. That is the behavior you want: the proxy enforces, but it does not decide.

Centralized policy enforcement is an architecture where every enforcement point asks one decision service, instead of each one carrying its own copy of the rules.

This post is about how Keymate's Istio plugin works and what it needs from the decision service.

The real problem: the mesh becomes a second policy store

Authorization logic tends to spread. A rule starts in application code. Then the API gateway needs it for north-south traffic. Then the mesh needs it for service-to-service calls. Three copies of one rule will drift, and you find out when one of them says yes and another says no.

Istio already has an authorization API. An AuthorizationPolicy with ALLOW or DENY rules is evaluated by the Envoy proxy itself, on inbound traffic, at the server side. (Istio security concepts)

That is useful for coarse rules. But every rule you write there is a rule that lives in the mesh and nowhere else. Your SDK does not read it. Your API gateway does not read it. A change log kept by your decision service will not include it unless you add it. Policy Lifecycle: A Policy Change That Ships Like Code covers how policy changes in the decision service get versioned and guarded.

So the question is not whether Istio can enforce. It can. The question is how the sidecar asks a decision service outside the mesh. And what it does when that service is slow, down, or speaking a different contract.

Why centralized policy enforcement starts with one decision point

Keymate's answer is to keep one decision point. SDKs, APISIX plugins and the Istio plugin all call the Access Gateway, at POST /gateway/api/v1/access/check-permission. The Access Gateway then asks a Keycloak-side Authorization Decision Gateway for the actual decision. The design docs say enforcement points should call the Access Gateway, not the Keycloak provider directly.

The Istio plugin's design notes split the work by traffic direction. APISIX guards north-south traffic and the Istio plugin guards east-west traffic, and both use the same Access Gateway. For the decision side, Inside Keycloak Authorization: Resources & Policy Engine shows how Keycloak evaluates policies. For the token side of the same gateway, see how DPoP binds a token to its sender.

The request flow, step by step

Here is the path of one request through a mesh enforcement point backed by a central decision service, as it works in Keymate's Istio plugin:

  1. The request arrives at the workload's sidecar. The Lua variant is inserted on the inbound listener (SIDECAR_INBOUND) just before Envoy's router filter. The Wasm variant runs in the AUTHN phase.
  2. Health, readiness and metrics paths are skipped by a configured prefix list and never reach the decision service.
  3. The plugin sends the original method and path, the caller's headers and the request body to the Access Gateway. It adds a Keops-Enforcer header that names itself: layer="mesh", tech="istio" and a version.
  4. The Access Gateway validates that header first. A request without it, or without the layer, tech and version fields, is rejected with 400 INVALID_ENFORCER.
  5. The Access Gateway gets the decision from the Authorization Decision Gateway and answers the plugin.
  6. Only an allow answer lets the request continue to the application: any 2xx in the Wasm variant, exactly 200 in the Lua variant. Anything else is sent back to the client with the gateway's status.

Step 3 is where the request body comes along. Envoy's own ext_authz filter does not send the body by default. For an HTTP authorization server the check request has Content-Length: 0 unless body buffering is turned on. (Envoy ext_authz API)

Two ways to plug a check into the sidecar

The plugin did not start in this shape. The first version was a single Lua filter written inline inside a Helm chart. In February 2026 that chart was replaced by the two variants described below: a Lua filter in its own file, and a Go plugin compiled to Wasm. The difference that matters most in practice is configuration. The Wasm variant reads its settings, such as skip paths, from configuration at deploy time, while the Lua variant keeps them in its source.

Both variants make the same call to the same gateway. They differ in how they get into Envoy.

  • Lua filter. Delivered as an Istio EnvoyFilter with the Lua code inline. Its settings live in the Lua source, rendered by Helm. It sits before the router on the inbound listener.
  • Wasm module. Delivered as an Istio WasmPlugin pulled from an OCI image. Its settings live in pluginConfig. It runs in the AUTHN phase, and it needs an extra EnvoyFilter that adds the cluster it calls.

The Wasm variant needs two objects. Here is a trimmed version of the chart in the plugin repository, with placeholder service names:

apiVersion: extensions.istio.io/v1alpha1
kind: WasmPlugin
metadata:
  name: keops-access-gateway
spec:
  selector:
    matchLabels:
      app: orders
  url: oci://registry.example.com/keops-istio-access-plugin:<version>
  phase: AUTHN
  pluginConfig:
    gateway_url: "http://access-gateway.auth.svc.cluster.local:8080"
    endpoint: "/gateway/api/v1/access/check-permission"
    timeout: 5000
    skip_paths:
      - "/health"
      - "/metrics"
---
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: access-gateway-cluster
spec:
  workloadSelector:
    labels:
      app: orders
  configPatches:
  - applyTo: CLUSTER
    match:
      context: SIDECAR_OUTBOUND
    patch:
      operation: ADD
      value:
        name: access_gateway_cluster
        type: LOGICAL_DNS
        connect_timeout: 5s
        load_assignment:
          cluster_name: access_gateway_cluster
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: access-gateway.auth.svc.cluster.local
                    port_value: 8080

One detail in that config is easy to miss. The Wasm plugin dispatches to a cluster named access_gateway_cluster, so the EnvoyFilter must define exactly that name. The timeout is in milliseconds, and the plugin uses 5000 when none is set.

How this compares with Istio ext_authz

Istio has a first-class path for exactly this job. You register the decision service as an extension provider in meshConfig, using envoyExtAuthzHttp or envoyExtAuthzGrpc. Then you point an AuthorizationPolicy with action: CUSTOM at it. (Istio external authorization task)

The docs fix a few behaviors you would otherwise have to write yourself:

  • Order. CUSTOM is evaluated before DENY and ALLOW. A CUSTOM policy that returns deny rejects the request before any other rule is checked. (AuthorizationPolicy reference)
  • One provider per workload. At most one extension provider is allowed per workload, though different workloads can use different providers. (AuthorizationPolicy reference)
  • Failure. failOpen defaults to false, and statusOnError defaults to 403. (MeshConfig reference)
  • What gets sent. For the HTTP provider, Host, Method, Path and Content-Length are always included. Other headers and the body must be listed or enabled. (MeshConfig reference)

A custom filter lets you shape the request you send, including your own headers and body. You pay for it in two places. First, failure handling is your code, not a documented field. Second, the Lua variant and the Wasm cluster both depend on EnvoyFilter.

Istio is direct about EnvoyFilter. Incorrect configuration "could potentially destabilize the entire mesh". Envoy configuration delivered through it has to be watched across proxy upgrades so deprecated fields get replaced. When several EnvoyFilters conflict on one workload, the behavior is undefined. (EnvoyFilter reference)

ext_authz moves that risk into a supported API. It also limits the check request to what the provider can send: the fixed fields plus the headers and body you opt in to. Which side of that trade you take depends on how much of the request your decision service needs to see.

Fail open or fail closed

For authorization, default to fail closed. Open a path only on purpose, and only for paths that carry no data.

The defaults agree. Envoy's ext_authz filter has failure_mode_allow set to false. When it cannot reach the authorization service, or gets a 5xx back, it rejects the client request, with status_on_error defaulting to 403 Forbidden. (Envoy ext_authz API) Istio's failOpen also defaults to false. (MeshConfig reference)

The Wasm path has a trap. WasmPlugin has a failStrategy field with FAIL_CLOSE, FAIL_OPEN and FAIL_RELOAD. It covers fatal plugin errors: a failed binary fetch, an exception, or an abort in the VM. Istio says FAIL_OPEN is not recommended for authentication or authorization plugins. (WasmPlugin reference) What the field does not describe is the case you care about most: the plugin is healthy, and its call to the decision service fails.

That case is the plugin's own code. In Keymate's Wasm plugin, a failed dispatch returns 502, a response with no headers returns 502, and any non-2xx answer is a deny. There is no setting that turns this into fail-open. A timeout has no separate branch, so it also ends in a deny.

A fail-closed enforcer that denies everything looks, from the outside, a lot like a strict policy. You need signals that tell those two apart.

Envoy's ext_authz filter counts errors that were let through because of failure_mode_allow in a failure_mode_allowed stat. (Envoy ext_authz filter) A custom filter has to produce that signal itself.

Keymate's Wasm plugin labels every refusal. A 500, 502, 503 or 504 from the gateway is marked GATEWAY_FAILED, and any other refusal is marked DENIED. On the other side, the Access Gateway logs the layer, tech and version of every enforcer that calls it. Watch the ratio of GATEWAY_FAILED to DENIED. A jump in the first is an outage. A jump in the second is a policy change, or a broken contract.

Security limits worth naming

The enforcer and the decision service share a contract. In Keymate's case the contract includes the Keops-Enforcer header, and the gateway rejects requests without it. Rename that header on one side only, and every check comes back as a 400. A fail-closed plugin turns that into a deny for every request. That is safe, but it is also an outage that looks like a policy decision. Ship contract changes to both sides together, and keep a version field in the enforcer header so the gateway can tell which client it is talking to.

Skip paths are an allow list with no decision behind it. They match by prefix, so /health also matches /healthz, and a / entry would match every path and switch enforcement off. Keep the list short, and keep it to paths that carry no data.

Enforcement happens at the server-side proxy, on inbound traffic. Istio describes AuthorizationPolicy enforcement this way, and both plugin variants sit on the inbound path. (Istio security concepts) Traffic that does not pass through an enrolled sidecar is not checked.

The Wasm check runs before Istio authentication. Istio documents the AUTHN phase as "Insert plugin before Istio authentication filters". So the plugin cannot rely on anything peer or request authentication would have established. (WasmPlugin reference)

Headers that identify the enforcer are only as trustworthy as the hop that sets them. If a client can send a header with the same name, the enforcer has to overwrite or strip it before it calls the decision service. Keymate's plugin does this: both variants drop any client-sent Keops-Enforcer, and the older Keymate-Enforcer, and never forward a client's copy.

Ambient mode is out of scope. Everything here assumes sidecars. ztunnel and waypoint proxies are not covered.

The broader lesson for any mesh

Keep three things in the mesh: mutual TLS, routing, and coarse allow or deny rules that never need business context. Send everything that depends on who the caller is and what they are touching to one decision service. Whether the sidecar reaches it through ext_authz or a filter is a delivery detail.

What is not a detail is the failure mode. Pick fail-closed, write it down, and make the denial observable.

Centralized policy enforcement only works if every enforcement point and the decision service keep agreeing. So here is the question to ask before the next upgrade. If your sidecar and your decision service disagreed on one header tomorrow, would your traffic fail open or fail closed? And how fast would you know?

Want one authorization decision for every enforcement point?

Keymate keeps authorization decisions in one Access Gateway that SDKs, API gateway plugins and an Istio plugin all call. See how the decision flow works on your own services.

Stay updated with our latest insights and product updates

Frequently Asked Questions