The Guard Is at the Front Door
We put an admin panel behind the setup every guide shows: a path block for /admin, an allow list for one LAN address, and forward auth for the login. Then a stranger sent 13 different spellings of /admin. On nginx, 2 got into the panel. On Caddy, 2 did. On Traefik, 3 did. The guard was real. It just stood at the one door the guide drew.
The cause is the same in all three. A path block is a guess about how your app reads paths. Express matches case-insensitively, so /ADMIN is the admin panel to it. nginx’s location /admin and Traefik’s PathPrefix are case-sensitive, so /ADMIN is not the admin panel to them. Caddy’s path /admin/* is case-insensitive, but it also requires a slash, so bare /admin walked past. Two more problems showed up after that. A stranger could pose as alice by sending a Remote-User header to any route that skipped forward auth. And two of the proxies’ own control planes answered a neighbor on the Docker network with the full config. On Caddy, the neighbor could replace it.
The fix has four parts. Match the way the app matches, which means case-insensitive and by prefix. Strip the identity header on every route. Keep control planes off the network. Probe with all 13 spellings, because a config that looks right tells you nothing.
This is Part 5, the last part of a series. What Your Reverse Proxy Leaks by Default covered banners and catch-all servers. Reverse Proxy TLS: What the A+ Hides covered ciphers and HSTS. Security Headers That Break Your Apps covered the headers. Rate Limits That Lock Out the Wrong User covered client addresses and limits. The baseline here is where Part 4 stopped.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/reverse-proxy-hardening/05-admin-paths
How We Tested
One machine, one Docker network (172.31.50.0/24). nginx 1.31.8, Caddy 2.11.9 (Part 4’s build with the rate-limit module), and Traefik v3.7.17 each carry exactly Part 4’s hardened config. Behind them sits an Express 5.3.0 app on Node 26.13.0 with an admin panel mounted at /admin. The panel has no login of its own and trusts the proxy. A /whoami route reads identity from the Remote-User header, the way header-auth setups do.
A small Python stub plays the auth server, shaped like Authelia’s verify endpoint. It answers 200 with Remote-User: alice for the cookie session=alice-ok, 401 for anything else, and 200 with no user for paths under /admin/health, like a bypass rule would. Two curl clients sit on the network: client-lan (172.31.50.101), the admin’s machine and the only allow-listed address, and client-out (172.31.50.102), a stranger. In the control-plane tests the stranger stands in for a compromised container on the same Docker network.
The stranger sends these 13 spellings with curl --path-as-is, so curl does not tidy them first:
/admin /admin/ /admin/users /ADMIN /Admin/users //admin /./admin/api/../admin /%61dmin /%41DMIN /admin%2Fusers /admin%20 /admin.“Got in” means the Express admin router answered. We ran three modes. Baseline has no admin protection. Trap adds admin protection the way most guides do. Hardened is the fix. The limits are in the box at the top, and the main ones are one backend and a stub auth server.
The Scorecard
Each cell reads nginx / Caddy / Traefik.
| Test | Baseline | Trap | Hardened |
|---|---|---|---|
| Spellings that reached the admin panel (of 13, stranger) | 5 / 5 / 10 | 2 / 2 / 3 | 0 / 0 / 0 |
Stranger’s Remote-User: alice believed by /whoami | yes / yes / yes | yes / yes / yes | no / no / no |
Real admin (LAN, session) on /admin, /admin/, /admin/users | n/a | 3 of 3 | 3 of 3 |
| Stranger reads the proxy’s own config | n/a / no / no | n/a / yes / yes | n/a / no / no |
The baseline row is worth a look. Without any protection, nginx and Caddy forwarded the path exactly as sent, and Express answered 404 to //admin, /./admin, /api/../admin, and /%61dmin. Traefik cleaned and decoded the path before forwarding, so //admin arrived as /admin and /%41DMIN arrived as /ADMIN. Express then served 10 of 13. The proxy that tidies the path is the proxy that hands your app more valid admin URLs.
The real admin got in on all three proxies in the trap and hardened runs, with remote_user set to alice. The one exception was bare /admin on the Caddy trap, which skipped forward auth and arrived with remote_user null. The same admin without a session got 401. Hardened costs the legitimate user nothing.
The Trap: Three Ways Past a Path Block
nginx: the case-sensitive location
The guide config is location /admin { allow ...; deny all; auth_request /_auth; ... }. nginx location prefixes are case-sensitive, so /ADMIN and /Admin/users matched the catch-all location / and went straight to the app. 2 of 13 got in, both with remote_user null and host set to app.example.test. Ten of the other 11 got a 403.
One spelling is worth a closer look. /%41DMIN (an encoded capital A) also missed the location, but Express answered 404, because nginx forwarded the raw /%41DMIN and Express does not decode it before routing. That is luck. A different backend that decodes before routing would have served it.
The trap config also contained a quieter bug. It set proxy_set_header Remote-User $auth_user; inside location /admin. In nginx, proxy_set_header in a location replaces all of the server-level proxy_set_header lines for that location. The app saw Host: backend:8080 instead of app.example.test, and lost X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto on the admin routes. It is the same inheritance rule that bit us with add_header in Part 3. Last, the stock 403 page ends in <hr><center>nginx</center> even with server_tokens off, which is the Part 1 leak again.
Caddy: the missing slash
The guide config is @admin path /admin/*. That pattern needs the slash. A stranger asking for bare /admin did not match, and the request fell through to the catch-all reverse_proxy. /admin got in. /ADMIN got in for the same reason: the case was fine, the missing slash was not. 2 of 13.
Caddy’s path matcher is case-insensitive, so /Admin/users was caught, and the stranger got a 401 for it. Which brings up an oddity: strangers got 401 instead of the 403 the config asked for. Caddy sorts directives into a fixed order, and forward_auth runs before respond. Every stranger’s request went to the auth server before the IP check ever ran. That is more load on your login service from people who should never reach it.
Traefik: PathPrefix and the decoder
The guide config is a router with PathPrefix(`/admin`). That rule is case-sensitive, so /ADMIN and /Admin/users skipped the admin router. The third spelling was /%41DMIN. Traefik decoded it to /ADMIN, did not match the router, and forwarded the decoded /ADMIN to Express, which served the panel. 3 of 13. Compare nginx, which forwarded the raw string and got a 404. Both proxies missed the block. The difference was luck in what each one sent next.
The Identity Header Nobody Strips
Many apps can log a user in from a header the proxy sets. Authelia sends one called Remote-User, and Grafana’s auth.proxy mode reads a header like it. We checked what happens when the client writes the header itself.
First, the good news. Forward auth did its job on all three proxies. A valid session plus a client-sent Remote-User: root gave the app alice. The auth response header replaces the client’s. A request that the auth server answered with 200 and no user (our /admin/health bypass) plus Remote-User: root gave the app null. nginx did that because an empty proxy_set_header value is not sent. Caddy’s forward_auth with copy_headers, and Traefik’s authResponseHeaders, removed the client’s header.
Now the bad news. A stranger sent Remote-User: alice to the public /whoami route, which never touches forward auth. On all three proxies, in baseline and trap, the app answered "remote_user":"alice". The hole is not in forward auth. It is in every route that skips it. Your proxy passes client headers through by default, so a header that means “the proxy vouched for this user” arrives from anyone who types it.
The Proxy’s Own Door
Proxies have a control plane, and quick-start guides open it.
Caddy: admin on 0.0.0.0
Caddy’s admin API listens on localhost:2019 by default. A common guide edit is admin 0.0.0.0:2019, so other containers can push config or run a reload. We set it. From the stranger’s address, GET /config/ returned 200 with the full running config, with the host header set to 172.31.50.22:2019 and again with Host: localhost:2019. Changing the host header made no difference.
Then we sent POST /load with a replacement config. It returned 200, and http://172.31.50.22/ answered “owned”. The stranger had replaced the site. Restarting the container restored the Caddyfile, which is the only reason this was a lab and not an incident. In baseline and hardened runs, where the admin API stays on localhost, the same requests got connection refused.
Keeping the API on localhost costs nothing for reloads. caddy reload run inside the container, with docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile, exited 0 in all three modes.
Traefik: --api.insecure=true
The Traefik quick start adds --api.insecure=true and publishes port 8080 on all interfaces. We added the flag and kept the host port on loopback, so the stranger reached 8080 over the Docker network. We also built a properly secured dashboard router on traefik.example.test with the LAN allow list and forward auth. The router worked: the stranger got 403, and the admin with a session got 200.
Port 8080 did not care. /api/rawdata answered the stranger with 200 and listed 8 routers and 2 services. The data includes the forward-auth server address (http://auth:9091/verify), the admin allow list (172.31.50.101/32), and the backend address (http://172.31.50.30:8080). /dashboard/ returned 200 too. A guard on the front door does not matter when the same building has an unlocked garage.
The others
nginx open source has no admin API, so there was nothing to test. Nginx Proxy Manager has a different problem: its admin UI on port 81 starts with a first-run account that anyone can claim. That was covered in Part 1, What Your Reverse Proxy Leaks by Default.
The Fix: Match the App, Strip the Header, Close the Port
The hardened run changed a handful of lines in each proxy. Every test passed on all three. The stranger got 0 of 13 spellings on every proxy, /whoami answered null to the forged header, and the control planes refused connections or stayed on localhost. The real admin still got in on /admin, /admin/, and /admin/users with remote_user alice. The kit’s audit.sh output matched Part 4’s hardened run line for line, so nothing earlier regressed.
In the configs below, 172.31.50.101 is the lab’s admin machine. Replace it with yours.
nginx
# ... Part 1 to 4 lines unchanged ...
server { # ... Part 1 to 4 lines unchanged ...
proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; # Set here, at server level, so every location sends it. A proxy_set_header inside a location # would replace ALL of the lines above for that location (the app then sees Host: backend:8080). # $auth_user is empty everywhere except /admin, and nginx drops a header whose value is empty, # so a client's own Remote-User never reaches the app. proxy_set_header Remote-User $auth_user; set $auth_user ""; # Stock 401 and 403 pages say "nginx" in the body (the Part 1 leak). error_page 401 @unauthorized; error_page 403 @forbidden;
# ~* is case-insensitive, because Express (and most app routers) are. nginx matches against the # decoded, cleaned path, so //admin, /./admin and /%61dmin land here too. location ~* ^/admin { allow 172.31.50.101; # the admin's LAN machine deny all; auth_request /_auth; auth_request_set $auth_user $upstream_http_remote_user; proxy_pass http://backend:8080; }
location = /_auth { internal; proxy_pass http://auth:9091/verify; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; }
location @unauthorized { default_type text/plain; return 401 "Login required.\n"; }
location @forbidden { default_type text/plain; return 403 "Forbidden.\n"; }}Three changes. The location is regex and case-insensitive. The Remote-User header moves to server level, so the app gets an empty value on routes that never call auth_request_set, and nginx drops an empty header. The 401 and 403 pages are plain text.
Caddy
# The admin API stays where Caddy puts it by default: localhost:2019 inside the container.# (The trap's "admin 0.0.0.0:2019" line is gone from the global block.)
app.example.test { # ... Part 1 to 4 lines unchanged ...
# The identity header comes from the auth server or not at all. request_header -Remote-User
# /admin* covers /admin itself; /admin/* does not. Caddy's path matcher is case-insensitive. @admin path /admin* handle @admin { # route keeps the order as written. Without it Caddy sorts forward_auth ahead of respond, # so every stranger's request goes to the auth server before the IP check. route { @outsider not client_ip 172.31.50.101 respond @outsider "Forbidden." 403 forward_auth auth:9091 { uri /verify copy_headers Remote-User } reverse_proxy backend:8080 } }
# ... reverse_proxy and handle_errors unchanged ...}request_header -Remote-User sits at site level, so the client’s copy is gone on every route before the request reaches the app. copy_headers then sets the real one on /admin. Strangers now get a 403 with “Forbidden.” from the IP check, and the auth server never sees them.
Traefik
http: middlewares: # ... Part 1 to 4 middlewares unchanged ...
# Every router: the identity header comes from forwardAuth or not at all. strip-identity: headers: customRequestHeaders: Remote-User: "" admin-lan: ipAllowList: sourceRange: ["172.31.50.101/32"] authelia: forwardAuth: address: http://auth:9091/verify authResponseHeaders: ["Remote-User"]The routers live in compose labels:
# (?i) because Express routes case-insensitively. Traefik matches the cleaned, decoded path.- traefik.http.routers.admin.rule=Host(`app.example.test`) && PathRegexp(`(?i)^/admin`)- traefik.http.routers.admin.middlewares=strip-identity@file,admin-lan@file,authelia@file,strip-banners@file,hsts@file,base-headers@file,policy-headers@file- traefik.http.routers.app.middlewares=strip-identity@file,strip-banners@file,hsts@file,base-headers@file,policy-headers@file# ... login and api routers get strip-identity@file the same way ...
# command: the dashboard without api.insecure. No port 8080, reachable only through its router.- --api.dashboard=trueThe strip-identity middleware belongs on every router. A router without it is the public /whoami hole all over again. The dashboard has no api.insecure and no published 8080, and the router on traefik.example.test still serves it, with the allow list and forward auth in front.
One more thing about the regex
^/admin is a prefix match, so it also catches /administrator and /admin-tools. That is the safe direction. A block that catches a few extra paths is an annoyance. A block that misses one is a breach. If you have a public page under such a name, move the page, not the rule.
Run It Against Your Own Proxy
You need one address on the allow list and one that is not. From the second one, run:
# 1. Try the 13 spellings. Fetch anything that prints 200 and see whether it is the panel.for p in /admin /admin/ /admin/users /ADMIN /Admin/users //admin /./admin \ /api/../admin /%61dmin /%41DMIN /admin%2Fusers /admin%20 /admin.; do printf '%s ' "$p" curl -s -k --path-as-is -o /dev/null -w '%{http_code}\n' "https://your.domain.example$p"done
# 2. Pose as a logged-in user on a public route. If the app echoes your name, the header is trusted.curl -s -H 'Remote-User: alice' https://your.domain.example/whoamiRead the result like this:
- A 200 on any spelling in step 1 means the block does not match the way your app reads paths. Add the matching spelling and run it again.
- A 404 from the app on a spelling means only that your backend declined it this time. A different backend or a framework upgrade might serve it.
- If step 2 shows the name you sent, any route that skips forward auth can be reached by a forged identity.
- From a second host,
curl -s http://your.proxy.host:2019/config/andcurl -s http://your.proxy.host:8080/api/rawdatashould refuse the connection. Anything else is a control plane on the network.
The kit’s run.sh runs the whole lab in Docker if you want the full probe output for all three modes.
The SumGuy Take
A path block copied from a guide encodes the guide author’s backend. Yours reads paths differently, and the proxy and the app each have a say. The only reliable defense is a rule at least as broad as the app’s, plus a test that tries the awkward spellings. If your proxy blocks /admin and your app serves /ADMIN, you have a side door and a sign on the front door.
Identity headers need a stricter rule: strip them from the client on every route, then let forward auth set them on the routes that need it. And keep the control plane off the network. The proxy that guards your admin panel should not hand its own config to a neighbor.
The Whole Series
All five parts, with the one fix to take from each:
- What Your Reverse Proxy Leaks by Default: make unknown hosts and bare IPs get nothing, and hide the banners.
- Reverse Proxy TLS: What the A+ Hides: pick ciphers and curves on purpose, and test what a handshake offers.
- Security Headers That Break Your Apps: add headers where the app sends none, and check what the app already sets.
- Rate Limits That Lock Out the Wrong User: trust exactly your CDN’s address and read
X-Forwarded-Forfrom the right. - This post: match the way the app matches paths, strip the identity header, and keep control planes off the network.
The checklist, one line per part:
- Test a bare IP and an unknown hostname. Both should get nothing.
- Test your TLS settings from outside, not from the config.
- Check each response for the headers you expect.
- Send a forged
X-Forwarded-Forstraight at the origin. - Send the 13 admin spellings and a forged
Remote-User.
Common Questions
Is Traefik api.insecure safe on a home network?
No. --api.insecure=true opens port 8080 with no authentication, and it ignores your dashboard router’s allow list and forward auth. In our run a stranger on the Docker network read the router list, the auth server address, and the backend address. Use --api.dashboard=true and put the dashboard behind a router with middleware.
How do I reload Caddy if the admin API is only on localhost?
Run the reload inside the container: docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile. It talks to the admin API on localhost and exited 0 in all three of our modes. You only need admin 0.0.0.0:2019 if another container must push config, which most setups do not.
Is nginx location matching case-sensitive?
Yes for prefix and exact locations. location /admin does not match /ADMIN, so that request fell through to location / in our test. Use location ~* ^/admin for a case-insensitive regex match. nginx matches the decoded, normalized path, so //admin and /%61dmin also match.
Is hiding the admin path enough security?
No. A hidden path only hides it from people who do not guess it, and our stranger needed nothing but spelling variants. Put the panel behind an IP allow list and forward auth, as in this post. Then test with the spellings above. Obscurity can be an extra layer on top, never the only one.