---
title: "The filter chain"
url: "/docs/concepts/filter-chain/"
kind: "concept"
summary: "The three levels filters attach at, the ordering rules Arion enforces, and how routes override or disable individual HTTP filters."
---

> Arion site index: [/llms.txt](/llms.txt)

# The filter chain

The three levels filters attach at, the ordering rules Arion enforces, and how routes override or disable individual HTTP filters.

The filter chain is Arion's processing model: every policy you enable is a filter attached at one of three levels — listener, network, or HTTP — and this page explains which level to pick and what ordering rules apply. Read it before combining several filters, because the rules here decide what each one gets to see.

## Why it matters

Attachment level determines scope and cost. A listener filter runs once per connection on raw bytes; a network filter runs once per connection with addresses and SNI; an HTTP filter runs on every request with full headers and body access. Blocking a hostile network at the RBAC network filter is cheaper than doing it per request — but only an HTTP filter can act on a path or a JWT claim. Arion validates the ordering rules below when it parses the configuration, so a chain that would behave surprisingly is refused at startup rather than debugged in production.

## How it works

{{ <figure src="img/docs/filter-chain.svg" alt="Three stacked bands for listener, network, and HTTP filters, with filters drawn as chips and the router highlighted as the last HTTP filter" caption="The three attachment levels. A connection flows top to bottom; a request runs the HTTP band left to right, its response right to left." label="Figure 1." /> }}

**Listener filters** attach under the listener's `listener_filters` and inspect the connection before any TLS handshake: the local rate limit (checked at accept, before anything is read), the PROXY protocol reader, and the TLS inspector that extracts SNI for filter chain matching. Each has one job and one slot; there is no ordering to configure.

**Network filters** are the `filters` list of a filter chain. Arion accepts RBAC filters (any number), at most one global rate limit, at most one connection limit, and exactly one terminal filter — the HTTP connection manager or the TCP proxy — which must be the last entry. A rate limit or RBAC filter declared after the terminal filter is a parse error. At runtime the checks run as connection limit, then rate limit, then RBAC, and only then does the terminal filter take the connection.

**HTTP filters** are the `http_filters` list inside the HTTP connection manager, and here declared order is execution order. On the request path filters run first to last; on the response path, the filters that ran are unwound in reverse order — though only filters with response-side behavior (ext_proc, Wasm, CORS, the MCP gateway) act there, while request-side gates such as RBAC, JWT, and rate limits are skipped.

The **router is the terminal HTTP filter, and you must declare it**: Arion does not append one for you. If the last entry of `http_filters` is not `envoy.extensions.filters.http.router.v3.Router`, the config is rejected with "final filter of the chain has to be a router"; a disabled router is rejected too. Everything before the router filters the request; the router itself hands it to [routing](@/docs/traffic/routing.md).

### Per-route overrides

A route can change how the declared filters apply to it through `typed_per_filter_config`, keyed by the filter's declared name. Wrapping the value in `envoy.config.route.v3.FilterConfig` toggles the filter; using the filter's own per-route type — supported for the local rate limit, RBAC (`RBACPerRoute`), and ext_proc (`ExtProcPerRoute`) — replaces its settings on that route:

```yaml,arion=fragment
routes:
  - match: { prefix: "/health" }
    route: { cluster: api }
    typed_per_filter_config:
      local_rate:   # must name a filter declared in http_filters
        "@type": type.googleapis.com/envoy.config.route.v3.FilterConfig
        disabled: true
```

An override naming a filter that is not declared is a parse error. The inverse pattern works as well: declare a filter with `disabled: true` at the listener and enable it only on the routes that need it.

## Key terms

- **Attachment level** — where a filter runs: listener (bytes), network (connection), or HTTP (request).
- **Terminal filter** — the mandatory last network filter (HCM or TCP proxy) or HTTP filter (router).
- **Declared order** — the list order in the config, which is the request-path execution order.
- **`typed_per_filter_config`** — the per-route map that disables a filter or swaps its settings.

## Next

- See where each level sits in the connection's journey: [life of a request](@/docs/concepts/life-of-a-request.md).
- Field-level details for every filter live under the filter reference, starting with the [router](@/reference/filters/http/router.md).
- Write your own HTTP filter with [Wasm](@/docs/extend/wasm-filters.md) or [in Rust](@/docs/extend/http-filters-in-rust.md).
