Work out what actually runs, in what order, for one route of your API gateway — and what that order is going to break.
You paste a Kong declarative config or an Envoy HTTP config, pick a route, and the tool resolves the effective middleware chain: which plugin or filter applies, which of the four or five competing configurations won, what it overrode, which phase it runs in, and where it sits relative to everything else. It draws that chain as a pipeline diagram, and then it reads the chain back to you as a list of ordering hazards — rate limiting ahead of authentication, a cache that can serve private data to anonymous callers, CORS behind an auth plugin so preflights 401, tracing that captures a response before the transformer redacts it.
It is a single static page. No build step, no bundler, no network access, no telemetry. The config you paste never leaves the browser.
- Why this exists
- Running it
- Config formats accepted
- How resolution works
- Risk rules
- Worked example
- Exports
- Adding your own risk rule
- Tests
- Project layout
- Limitations
- FAQ
- Further reading
- Licence
A gateway route is a pipeline, and almost every interesting gateway bug is an ordering bug. The
individual plugins are usually configured correctly; it is their sequence that is wrong. The
trouble is that the sequence is never written down anywhere. In Kong it is an emergent property of
a static integer priority baked into each plugin, combined with a scope-precedence rule that
decides which of up to six competing configurations for the same plugin actually applies. In Envoy
it is the literal order of http_filters, further modified by typed_per_filter_config blocks
that can appear on a virtual host, on a route, or both.
So the question "does rate limiting run before or after JWT validation on /v1/orders?" has an
answer, but reading it out of the config means holding a priority table and a precedence table in
your head at the same time. This tool holds them for you, and then applies the ordering rules that
experienced platform engineers apply by reflex.
There is nothing to install. This project is not published to npm and never will be.
git clone <repository-url>
cd middleware-chain-visualizer
Then open index.html in a browser — double-click it, or:
xdg-open index.html # Linux
open index.html # macOS
start index.html # Windows
It works from file://. There is no server and no build step; the page loads six plain
<script> files from assets/ and runs. There is also a
hosted copy of the visualizer
if you would rather send colleagues a link than have them clone. Configs you paste are parsed
in the browser and never leave the page either way.
Six example configs ship with the page and are selectable from a dropdown, so you can see the
whole thing working before pasting anything of your own. You can also drag a .yaml or .json
file straight onto the text box.
| Input | Detected by | Notes |
|---|---|---|
| Kong declarative config, YAML | _format_version, services, routes, consumers, upstreams |
The format deck dump produces. |
| Kong declarative config, JSON | same keys | Any JSON document is passed to JSON.parse. |
| Envoy bootstrap / static config, YAML | static_resources, or any http_filters array |
Reads http_connection_manager filter chains. |
| Envoy config, JSON | same |
YAML is parsed by a small built-in subset parser (assets/yaml.js), because the project carries no
third-party dependencies. It handles block mappings and sequences, flow collections ([a, b],
{a: 1}), quoted and plain scalars, integers, floats, booleans, nulls, comments and ---
document markers. It deliberately rejects — with a clear message and a line number — anchors
and aliases (&a / *a), block scalars (| and >), and tabs used as indentation, rather than
silently mis-parsing them.
What the tool reads from a Kong config:
services[]—name,url,retries,plugins[],routes[]routes[](nested or top level with aservicereference) —name,paths,methods,hosts,plugins[]consumers[]—username/custom_id,plugins[]plugins[]at the top level — including theservice/route/consumerreference fields- each plugin entry —
name,config,enabled, and an optional non-standardpriorityoverride upstreams[].healthchecks.passive— treated as a circuit-breaking layer
What it reads from an Envoy config:
static_resources.listeners[].filter_chains[].filters[].typed_configwhere that config hashttp_filtershttp_filters[]—name,typed_config,disabledroute_config.virtual_hosts[]—name,domains,retry_policy,typed_per_filter_configvirtual_hosts[].routes[]—name,match,route.cluster,route.retry_policy,typed_per_filter_configstatic_resources.clusters[]—outlier_detection,circuit_breakers
Kong lets the same plugin be configured at several levels at once, and exactly one of them wins. The tool implements this precedence, most specific first:
| Rank | Scope | Where it is written | Beats |
|---|---|---|---|
| 1 | consumer + route | top-level plugins[] with both consumer: and route: |
everything below |
| 2 | route | routes[].plugins[], or plugins[] with route: |
3, 4, 5, 6 |
| 3 | consumer + service | plugins[] with both consumer: and service: |
4, 5, 6 |
| 4 | service | services[].plugins[], or plugins[] with service: |
5, 6 |
| 5 | consumer | consumers[].plugins[], or plugins[] with consumer: |
6 |
| 6 | global | top-level plugins[] with no entity reference |
— |
Only one configuration per plugin name survives. The losers are not discarded: the tool records them, shows them in the "Overrode" column and in the node details panel, so you can see that the 30/minute route limit you are looking at silently replaced a 300/minute service limit and a 60/minute global one.
Because scopes 1, 3 and 5 depend on the caller, the resolved chain changes with the consumer. The "Resolve as consumer" selector re-runs the whole resolution as that caller, which is the fastest way to answer "why does this partner get a different quota?".
A plugin entry with enabled: false is resolved normally but excluded from the chain, and shown
greyed out so you can see that it exists.
Kong runs a fixed sequence of phases per request. Everything in an earlier phase runs before anything in a later one, regardless of priority:
certificate -> rewrite -> access -> [upstream] -> header_filter -> body_filter -> log
Within a phase, plugins run in descending priority order: a higher number runs earlier. The
priorities in assets/catalog.js are the shipped Kong Gateway 3.x defaults, for example:
| Plugin | Priority | Phases |
|---|---|---|
pre-function |
1000000 | all |
zipkin |
100000 | rewrite, header_filter, log |
ip-restriction |
3000 | access |
cors |
2000 | access, header_filter |
key-auth |
1250 | access |
jwt |
1005 | access |
acl |
950 | access |
rate-limiting |
901 | access |
request-transformer |
801 | access |
response-transformer |
800 | header_filter, body_filter |
proxy-cache |
100 | access, header_filter, body_filter |
http-log |
12 | log |
correlation-id |
1 | access, header_filter |
post-function |
-1000 | all |
If you run a plugin recompiled with a different PRIORITY, or a custom plugin the catalogue has
never heard of, add a priority: key to the plugin entry in the config you paste. The tool honours
it and marks the node as "set in config". Plugins with no catalogue entry are assumed to be
access phase, priority 0, and raise the unknown-middleware finding so you know the ordering
below them is a guess.
The response path is computed the same way, over the phases that run after the upstream. That is
why http-log never appears on the request path, and why zipkin appears on both.
Envoy has no priorities. Filters run in the order they are declared in http_filters, and the
response path is the exact reverse — restricted to filters that have an encoder hook, so
jwt_authn does not reappear on the way back but cors does. The last filter must be a terminal
filter (envoy.filters.http.router); anything declared after it is dead code and the
terminal-filter-not-last rule flags it as critical.
Per-route configuration is layered, most specific last:
| Rank | Source | Effect |
|---|---|---|
| 1 | routes[].typed_per_filter_config |
replaces everything below for that route |
| 2 | virtual_hosts[].typed_per_filter_config |
replaces the listener config for the whole vhost |
| 3 | http_filters[].typed_config |
the listener default |
The FilterConfig wrapper is understood: disabled: true at either level removes the filter from
the chain for that route, and a config: key is unwrapped. As with Kong, every layer that lost is
kept and shown.
Each rule produces a finding with a severity, a plain explanation of the mechanism, a concrete failure scenario, a suggested fix, and a link to the matching background reading (see Further reading).
| Rule id | Severity | Fires when | Background reading |
|---|---|---|---|
cache-before-auth |
critical | a response cache can short-circuit the chain before any identity is established | Caching and response optimisation |
terminal-filter-not-last |
critical | (Envoy) the router filter is missing, or something is declared after it | Middleware chains and request transformation |
rate-limit-before-auth |
high | a limiter runs before an auth stage, so it can only key on IP or an anonymous bucket | Rate limiting and throttling strategies |
cors-after-auth |
high | an auth or authz stage runs before the CORS stage, so preflight OPTIONS gets 401 |
Debugging CORS preflight failures |
logging-before-redaction |
high | a logging or tracing hook captures the request or response before a transform that removes or masks fields | Structured access logging |
transform-after-body-reader |
medium | something reads the body (validator, transcoder, lambda) before the transform that rewrites it | Rewriting JSON payloads on the fly |
response-transform-after-observer |
medium | a response-side observer records the upstream response before the transform that rewrites it | Request and response transformation |
retry-amplification |
medium | two or more independent layers retry or trip (route + virtual host retry policies, service retries + passive healthchecks, a fault filter) |
Tuning retry budgets to prevent thundering herd |
header-collision |
medium | two middleware write the same header, so only the last write survives | Request and response transformation |
no-authentication |
low | no stage on the route establishes or checks identity | Middleware chains and request transformation |
rate-limit-after-auth-cost |
info | every limiter is after auth — the correct default, stated with its trade-off | Rate limiting and throttling strategies |
unknown-middleware |
info | a plugin or filter is not in the catalogue, so its phase and priority are assumptions | Middleware chains and request transformation |
Findings are sorted most severe first. A rule may fire more than once if several pairs of nodes match; each occurrence names the specific pair.
Load the bundled example "Kong — four scopes competing for one plugin". It configures
rate-limiting globally (60/minute), on the service (300/minute), on the route (30/minute), for
the consumer partner-acme (1200/minute), and once more for that consumer on that route
(5000/minute).
Resolved as an anonymous caller, the route-scoped config wins:
key-auth | scope: service | {"key_names":["apikey"]}
acl | scope: route | {"allow":["reporting"]}
rate-limiting | scope: route | {"minute":30,"policy":"redis"} overrode: service, global
Switch the consumer selector to partner-acme and the most specific scope takes over:
key-auth | scope: service | {"key_names":["apikey"]}
acl | scope: route | {"allow":["reporting"]}
rate-limiting | scope: consumer + route | {"minute":5000,...} overrode: route, service, consumer, global
Now load "Kong — storefront API with an inverted plugin order" and press Copy markdown summary. This is exactly what lands on your clipboard, ready to paste into a PR:
## Middleware chain: orders
- Gateway: `kong`
- Service: `storefront-api`
- Matches: `GET,POST,OPTIONS /v1/orders`
### Request path
1. **rate-limiting** (rate limiting) phase access, priority 1400 - from route
2. **jwt** (authentication) phase access, priority 1005 - from route
3. **cors** (CORS) phase access/header_filter, priority 900 - from route
-> upstream `http://storefront.internal:8080`
### Response path
1. **cors** (CORS) phase access/header_filter, priority 900 - from route
2. **http-log** (logging) phase log, priority 12 - from route
### Risks (2)
#### HIGH - CORS runs after authentication, so preflights get 401
jwt runs before cors. A CORS preflight is an unauthenticated OPTIONS request with no
Authorization header and no cookies, by design -- browsers never attach credentials to a
preflight.
**How it fails.** The browser sends OPTIONS /v1/orders, jwt rejects it with 401 before cors can
answer it, and the browser reports a generic "blocked by CORS policy" error. The actual GET or
POST is never sent, and the server-side logs show only a 401 on OPTIONS, which is easy to dismiss
as a client bug.
**Fix.** Order cors before jwt so it can short-circuit the preflight with 204 and the right
Access-Control-* headers. In Kong that is the default (cors priority 2000 beats every auth
plugin) -- an inverted order almost always means a hand-set priority; in Envoy, declare the cors
filter before the auth filter.
Further reading: [Debugging CORS preflight failures](https://www.request-routing.com/middleware-chains-request-transformation/cors-cross-origin-security/debugging-cors-preflight-failures-runbook/)
#### HIGH - Rate limiting runs before authentication
...| Button | Produces |
|---|---|
| Download diagram (.svg) | A standalone SVG with its styles embedded and no external references. It picks up your current light/dark preference at download time and renders correctly in a browser, in an image viewer, and in most markdown renderers. |
| Copy markdown summary | The resolved chain plus every finding, as the markdown shown above. |
| Copy shareable link | The whole state — config text, selected route, selected consumer — base64url-encoded into the URL hash. Nothing is uploaded; the recipient's browser decodes and re-resolves it locally. Long configs make long URLs. |
The URL hash is kept in sync as you change route or consumer, so the address bar is always shareable without pressing anything.
Rules live in the RULES array in assets/chain.js. A rule is an object with an id, a title,
a severity, a documentation link, and a check(chain) function that returns an array of
findings. finding(this, {...}) fills in the boilerplate from the rule.
{
id: 'basic-auth-on-public-internet',
title: 'Basic auth is exposed on an internet-facing route',
severity: 'high',
docs: DOCS.auth,
docsLabel: 'Authentication proxying and token validation',
check: function (chain) {
var rule = this;
return chain.requestOrder
.filter(function (node) { return node.name === 'basic-auth'; })
.map(function (node) {
return finding(rule, {
nodes: [node.id],
explanation: '...what is true about this chain...',
scenario: '...how it fails in production...',
fix: '...what to change...'
});
});
}
}What check receives:
| Field | Meaning |
|---|---|
chain.engine |
'kong' or 'envoy' — return [] early if your rule is engine-specific |
chain.active |
every enabled node, unordered |
chain.requestOrder |
enabled nodes with a request-side hook, in execution order |
chain.responseOrder |
enabled nodes with a response-side hook, in execution order |
chain.retryLayers |
{ where, setting, attempts } for each retrying or tripping layer |
chain.route, chain.consumer, chain.upstream |
what the chain was resolved for |
node .category |
auth, authz, ratelimit, transform, cache, cors, logging, observability, security, validation, resilience, proxy, custom, other |
node .requestIndex / .responseIndex |
position on each path, or null |
node .meta |
catalogue flags: readsBody, mutatesRequest, mutatesResponse, needsConsumer, identifiesConsumer, mustBeLast |
Reason about .category and .meta rather than plugin names wherever you can — a rule written
against categories fires for jwt, key-auth, oauth2 and envoy.filters.http.ext_authz alike.
If your middleware is simply missing, add it to KONG_PLUGINS or ENVOY_FILTERS in
assets/catalog.js instead of writing a rule; every existing rule immediately understands it.
Then add a test that it fires and a test that it does not fire on a correct chain. The rule
coverage test in tests/risks.test.mjs fails until your rule id is listed there, which is
deliberate.
The analysis logic is an ES module (assets/chain.mjs), imported by both the page and the tests.
Run the suite with Node's built-in runner — no dependencies, no test framework:
$ node --test
ℹ tests 63
ℹ suites 0
ℹ pass 63
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Coverage: the YAML subset parser and its error handling, engine detection, all six Kong scope
precedence combinations, enabled: false, priority ordering and priority overrides, phase-beats-
priority, response-path membership, Envoy declared ordering, Envoy reverse response ordering,
virtual-host and route typed_per_filter_config layering, disabled: true, every risk rule
firing and not firing, the markdown export, the SVG export (self-containment and escaping), and
every bundled example.
Node 18 or newer is required for the built-in test runner. Older Node versions can run a
single file at a time with node --test tests/risks.test.mjs.
index.html the page
assets/yaml.js YAML subset parser
assets/catalog.js what we know about each plugin / filter: phase, priority, category
assets/chain.js parsing, resolution, risk rules, markdown export (the brain)
assets/chain.mjs ES module facade over chain.js, used by the tests
assets/diagram.js hand-authored inline SVG renderer
assets/examples.js six bundled example configs
assets/app.js DOM wiring only
assets/app.css layout and light/dark theme
tests/*.test.mjs node --test suite
chain.js is written as a classic script that also exports itself for Node. That is what lets the
same file power a page opened from file:// — where ES module scripts are blocked by the origin
policy — and still be imported as a real module by the test suite through chain.mjs.
- Static analysis only. It reads config, it does not talk to a running gateway. It cannot know
about plugins loaded from a database, from
deck-managed workspaces you did not paste, or from Kong's Admin API at runtime. - Priorities are a catalogue, not an oracle. They match Kong Gateway 3.x defaults. A plugin
recompiled with a different
PRIORITY, or an enterprise plugin not listed, needs an explicitpriority:in the pasted config. - Envoy dynamic config is out of scope.
rds,ldsand other xDS-served resources are not fetched; only inlineroute_configis resolved. A listener with no inline routes is still listed so you can inspect its filter chain. - Route matching is not simulated. You choose the route; the tool does not decide which route a given request would match. Route matching precedence is a separate problem from chain ordering.
- Weighted-cluster per-filter config is not read, only virtual host and route level.
- Header collision detection is best effort. It reads structured header fields
(
add/append/replace/renameheaders,header_name,request_headers_to_add,response_headers_to_add). Headers written from Lua, WASM or a custom plugin body are invisible to it. - The YAML subset is a subset. Anchors, aliases and block scalars are rejected rather than
guessed at. If your config uses them, resolve them first (
deck dumpoutput does not use them).
Does my config get uploaded anywhere?
No. There is no network code in the page at all — no fetch, no XMLHttpRequest, no analytics,
no external stylesheet or font. Open it from file:// with the network cable unplugged and
everything works, including the shareable link, which encodes state into the URL rather than
storing it anywhere.
Why does the tool say my chain is fine when I know it is broken? The rules only catch ordering hazards that are visible in config. A plugin that is correctly ordered but wrongly configured — the wrong issuer, the wrong cache key, a limit off by a factor of sixty — is out of scope. Click each node and read the resolved config; that panel exists for exactly this.
Kong says my plugin runs at a different point than you show.
Either the plugin was recompiled with a non-default PRIORITY, or it is an enterprise or custom
plugin absent from the catalogue (you will see the unknown-middleware finding in that case). Add
priority: <n> to the plugin entry in the config you paste, or add the plugin to
assets/catalog.js.
Why is cors shown twice, on the request path and the response path?
Because it runs twice. The access hook answers preflight OPTIONS requests; the header_filter
hook adds Access-Control-* headers to real responses. Middleware with hooks on both sides
appears in both lanes, and the risk rules reason about each side independently.
Can it read an NGINX or Tyk config? Not today. The resolution model is engine-specific — a phase-and-priority model for Kong, a declared-order model for Envoy — and each new engine needs its own resolver. The risk rules themselves are engine-agnostic: they work on categories and positions, so a third resolver would inherit all of them.
The diagram is wider than the screen. It scrolls inside its own container. The page body itself never scrolls horizontally, at any viewport width.
Is there a CLI?
No, but assets/chain.mjs is a plain ES module with no browser dependencies, so a fifteen-line
Node script can resolve a chain and print toMarkdown() output in CI. That is how the tests use it.
Background on the concepts this tool models, from the API gateway reference at request-routing.com:
- Middleware chains and request transformation — the pipeline model the whole tool is built around
- Rate limiting and throttling strategies — why the limiter's position decides what it can key on
- Debugging CORS preflight failures — the 401-on-OPTIONS failure in detail
- Caching and response optimisation — cache keys, and what belongs in them
- Request and response transformation — where transforms belong relative to validators and observers
- Tuning retry budgets to prevent thundering herd — why stacked retries multiply
- Structured access logging — what a log hook can and cannot see
MIT — see LICENSE. Copyright 2026 request-routing.com.