---
title: "SSO is for people, not for machines"
date: 2026-08-02
summary: "When an app grows a human face on an interface machines also depend on, one auth layer over everything either breaks the machines or quietly weakens the human door to let them through. The pattern that holds: a single identity-provider proxy in front of every human door, while machine paths keep their own scoped credentials — route by audience, not by app."
tags: ["security", "sso", "authentik", "kubernetes", "homelab"]
draft: false
hero: "/blog/one-doorman-many-doors.webp"
heroAlt: "A glowing reception desk in a dark circuit-lined lobby signs streams of visitor light through many doors, while courier light-trails slip through keyed loading docks around the building's edge"
---

Sooner or later, a machine-facing service grows a human face. The container registry every node in the fleet pulls from ships a web UI. The development environment becomes a browser tab with a live terminal in it. Now there are people arriving at hostnames that machines also depend on — and the tempting move is one auth layer over the whole thing. Put the entire host behind single sign-on. One rule, no exceptions, done.

Do that to a registry and the cluster stops pulling images the same afternoon, because a container runtime cannot answer an interactive login page. The failure after that one is quieter and worse: you see the machines breaking, so you loosen the layer to let them through — an exception here, an anonymous path there — until the human door is weaker *because* the machines needed in.

The answer isn't a cleverer single layer. It's a split: **route by audience, not by app**. People get one doorman. Machines get keyed doors of their own, nowhere near the desk.

<!-- DIAGRAM: One hostname, two audiences. Left: human browsers converge on a single identity-provider proxy outpost (the doorman), which fronts multiple app doors — registry web UI and browser IDE — each app a provider bound to the SAME outpost. Right: machine clients (node container runtimes, CI) hit the registry's /v2 path directly, split off at the gateway, presenting their own scoped read-only credential — never touching the SSO layer. Show the gateway making the split: /v2 → registry direct, catch-all → outpost. Cyan #0c8fce human path, magenta #c026d3 machine path, violet #7c3aed app boxes, on the light card. -->
![One host, two doors — the gateway splits by audience: browsers route through the identity-provider outpost, while the registry's /v2 machine path goes direct with its own scoped credential](/diagrams/one-doorman-many-doors.svg)

## A doorman is for doors that can't answer for themselves

The house rule comes before anything else: when an app speaks OIDC natively, wire it natively. The app authenticates its own users against the identity provider and stays directly reachable — which matters, because its existing machine clients (a credential vault's mobile app, a git CLI pushing over HTTPS) keep working untouched. Native wiring routes by audience for free.

A proxy outpost — an authenticating reverse proxy operated by the identity provider — is for the remainder: apps with no serious auth story of their own, or surfaces you'd never trust to defend themselves. Two tenants in this fleet fit that bill. A registry web UI, whose built-in auth is a static htpasswd file — a fine key for a machine, a poor front door for a person. And a browser IDE — an editor in a tab with a real shell behind it, which is about the most consequential thing you can put behind a URL.

## One desk signs in every visitor

Recent versions of the identity provider dropped their embedded proxy, so the outpost is now a deployment you run yourself. That sounds like a tax. It's the opposite, because you only ever run one. A single outpost serves many providers: each new application binds a new provider to the *same* instance. When the browser IDE arrived — the second tenant — the marginal cost was a provider object, a route pointing its hostname at the outpost, and a matching pair of network policies. No new deployment. App N+1 is a nameplate at the desk, not a second desk.

Standing up the desk the first time is where the hours went. Three traps, each with a price paid in real time:

- **A provider created by automation isn't a provider created by the UI.** Scripting the provider into existence skipped the OAuth defaults the admin UI sets silently — the redirect URIs sat empty, and every successful login dumped the user onto the identity provider's own homepage with a shrug of "no provider url". The login worked; it just went nowhere. Set the defaults explicitly, every time.
- **The outpost has to be told which host the browser sees.** Left alone it knows only its internal service name, and it will happily redirect your browser to an address that exists nowhere outside the cluster. One environment variable carrying the browser-facing hostname closes the loop — and it wins over anything set through the API.
- **A token with an invisible trailing newline fails like everything else fails.** The outpost's token, pasted into a secret, carried a newline you cannot see. The authorisation header it produces is invalid, and the symptoms are indistinguishable from both traps above. Strip the newline before the secret ever exists.

## Couriers don't queue at reception

The registry is the sharper case, because its real customers were never people. Every node's container runtime pulls images through its API — the `/v2` path — dozens of times a day. Put the doorman in front of that and every pull in the fleet dies waiting on a login page no runtime can answer.

So the route splits at the gateway. `/v2` goes straight to the registry. Everything else on the same hostname — the UI, the search — goes to the outpost and gets the full sign-in. And the direct path isn't left swinging open as the price of working: it's keyed with its own scoped, read-only identity, carried in each node's registry configuration. The couriers don't queue at reception, and reception doesn't prop the dock open for them either.

One hostname, two audiences, two doors — and neither is weakened to accommodate the other.

## Rattle every door before you trust the lock

The proof discipline matters as much as the pattern. The easy check is to load the homepage, watch it bounce to the sign-in page, and call the thing secured. But the homepage is the door nobody breaks in through.

The browser IDE made this concrete. Its whole value is a terminal speaking over a WebSocket — and a WebSocket doesn't render a login page. It either connects or it doesn't. Had the route carved that path out for convenience, the front door would be locked while a side corridor ran straight past the desk, exactly where no identity is ever checked. The same goes for static assets, and for the health endpoint.

So the route sends every path on the host to the outpost, and the check was empirical: an unauthenticated request to the root, to the terminal WebSocket, to a static asset and to the health endpoint each came back as a redirect to the identity provider. All of them. On a human door, a single carve-out *is* the hole; carve-outs belong only on machine doors, where they get keys of their own. And behind the doorman, the IDE still keeps a lock of its own — the desk adds a layer; it doesn't replace the one on the office door.

## The principle

Slice authentication by who arrives, not by what serves the request. Humans are one audience — interactive, browser-borne, phishable — and they get a doorman: one strong flow, every human door in the estate behind the same desk, so door N+1 inherits the whole policy for the cost of a nameplate. Machines are the other audience — headless, scoped, incapable of answering a challenge — and they get narrow keys to their own doors, which the doorman never learns about.

The one-layer instinct fails because it treats the app as the unit of security. The unit is the audience. Sign the people in at one desk, key the machines at the dock — and neither door ever has to weaken to let the other's traffic through.

*Live in the homelab: one Authentik proxy outpost on the cave cluster fronting the Zot registry UI and a code-server IDE; the registry's `/v2` pull path keyed separately with a read-only identity across six Talos nodes.*
