Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

middleware-chain-visualizer

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.


Contents


Why this exists

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.

Running it

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.

Config formats accepted

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 a service reference) — name, paths, methods, hosts, plugins[]
  • consumers[]username / custom_id, plugins[]
  • plugins[] at the top level — including the service / route / consumer reference fields
  • each plugin entry — name, config, enabled, and an optional non-standard priority override
  • upstreams[].healthchecks.passive — treated as a circuit-breaking layer

What it reads from an Envoy config:

  • static_resources.listeners[].filter_chains[].filters[].typed_config where that config has http_filters
  • http_filters[]name, typed_config, disabled
  • route_config.virtual_hosts[]name, domains, retry_policy, typed_per_filter_config
  • virtual_hosts[].routes[]name, match, route.cluster, route.retry_policy, typed_per_filter_config
  • static_resources.clusters[]outlier_detection, circuit_breakers

How resolution works

Kong: scope precedence

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: phases and priority

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: declared order and per-route overrides

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.

Risk rules

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.

Worked example

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
...

Exports

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.

Adding your own risk rule

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.

Tests

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.

Project layout

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.

Limitations

  • 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 explicit priority: in the pasted config.
  • Envoy dynamic config is out of scope. rds, lds and other xDS-served resources are not fetched; only inline route_config is 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/rename headers, 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 dump output does not use them).

FAQ

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.

Further reading

Background on the concepts this tool models, from the API gateway reference at request-routing.com:

Licence

MIT — see LICENSE. Copyright 2026 request-routing.com.

About

Resolve and visualise the middleware execution chain for a single Kong or Envoy gateway route - scope precedence, plugin priority and filter order made explicit - then get a diagram, a markdown review summary, and explained risks for ordering hazards like rate limiting before auth or a cache ahead of authentication.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages