graph TD Req["Request arrives"] --> Handle["handle blocks<br/>(run first)"] Handle --> Proxy["Straight to the app"] Req -.-> Gate["Passkey gate<br/>(never reached)"]
I had just put a passkey login in front of the homelab: Caddy asks a small auth proxy “is this person signed in, and are they in the right group?” before it hands a request to any app. Twenty-nine sites went behind it in one afternoon, and I tested them all the way I’d tested the first one: sign in, load the page, see a 200.
Then I ran the same list without signing in.
The test that passes either way
A signed-in test can’t fail because of a missing gate. If the gate is there, you get through it. If it isn’t, you get through anyway. The only test that tells the two apart is the one that should be refused.
Every gated site answered the anonymous request with a redirect to the login page, which is exactly right. Except one. One control page answered 200, with its full interface, to anyone who asked.
Why the gate wasn’t there
That site had a few special-cased paths, written as handle blocks, and the gate was a line at the top of the site. Reading the config top to bottom, the gate comes first. But Caddy doesn’t run directives in the order you write them. It has a fixed internal order, and handle runs before the auth step. The special paths matched, answered, and the request never got near the gate.
The config looked right. It validated. It loaded. It was wrong in a way you can only see by running it.
The second one, found while looking
The same snippet had a smaller version of the same mistake. It was meant to strip any “I’m this user” headers a client sent before asking the auth proxy, so nobody could forge an identity. But the header-stripping directive also runs after the auth step by default. Nothing could get through to the app with a forged header, but the stripping happened in the wrong place, and it would have removed the real ones too.
The fix
Both fixes are the same move: put the steps inside a single route { } block, which keeps the order you wrote. Strip first, check second, then handle the special paths inside it.
I confirmed it the way I should have the first time: the anonymous request now bounces to the login page on all twenty-nine sites, and I dumped the adapted config to check the handler order directly instead of trusting that it read correctly.
The exposure lasted a few minutes, on a name that only resolves inside the house and on the private network. No harm done. But it was real, and the only thing that caught it was asking the question the gate exists to answer.
What I’m keeping
- Test the refusal, not just the access. A gate that was never exercised for “no” hasn’t been tested.
- Written order is not run order. When a tool has its own fixed order, put the sequence you care about in an explicit block, and look at the adapted config once.
- “Valid” is not “correct.” The config parsed cleanly. The bug was in what it meant.