Nazeem.Me

A blog about technology, football and all the other random stuff in my life

The Auth Layer I Didn’t Build

Written by

in

·

Everything on my home network that has a web interface sits behind one reverse proxy. You type a friendly hostname, the proxy terminates TLS with a real certificate and hands the request to whichever container is behind it. It is the most useful single thing I run, and in August I realised it was also a very tidy list of everything an intruder on my LAN would want to look at.

The proxy adds no authentication of its own. Whatever login the application has is the only login there is. So on 11 August I sat down to answer a reasonable question: does this lab need an identity provider in front of it?

Four days and two complete designs later, I didn’t build one. The net change to the proxy config was a deletion.


The plan

The first design was authentik: a full identity provider with OIDC, SAML, LDAP and a forward-auth outpost that puts a login page in front of apps that have none. PostgreSQL, Redis, a server and a worker. Four cores and 8 GB to be comfortable.

It came with a fit map. Tier 1 was apps that speak OIDC natively and could share one login. Tier 2 was the actual security payoff: services with no login at all, which would get forward auth in front of them. I built that list from the proxy config and from what I knew about each app, and it came to nine of the 22 proxied hostnames: speed test pages, a dashboard, a PDF toolkit, a web SSH client, a torrent client and a few others.

Top of the list, above all nine, was the router. Its admin UI had turned up in the proxy config with no record of when or why I’d added it. Diffing old config backups dated it to around 4 August. It had its own login, but it was now one DNS lookup away from any device on the network, and I called it “the single strongest item on this whole page”.

authentik is a big thing to keep alive, so on 15 August I wrote the second design: Authelia. A single Go binary, YAML config, about 50 MB of RAM. Same forward-auth job, a tenth of the weight. The build sheet was complete down to the container ID, the IP address, the four secrets and which ones had to be stored off-box before first start, a SQLite backup timer, the Caddy directive, a break-glass list, and an order of operations that protected the router alone for a day before touching anything else.

Every part of that plan was sound. It was aimed at the wrong list.


Then I probed

Both designs had a line in their open questions that I’d been politely ignoring: which services have weak auth rather than none has only been assessed by inspection, not probed.

So before building anything, I sent unauthenticated requests to the actual API endpoints of every proxied service, from a machine on the LAN with no cookies and no keys.

On the list of nineWhat it returned
Speed tests ×2, link dashboard, PDF toolkit200, UI served, genuinely no login
Web SSH client401 on its real endpoints
Torrent clientits own login form
Monitoring hub200 with an empty list (see below)
Storage server UIits own login
Routerits own login

Nine became four. Five of the services I was about to put behind a new identity provider had their own authentication the whole time. The estate had also grown in the four days since the list was made, from 22 hostnames to 34. The newcomers, a media automation stack and a set of dashboards, all came back 401 or 403 on their APIs.

Two of the results needed a second look before I trusted them.

The web SSH client returned 200 on every path, including ones I made up. It’s a single-page app that serves index.html as a fallback for anything it doesn’t recognise. A 200 at the root proved the UI loads and nothing else. Its real endpoints all returned 401 Missing authentication token.

The monitoring hub returned 200 with an empty list. That’s what PocketBase sends when a collection rule denies access. It looks like an open endpoint and leaks nothing. The hub was watching eight systems; the anonymous request saw zero.

I’d already caught myself making the first mistake once on that same pass: my first table listed the SSH client as “no login” on the strength of the root 200. It’s the same error as the original plan, at a smaller scale.


The hole nobody had listed

The web SSH client did have a real problem. It just wasn’t the one either plan was designed to fix.

One endpoint, /users/registration-allowed, returned {"allowed":true}.

Open registration, on a web SSH client. Anyone who could reach it could create their own account and use it as a launch point into anything it could reach. The admin account was already claimed, so it wasn’t a takeover, but it was the most dangerous thing the probe found, and it sat in a service my plan had filed under “no auth, put a login in front of it”.

A forward-auth tier would have covered it, by accident, for requests that came through the proxy. That qualifier turned out to matter.


What I actually changed

The router came out of the proxy entirely. The handle block was deleted, the DNS record removed from both Pi-holes, and the admin UI is reachable only at the router’s own address. My own notes had called it the highest-value target on the network. Protecting it with a new service would have kept it on the list. Removing it from the list was the better answer, and it was implied by my own argument the whole time.

Open registration was turned off in the SSH client itself. I blocked the signup path at the proxy first, with a respond 403, and verified it worked. Then I realised it only covered requests that went through the proxy. The container has its own LAN address, and anything on the LAN can talk to it directly. So I turned registration off in the app’s own settings, confirmed a signup sent straight to the container, bypassing the proxy, now returned 403 Registration is currently disabled, and deleted the proxy rule. The app setting closes it everywhere. The proxy rule only ever closed one door into the same room.

(For anyone writing that kind of rule in Caddy: wrap it in route. Under the default directive order reverse_proxy runs before respond, and the block silently does nothing.)

The four genuinely open services stayed open. I added Caddy’s basic_auth to all four, verified it worked, then took it off again. Two speed test pages, a link dashboard and a PDF toolkit. That’s an accepted gap, written down as one, rather than a closed one.

Two things I learned while basic_auth was live:

  • basic_auth only accepts bcrypt hashes, and caddy hash-password will happily give you argon2id. caddy validate passes, because the config is valid. Then the correct password returns 401, and the real reason is only in the journal: bcrypt algorithm version 'a' requested is newer than current version '2'. Pass --algorithm bcrypt.
  • One of the four took 9.5 seconds per authenticated request. The other three served the identical directive in about 8 ms. The backend answered in 0.06 s direct, with the same headers Caddy sends. Removing basic_auth brought it straight back to 0.066 s. I know the cause and still don’t know the mechanism, and any forward-auth tier would have hit it too, on the one service I’d have tested last.

Net result: one handle block deleted from the proxy, one DNS record removed, one checkbox in one app. No new container, no new secrets to store off-box, no new backup timer, no new thing that has to be running for anything else to work.


The same mistake, in the other direction

Eight days later I went back to the authentik note for a different reason, and did for OIDC what I’d done for forward auth: read the installed build of every service, rather than the vendor’s integration page. Package versions, JAR manifests, strings in the binary.

The original fit map had listed five apps that could take part in single sign-on. The installed builds said ten. Six of them either hadn’t existed on 11 August or had never been assessed, because the original list came from integration catalogues rather than from the machines.

So the same planning method had overcounted the unprotected services by more than double and undercounted the SSO-capable ones by half. It’s the same error both times, I planned against my picture of the estate rather than the estate.

That second audit also cuts the other way on the decision. The case for forward auth is now weaker than it was in August, because its strongest target no longer exists. The case for one identity across ten apps, the hypervisors and the NAS units is roughly twice as strong, and that’s something proxy config genuinely cannot do. And it turned up something I hadn’t considered: both Synology units already expose an OAuth server. If the goal narrows to “one login for a handful of apps”, the NAS may already be able to do it with nothing new to keep alive. I haven’t tested that. It’s the first thing to check if this comes back.


What I’d tell someone about to build an auth tier

Probe before you plan. A request to each service’s real API endpoint, without credentials, takes a few minutes. It would have deleted more than half of the target list before I’d written a line of either design.

Don’t trust a 200 at the root. Single-page apps return 200 for everything. Hit an endpoint that should require auth and look at what comes back.

Removing an entry beats protecting it. If something shouldn’t be reachable through the proxy, the strongest control is not having it in the proxy.

Fix it in the app, not in front of it. A proxy rule only covers traffic through the proxy. Anything on the same network can usually reach the container directly.

Count the running cost against the real gap, not the assumed one. An identity provider is a database, secrets that make that database unreadable if you lose them, a backup job, upgrades, and a new dependency that can lock you out of everything at once. That cost was justified against nine unprotected services and a router. It was never going to be justified against four speed test pages and a dashboard.

Both build sheets are still in my notes, complete and unbuilt. One day I may want single sign-on, for convenience rather than security, and when that happens I’ll probe first.