Architecture Patterns for Building Gateways with Envoy Proxy
This article organizes the architecture patterns for building an API gateway around Envoy Proxy. Envoy is best known as Istio's data plane, but it is an independent, general-purpose proxy born at Lyft that can be used standalone β no Kubernetes, no service mesh required. This "freedom as a raw material" is Envoy's greatest strength, and at the same time it forces a design decision: how much do you build yourself?
The article has three parts. For every pattern, I make explicit which problem it solves.
- Configuration management and control plane patterns β who delivers configuration to Envoy, and how
- Authentication and authorization patterns β which layer decides what
- Comparison with OSS gateways that need no custom authorization service β the structural difference from Kong, APISIX, and friends
Background: What Envoy Provides as a Gateway
Envoy is not just a reverse proxy β it ships the building blocks a gateway needs as a filter chain. A request passes through the filters in order, and each filter verifies, transforms, or decides.
Figure: Envoy's filter chain. Requests pass through each filter in order for verification, decision, and transformation
The main building blocks:
- jwt_authn filter: built-in JWKS fetching and caching, signature and exp / aud / iss verification. Verified claims are handed to downstream filters
- RBAC filter: declarative access control such as matching routes against scopes
- ext_authz filter: the official interface for querying an external authorization service via gRPC / HTTP on every request
- Lua / WASM filters: run custom logic inline, inside the proxy process
- mTLS, rate limiting, observability (access logs, metrics, tracing)
In other words, the division of labor β "standard token verification is built in, custom attribute-based decisions live in the extension layer" β is there from the start. Once you see this structure, choosing an authorization pattern reduces to the question: which filter should hold the decision?
Part 1: Configuration Management and Control Plane Patterns
The first wall you hit when using Envoy standalone is not the proxy's performance or features β it is configuration management.
Problem: How do you distribute and update Envoy's configuration (routes, clusters, filters)? Redeploy on every change, or push dynamically? And if dynamically β who builds the distribution server?
Envoy accepts configuration in two ways: static config read from YAML at startup, and dynamic delivery from an external server over the standard xDS API. The latter is exactly what Istio's istiod does.
First, the basic shape of xDS-based dynamic delivery. The control plane (istiod in Istio) owns the source of truth and pushes it over xDS to every Envoy β the Gateway Pod and every app Pod's sidecar alike. Envoy itself is an "empty vessel" holding no configuration; this separation is what makes policy changes without proxy restarts possible.
Figure: The basic xDS shape. The control plane dynamically delivers configuration to all Envoys (Gateway Pod and app Pod sidecars)
Four practical patterns sit on top of this basic shape.
Figure: The four control plane patterns. They differ in who delivers configuration to Envoy, and how
Pattern A: Static Configuration (Hand-Written YAML)
Problem: You just want Envoy running as a gateway. No Kubernetes (ECS, EC2).
Solution: Keep Envoy's YAML in a repository and swap containers via CI/CD on every change. Running Envoy as an ECS task behind an NLB as an L7 gateway is a perfectly workable setup.
- Good fit: routing and policy change infrequently (weekly or less), few teams
- Remaining problem: every change triggers a deploy. The moment you want teams to self-serve route additions, this starts to break down. The coupling of "config change = infrastructure deploy" bites harder as the organization grows
Pattern B: Building Your Own xDS Control Plane
Problem: Pattern A needs a task swap for every change. You want dynamic configuration, but Kubernetes is not available.
Solution: Run your own distribution server β the equivalent of istiod β and stream configuration to Envoy over xDS. Routes and policies change without proxy restarts.
- Reality: off-the-shelf options outside Kubernetes are scarce β roll your own with go-control-plane, or Consul, and that's about it. Building your own means committing to "maintaining a small istiod" indefinitely
- Remaining problem: you own the control plane's availability, testing, and versioning. If changes really are frequent, first consider AWS-managed alternatives β ALB, API Gateway, VPC Lattice, ECS Service Connect β before building; a custom xDS server is the last resort
Pattern C: Envoy Gateway (the Official OSS Control Plane on Kubernetes)
Problem: You want dynamic delivery without writing an xDS server β but you don't need Istio's full mesh (sidecar injection, east-west mTLS, istiod operations) either.
Solution: On Kubernetes (e.g. EKS), the official OSS Envoy Gateway is the middle ground. It bundles a Gateway API-compliant control plane: write CRDs like HTTPRoute and they are delivered over xDS. The build-your-own-xDS problem disappears, and the ext_authz / WASM extension points are already wired up.
- Good fit: you only need a north-south (external-to-internal) gateway. Clearly lighter than Istio
- Remaining problem: coverage is north-south only. Service-to-service (east-west) traffic remains unprotected
Pattern D: Istio Ingress Gateway (as Part of the Mesh)
Problem: Protecting only the gateway leaves you with "perimeter defense with a single checkpoint" if internal services talk to each other unauthenticated and in plaintext. You want to satisfy NIST SP 800-207's core tenet: never use network location as a basis for trust.
Solution: Adopt Istio and unify both north-south (Ingress Gateway) and east-west (sidecar / ambient) on Envoy. The Ingress Gateway is just an Envoy Pod (a Deployment) running inside the cluster, receiving configuration from istiod over xDS. Internal services can trust the user-identity headers attached at the edge precisely because east-west traffic is protected by mTLS plus service identity authorization, guaranteeing those headers can only come from the legitimate gateway. Try the same thing without a mesh and header spoofing is wide open.
Figure: The Istio setup at a glance. The NLB is AWS-managed; the Ingress Gateway and app Pods live in the cluster. Every hop past the gateway is protected by mTLS + service identity authorization
- Good fit: organizations needing fine-grained L7 service-to-service authorization or multi-tenant governance
- Remaining problem: operating istiod, sidecar resource overhead, and Pod-restart operations during Istio upgrades. A platform team (roughly six people minimum) is a prerequisite
Part 1 Summary: Problems and Solutions
| Problem | Solution pattern |
|---|---|
| Just get it runningγ»low churnγ»non-K8s | A: static config + CI/CD |
| Frequent dynamic changesγ»non-K8s | Managed alternatives first (ALB / API Gateway) β B: custom xDS as last resort |
| North-south gateway only, on K8s | C: Envoy Gateway |
| Full zero trust including east-west | D: Istio |
Part 2: Authentication and Authorization Patterns
Authorization is the heart of a gateway. Envoy's design philosophy is PEP / PDP separation β the proxy sticks to being the PEP (Policy Enforcement Point) while the PDP (Policy Decision Point) can be built in or external, depending on the situation. This is the classic pattern from the XACML era, brought into the proxy.
Figure: PEP / PDP separation in its basic form. Envoy enforces; decisions go to the authz service; the truth about consent and tokens lives in the authorization server
As a map of the whole territory, keep in mind that authorization splits into three layers.
Figure: The three layers of authorization. The lower the layer, the more "business-shaped" the data its decisions require
| Layer | What it decides | Data required |
|---|---|---|
| Edge (Gateway) | End-user JWT verification β "who is this" | Token and public keys (JWKS) |
| Mesh | Which service may talk to whichγ»mTLS | Service identity (certificates) |
| Application | May this user touch this resource | Business data: ownership, sharing settings |
Coarse per-endpoint guardrails belong to the gateway and mesh; per-resource business authorization belongs to the application layer. Break this division β "let the gateway do everything" β and policy maintenance costs explode, because the business data those decisions need is structurally invisible to the gateway. When a resource-level permission graph spans multiple services, that is Zanzibar territory (SpiceDB / OpenFGA) β but that lives outside the gateway.
Below are the edge-layer (gateway) implementation patterns, problem-first. The essence of choosing between them is where the information needed for the decision lives.
Figure: Choosing an authorization implementation pattern. The branching axis is where the decision's inputs live
Pattern 1: Built-in Filters Only (JWT + RBAC)
Problem: Verify the end user's JWT and match scopes against routes β without adding external dependencies or latency.
Solution: The jwt_authn filter completes local verification against JWKS. JWKS holds public keys, so it caches well; the authorization server is involved only at issuance and JWKS fetch. Coarse scope-to-route matching is written declaratively with the built-in RBAC filter. Zero external dependencies, minimal latency, no failure mode to design.
Guiding principle: any decision expressible in configuration (CRDs, in Istio) belongs here by default. East-west traffic in particular completes stateless verification on JWTs, so ninety percent of authorization is covered by this layer. Istio's RequestAuthentication + AuthorizationPolicy is exactly this standard form.
Pattern 2: Inline Extensions (Lua / WASM)
Problem: FAPI-specific checks β matching the cnf claim against the mTLS client certificate, for example β fall outside jwt_authn's remit. Yet the decision needs nothing beyond the request itself, and standing up an external service just for that feels wrong.
Solution: For stateless decisions that need only the request plus pre-distributed configuration, write a small inline filter in Lua or WASM. It runs inside the proxy process, so no external dependency is added.
- WASM modules follow the proxy-wasm spec and can be written in Rust (the primary choice), C++, TinyGo, and more. Compiling OPA's Rego to WASM is a known combo
- Remaining problem (operational weight): hard to debug, a bug takes the proxy down with it, and you must design module distribution and versioning yourself. There is a real distance between "can write it" and "can keep operating it"
Pattern 3: External Delegation via ext_authz
Problem: The decision needs external state at request time β introspection, consent status, a permission DB, revocation lists. None of this can be pre-distributed to the proxy, so inline evaluation is impossible in principle.
Solution: The ext_authz filter queries an external authorization service on every request. The proxy stays a pure PEP; decisions belong to the PDP.
Figure: The ext_authz flow, steps β through β£. On Denied, it stops at β’ and the client receives a 403 or similar
Design points:
- The interface is a single-method contract: with gRPC,
Check(CheckRequest) β CheckResponse. The authorization service can be written in any language; using OPA as the ext_authz server is a standard setup - Push endpoint knowledge into policy: the table of path patterns Γ required scopes / consent belongs in the PDP's policy data (Rego, if OPA) β not in the interface. Keep the interface as "facts in β verdict out" and it survives API growth
- Portability: Istio's AuthorizationPolicy CUSTOM action also calls ext_authz under the hood, so the same authorization service is reusable across ECS / EKS and across gateway swaps
- Latency: short-TTL caching of verdicts + placing the authz service as a sidecar / on the same node (sub-millisecond over localhost)
- Remaining problem (the failure-mode dilemma): when the external PDP is down, you must explicitly choose fail-close (deny all; the PDP becomes a SPOF) or fail-open (let everything through; security drops). Inlining (Patterns 1 and 2) eliminates the dilemma itself β the essential advantage of inline is "fail-close-grade safety without sacrificing availability"
In practice a two-stage stack is common: the inline layer cheaply rejects the obviously bad (invalid signature, expired, missing scope), and only what passes goes to external decision-making.
Pattern 4: Phantom Token (Token Exchange at the Edge)
Problem: With opaque (reference) tokens, every verification requires introspection. Every request carries a serial dependency "gateway β authorization server": availability degrades multiplicatively and the authorization server caps the whole system's throughput. But migrating fully to JWTs sacrifices revocation immediacy β a withdrawn consent stays live until expiry.
Solution: The Phantom Token pattern localizes the serial dependency to "once, at the edge." Exchange the opaque token for a JWT at the edge; everything inside is self-contained local JWT verification.
Figure: The Phantom Token flow. Steps β’β£ occur only on first sight of a token and on TTL expiry; on a cache hit the flow completes with β‘ββ€
Design points:
- One call is enough: have the introspection response itself be a JWT (RFC 9701: JWT Response for OAuth Token Introspection). Verification and acquisition are the same event in the same round trip
- No signature check needed at the edge: the JWT arrives directly from the authorization server over a trusted mTLS / TLS channel. Signature verification does its work inside β each service sidecar's jwt_authn β which dovetails with the "edge + internal re-verification" architecture
- Caching: TTL cache keyed by opaque token compresses actual calls to "first sight + TTL expiry" per token
- The other lineage is Token Exchange (RFC 8693) β issuing a new token, suited to active transformations like narrowing aud to the destination service. For plain opaqueβJWT conversion, RFC 9701 is the shortest path
- Remaining problem: RFC 9701 / RFC 8693 support varies by authorization server product. Without it you end up minting JWTs yourself β inheriting key management and getting heavy fast β so put it in the requirements at server selection time
The Deciding Axis for Token Strategy: Revocation SLA
The choice between introspection (opaque) and JWT ultimately comes down to the revocation SLA β how much delay you tolerate before a withdrawn consent takes effect. Introspection's serial dependency was the price of real-time revocation checking.
| Revocation SLA | Strategy |
|---|---|
| A few minutes of delay is acceptable | Short-lived JWTs (5β15 min); push revocation delay into token lifetime. Widely accepted even in FAPI contexts |
| Minutes acceptable, but want near-real-time | Short-lived JWTs + async revocation events (denylist push, Shared Signals / CAEP) |
| Immediate revocation required | Keep introspection + sidecar-placed adapter + short-TTL cache + redundant authorization server. Or Phantom Token to localize the serial dependency to the edge |
Part 2 Summary: Problems and Solutions
| Problem | Solution |
|---|---|
| JWT verification and scope control at low latency | Pattern 1: jwt_authn + RBAC (built-in) |
| Stateless decision not expressible with built-ins | Pattern 2: Lua / WASM inline |
| Decision needing external state (consent, permission DB) | Pattern 3: ext_authz + separate PDP (sidecar placement + short-TTL cache + explicit fail mode) |
| Availability degraded by opaque tokens' serial dependency | Pattern 4: Phantom Token (RFC 9701), localized to the edge |
| Balancing revocation immediacy with local verification | Decide by revocation SLA (short-lived JWT / event push / keep introspection) |
| Per-resource business authorization | Not the gateway's job. Application layer (Zanzibar-style if needed) |
Part 3: Comparison with OSS Gateways That Need No Custom Authorization Service
The Envoy setups so far assumed, in Patterns 3 and 4, that you write an authorization service (adapter). It is only a few hundred lines of glue β introspection calls, certificate-binding checks, header propagation β but it is still one more independent component.
Meanwhile, OSS gateways like Kong, APISIX, Tyk, and KrakenD ship authentication and authorization as bundled plugins: JWT verification, OIDC, API keys, ACLs, and rate limiting work with configuration alone β no custom service.
Problem: Which cost do you take β writing and operating a custom authorization service, or living within the constraints of bundled plugins?
The Structural Difference: PEP / PDP Separated vs. Co-located
This choice is also a difference in philosophy.
Figure: Three PEP / PDP placements β separated, co-located, and the hybrid that takes the best of both: logical separation, physical co-location
- Envoy + ext_authz: PEP and PDP are separated. Authorization logic is an independent service outside the gateway
- Kong / APISIX etc.: PEP and PDP are co-located. Authorization logic runs as plugins (Lua etc.) inside the gateway process
Co-location has historically been the mainstream, and its virtues are fewer hops and simplicity. But it comes with three couplings:
- Change lifecycle coupling β fixing authorization logic = redeploying the gateway. The more often policy changes, the more it hurts
- Failure and resource coupling β a buggy or overloaded authorization plugin takes the whole data plane down with it. No fault isolation for authorization
- Team ownership coupling β code written by the authorization team (security / identity platform) lands in the heart of the platform team's infrastructure. Deploy rights and review responsibility get tangled
In Istio's case these couplings are mitigated by CRDs: policies are declarative configuration dynamically distributed by istiod over xDS, so no proxy redeploy (resolves coupling 1); CRDs are namespaced and ownership separates via Kubernetes RBAC (resolves coupling 3). Part 2's principle β "inline whatever configuration can express; push out only what it can't" β holds because of this mitigation.
Comparison Table
| Aspect | Envoy + custom authz (ext_authz) | OSS gateway bundled plugins (Kong / APISIX etc.) |
|---|---|---|
| Initial cost | High. Adapter implementation (hundreds of lines+) + control plane selection | Low. JWT, OIDC, key-auth, ACL work with configuration alone |
| Freedom for custom authz | High. Any language; external-state lookups by design | Within the plugin SDK (Kong: Lua / Go / WASM; APISIX: Lua / WASM). Fancy requirements end in custom plugin development anyway, narrowing the cost gap |
| Portability | An ext_authz authz service is gateway-agnostic. The same contract works with Istio and Envoy Gateway β reusable across ECS / EKS and future gateway swaps | Plugins are gateway-specific. Rewrite on migration |
| Fault isolation | Choose fail-open / fail-close on authz outage; the data plane survives | Plugin failure shares the gateway's fate |
| Change lifecycle | Authz service deploys independently; update authorization without touching the gateway | Plugin update = gateway redeploy (dynamic-reload support varies by plugin type) |
| Operational surface | Three things: Envoy + control plane + authz service. More | Gateway + management DB (PostgreSQL for Kong, etcd for APISIX). Less |
| Admin UI / developer portal | None; add separately if needed | Bundled, or in the commercial edition |
| Mesh integration | Same data plane technology as Istio / Envoy Gateway β one consistent mechanism north-south and east-west | Gateway is north-south only; east-west becomes a different technology |
Which to Choose
Bundled OSS gateway fits when:
- Authorization needs are combinations of standard patterns (JWT verification, scope control, API keys, IP restriction, rate limiting)
- No proprietary data (consent, contract tiers, custom permission models) feeds the decisions
- A small team that doesn't want to own an extra authorization component
- You value the surrounding features β admin UI, API catalog
Envoy + custom authorization fits when:
- Decisions must consult external state at request time β FAPI compliance, custom consent verification
- The same authorization logic should be reused in multiple places (edge gateway, mesh, a future different gateway)
- Authorization changes must be independent of gateway deploys (high change frequency, different owning team)
- You already run Istio / Envoy Gateway and want one data plane technology
The middle ground: logical separation, physical co-location. Place the PDP (OPA etc.) as a sidecar in the same Pod / task as the gateway: hop cost is near zero over localhost, while change-lifecycle and failure isolation are preserved. Consider this before embedding authorization logic into the gateway process β you'll regret it less. With OPA, policy distribution runs through the Bundle API under GitOps, enabling the ownership split "platform team owns the runtime; security / service owners own the policy content."
To be fair, Kong and APISIX also have external-delegation plugins (forward-auth style), so external PDPs are not Envoy-exclusive. But Envoy's ext_authz speaks the same contract across the whole ecosystem β Istio and Envoy Gateway included β which makes its portability meaningfully stronger in practice.
Overall Summary: ProblemβSolution Table and Decision Flow
All the problems and solutions in one place:
| # | Problem | Solution |
|---|---|---|
| 1 | Delivering config (non-K8s, low churn) | Static YAML + CI/CD |
| 2 | Delivering config (K8s, north-south only) | Envoy Gateway (Gateway API) |
| 3 | Delivering config (incl. east-west) | Istio (istiod + CRD) |
| 4 | Standard token verification | jwt_authn + RBAC (built-in, zero external deps) |
| 5 | Stateless decisions beyond built-ins | Lua / WASM inline |
| 6 | Decisions needing external state | ext_authz + separate PDP (sidecar placement + cache + explicit fail mode) |
| 7 | Opaque tokens' serial dependency | Phantom Token (RFC 9701), localized to the edge |
| 8 | Revocation immediacy | Strategy by revocation SLA (short-lived JWT / event push / introspection) |
| 9 | Header spoofing past the edge | Mesh mTLS + service identity authz (the edge alone can't prevent it) |
| 10 | Business-authz data invisible to the gateway | Keep it in the app layer; Zanzibar-style if the graph spans services |
| 11 | To write or not to write an authz service | Bundled plugins if standard patterns suffice; ext_authz for external state / portability; when in doubt, logical separation + physical co-location |
Finally, the whole decision flow as one figure.
Figure: The full selection flow. The upper half decides the gateway architecture; the lower half decides the authorization implementation
Building a gateway with Envoy is not buying a finished product β it is assembling enforcement points from parts. In exchange, you cannot escape the authorization design decisions: which layer decides what, where the decision's data lives, which way to fail. Conversely, for systems that want those decisions made explicitly β finance, multi-tenant platforms, regulated industries β that very freedom is the reason to choose it. Start light with a bundled-plugin gateway while standard patterns suffice, and move to ext_authz the moment external-state decisions or portability demands appear. That such a migration path exists at all is the value of PEP / PDP separation as a design.
