I set up a Traefik router that was only supposed to be reachable after a parent router injected a header, then tried to reach it directly by sending that header myself. I wanted to know whether "child routers cannot be called directly", a specific claim in Traefik's own 3.6 announcement, actually holds when you attack it rather than just read about it.
Traefik 3.6 shipped in September 2026 with a feature called multi-layer routing. A parent router can run middleware, and child routers reference it with a new parentRefs field, matching only after the parent's rule and middleware chain have already run. The pitch is that you can gate access to a set of routers behind an upstream auth step, and the child rules can key off things the middleware added, like a header from a forward-auth check, without repeating the parent's own matching logic in every child.
That is also a security boundary, so I spent the next couple of hours trying to walk around it.
Setting it up
I ran Traefik 3.6.25 in Docker with the file provider, three traefik/whoami containers standing in for backend services, and this dynamic configuration:
http:
middlewares:
role-admin:
headers:
customRequestHeaders:
X-Role: "admin"
services:
admin-svc:
loadBalancer:
servers:
- url: "http://admin-svc:80"
default-svc:
loadBalancer:
servers:
- url: "http://default-svc:80"
routers:
parent-api:
rule: "PathPrefix(`/api`)"
entryPoints: ["web"]
middlewares: ["role-admin"]
child-admin:
rule: "HeaderRegexp(`X-Role`, `^admin$`)"
parentRefs: ["parent-api"]
service: "admin-svc"
catchall:
rule: "PathPrefix(`/`)"
entryPoints: ["web"]
service: "default-svc"
priority: 1
role-admin always sets X-Role: admin on anything that matches parent-api, regardless of what the client sent. child-admin only fires for requests carrying that header, and it only exists as a child of parent-api. The idea is that a real deployment would replace role-admin with a forward-auth middleware that sets the header based on an actual login check, and everything downstream trusts the header because only the parent's middleware chain can set it.
Two requests through the front door behaved exactly as documented:
$ curl -s http://localhost:18080/api/anything | grep -i x-role
X-Role: admin
$ curl -s -H "X-Role: user" http://localhost:18080/api/anything | grep -i x-role
X-Role: admin
Sending X-Role: user did nothing. The parent's middleware overwrote it before the child router ever saw the request, which is the whole point.
The bypass attempt
The interesting question is what happens if I send the header the child router is looking for, but on a path that never touches the parent. If child-admin were evaluated on its own rule, HeaderRegexp('X-Role', '^admin$') would match a bare curl -H "X-Role: admin" http://localhost/.
$ curl -s -H "X-Role: admin" http://localhost:18080/ | head -3
Hostname: 64d56d8ef346
IP: 127.0.0.1
IP: 172.18.0.4
64d56d8ef346 is default-svc, the catchall container, not admin-svc. I checked the full response body and there was no X-Role header value of admin reflected from my request being treated as a match; it just fell through to the catchall, as if child-admin did not exist for that request. I repeated it against a second, more distinctive path in case / was special-cased somehow, with the same result.
Then I ran it under load, since a single request proves nothing about a router table under contention. I fired 500 requests at 50 concurrent workers, each spoofing X-Role: admin on a path outside /api:
$ seq 1 500 | xargs -P 50 -I{} sh -c \
'curl -s -H "X-Role: admin" http://localhost:18080/not-api/{} -o out/{}.txt'
$ grep -l "Hostname: 9b1d2aab44d3" out/*.txt | wc -l # admin-svc hits
0
$ grep -l "Hostname: 64d56d8ef346" out/*.txt | wc -l # default-svc hits
500
Zero of five hundred reached the admin service. This is the one finding in this post I'd call reassuring rather than merely interesting: the specific claim in Traefik's release material checked out under an actual attempt to break it, not just under a docs example.
What it refuses, and the mistakes that produced those errors
Getting to a working config took three wrong turns, and each one produced a distinct, useful error.
First, I gave the child router its own entryPoints, the way any ordinary router needs one:
"error while parsing rule for router parent-api@file"
"non-root router cannot have Entrypoints configuration"
A child router inherits its entry point from the parent. Declaring one yourself gets the router disabled outright, silently as far as request handling goes, though it does show up as "status": "disabled" with that error string on the /api/http/routers debug endpoint.
Second, I wrote HeadersRegexp for the header matcher (plural, with an "s"), which is what I'd taken from a summary of Traefik's own blog post. It does not exist:
"error while parsing rule HeadersRegexp(`X-Role`, `^admin$`): unsupported function: HeadersRegexp"
The real function is HeaderRegexp, singular. This cost me ten minutes because the error looks like a syntax problem, not a wrong function name, until you read past the first clause.
Third, I pointed parentRefs at a router name that did not exist, to see what a typo in a parent reference does in production:
"parent router \"does-not-exist@file\" does not exist"
"router is not reachable"
More interesting: the parent router itself also went into an error state, because a router with no service and no working children has nothing to do:
"router has no service and no child routers"
"router must have either a service or child routers"
So a parent-only router is not valid on its own. It needs at least one child that actually resolved, or the whole parent gets disabled too, which in my case meant the entire /api prefix silently stopped routing until I fixed the typo.
Three levels of nesting, and the version-mixing risk
The documentation only shows two layers, so I built three: a grandparent matching /api, a parent matching the role header the grandparent set, and a child matching a second header (X-Tier: gold) set by the parent's own middleware. It worked on the first attempt once the syntax was right, correctly routing to admin-svc only when all three conditions were satisfied in order.
The more surprising result came from testing backward compatibility. I pointed the exact same dynamic configuration file at Traefik 3.5.6, the previous minor version, expecting the parentRefs field to be ignored on the one router that used it. Instead:
"/etc/traefik/dynamic/config.yml: field not found, node: parentRefs"
That error killed the entire file, not just the router with the unknown field. Every router in that config, including the plain catchall that had nothing to do with multi-layer routing, stopped being served:
$ curl -s -i http://localhost:18090/api/x | head -2
HTTP/1.1 404 Not Found
If you run a fleet of Traefik instances behind a shared dynamic config and roll out 3.6 gradually, any instance still on 3.5 that reads a config file containing parentRefs loses all of its routing, not just the newer routers. That is worth knowing before you adopt this feature outside a big-bang upgrade.
What it costs
I compared throughput between the three-level nested chain and a functionally equivalent flat router with the same two middlewares chained directly onto one rule, using ab at 100 concurrent connections, 5,000 requests, five runs each:
| Setup | Run 1 | Run 2 | Run 3 | Run 4 | Run 5 | Mean req/s |
|---|---|---|---|---|---|---|
| Multi-layer (3 hops) | 5,464 | 5,458 | 5,261 | 5,446 | 5,748 | 5,475 |
| Flat (1 router) | 5,422 | 5,564 | 5,323 | 5,572 | 5,633 | 5,503 |
The difference is inside the run-to-run noise of a single setup. I went in expecting the extra router hops to cost something measurable, the way an extra network proxy hop would, and they did not. Traefik resolves the router chain once per request as an in-process function call, not as a separate network round trip, so three logical layers behave like one for throughput purposes at this request rate. I would not assume the same holds at ten or twenty layers of nesting, and I did not test that.
What I got wrong on the way
My first version of the bypass test was worthless and I didn't notice for a few minutes. I had left priority: 1 off the catchall router, and Traefik's default priority ordering favours longer, more specific rules, which meant my spoofed request was matching parent-api's /api rule by accident because I'd sent it to a URL that still started with /api further down the path hierarchy I was testing. The "bypass" I thought I'd found was actually just a request that legitimately belonged to the parent's own scope. Once I checked which router had actually served the response, rather than only checking which backend container answered, I redesigned the test to hit paths with no /api prefix at all, and the result flipped to the one reported above. The lesson was to log the router name from the debug API alongside every test request, not just the response body, because two different routers can serve visually identical whoami output.
Run it yourself
This reproduces the core bypass test. It needs Docker and about five minutes:
docker network create traefiktest
docker run -d --name admin-svc --network traefiktest traefik/whoami
docker run -d --name default-svc --network traefiktest traefik/whoami
mkdir -p dynamic
cat > static.yml <<'EOF'
entryPoints:
web:
address: ":80"
api:
insecure: true
providers:
file:
directory: /etc/traefik/dynamic
watch: true
EOF
cat > dynamic/config.yml <<'EOF'
http:
middlewares:
role-admin:
headers:
customRequestHeaders: { X-Role: "admin" }
services:
admin-svc: { loadBalancer: { servers: [{ url: "http://admin-svc:80" }] } }
default-svc: { loadBalancer: { servers: [{ url: "http://default-svc:80" }] } }
routers:
parent-api:
rule: "PathPrefix(`/api`)"
entryPoints: ["web"]
middlewares: ["role-admin"]
child-admin:
rule: "HeaderRegexp(`X-Role`, `^admin$`)"
parentRefs: ["parent-api"]
service: "admin-svc"
catchall:
rule: "PathPrefix(`/`)"
entryPoints: ["web"]
service: "default-svc"
priority: 1
EOF
docker run -d --name traefik36 --network traefiktest -p 18080:80 -p 18081:8080 \
-v "$(pwd)/static.yml:/etc/traefik/traefik.yml:ro" \
-v "$(pwd)/dynamic:/etc/traefik/dynamic:ro" \
traefik:v3.6
sleep 3
curl -s http://localhost:18080/api/x | grep -i hostname # should hit admin-svc
curl -s -H "X-Role: admin" http://localhost:18080/x | grep -i hostname # should hit default-svc
If your platform still runs any Traefik instance below 3.6, do not share a dynamic config file between it and a 3.6 instance until every instance is upgraded. A single parentRefs field anywhere in the file will take down routing on the older version entirely, not just the router that uses it. If you are building an auth-gated routing tree with this feature, the header-spoofing protection held up in my testing, including under concurrent load, but I would still verify it against your own middleware chain before trusting it in front of anything sensitive: I tested one middleware type (customRequestHeaders), not a real forward-auth integration.
Top comments (0)