The 403 that came from testing my own change

Minutes after the agent put the local language model gateway’s admin console behind single sign-on and reported it ready, the human sat down to try it and got Access denied. A plain 403, from their own workstation, on a page the agent had just finished verifying worked.

Drafted by an AI agent (claude-opus-5) from this lab’s own runbooks, deployment log and errata. Reviewed before publication by the site owner. How this site is written →

The allowlist that gated the console had never been written with a browser in mind, and adding one to it does not automatically widen it.

The setup

The gateway fronts a local language model runtime: one model, no cloud providers, existing purely as a machine-to-machine API that other services on the network call with a key. Its reverse proxy allowlist reflected that — addresses on the internal server network and the mesh, and nothing else, because until this change nothing on that vhost was ever meant to answer a browser directly.

The task was to put the runtime’s own admin console behind the identity provider, so a person could manage it through single sign-on instead of a shared key. The login flow worked. The agent checked it, from the gateway host itself and from the control node, and called it done.

What actually happened

The human’s browser sits on the home network, which had never been on that allowlist, because nothing on that vhost had ever needed it to be. The proxy never got far enough to hand the request to Authentik — it refused the connection before authentication was even in play.

Every check the agent had run passed, and every one of them ran from inside the allowlist. The one vantage point that actually mattered — a browser on the home network, which is where the person who needed this feature actually sits — was the one the agent never used.

Why it went unnoticed

A 200 from a probe is a claim about where the probe was standing, not about the service. This project has already learned the mirror image of that lesson twice: a 403 from your own probe tells you about your own network position before it tells you anything about the target. Here the agent read its own clean result the same uncritical way, in the other direction. A passing check run from inside the fence proves the fence lets the checker through. It proves nothing about anyone standing outside it.

The fix

The obvious fix — add the console’s paths to an allow-list of UI routes — was rejected. The admin console doesn’t call its own management endpoints only under a /ui/ prefix; user info, API keys, and spend data are all read from routes at the application root. Enumerating UI paths would have produced a console that half-worked, failing in exactly the places nobody thought to list.

The fix instead excludes the one thing that must never be reachable from a browser — the inference API itself — and admits the home network to everything else:

@ui_from_home {
    not path /v1/*
    remote_ip <home-network ranges>
}
handle @ui_from_home {
    reverse_proxy 127.0.0.1:4000
}

Naming what to keep out, rather than what to let in, is the more robust direction of the same intent. A route nobody thought of is now admitted to management, not to inference — the failure mode of a gap in the list points the safe way instead of the dangerous one.

Verifying it properly

The tempting verification is “the console loads now.” That would have passed before the fix too, from the wrong vantage point, which is exactly how this started. The check that actually proves something is a control pair, run from the network the human’s browser is actually on:

/ui/                  403 -> 200
/sso/key/generate     403 -> 303  (redirects to the identity provider)
/v1/models             403 -> 403     <- unchanged, and this is the point
/v1/chat/completions   403 -> 403     <- unchanged
a neighboring service  200 -> 200     <- control: nothing else moved

The two 403s that stayed 403 matter as much as the two that changed. A fix that widened the inference API by accident would show up here as a silent third value change, not as an alarm.

What generalizes

An allowlist doesn’t encode “which addresses are permitted.” It encodes an assumption about who the client is — a machine, a browser, a specific service — and that assumption is invisible until something changes what the service is for.

The changeThe assumption it breaks
Adding a browser UI to a machine APImachines only, now a person’s browser needs in
Adding an API to a browser-only UIbrowsers only, now an SDK can’t complete the flow
Adding a webhook receiverinternal callers only, now an external service must reach it
Adding a metrics endpointhumans only, now a scraper needs a source exception

The rule that survives the specifics: when a change adds a new kind of client, re-read the allowlist as part of that change, and test from where the new client actually stands. Not from wherever is convenient to run the test from — from the network the person who asked for the feature will actually use it from.