Publishing your identity provider without making it a target

may 3 2026 · 4 min read

For a long time, every OIDC flow in the homelab only worked from inside the house. That was fine until it was not. Off-network access to apps like the Floorplan Studio editor or the Overtime Calculator meant either opening a VPN first or rebuilding the auth model. I wanted neither of those options, so I made the identity provider publicly reachable. The constraint was that doing it could not weaken anything on the internal side.

The forcing function

Authentik was running behind Traefik and reachable only via the internal network. Any Cloudflare-proxied app that tried to redirect to the IdP for login would time out the moment you were off the LAN. OIDC flows that include a redirect back to the authorization endpoint only work if the browser can actually reach that endpoint. Making the IdP publicly reachable was the prerequisite for making any of the downstream apps work off-network.

Split-horizon DNS

The first architectural piece was split-horizon DNS: the same hostname (auth.kujo.tech) resolves to different addresses depending on where the query originates. Internal DNS returns the Traefik VIP so LAN traffic never leaves the network. External DNS points to Cloudflare's proxy, which then forwards to the Traefik origin. The app itself sees one endpoint regardless of where the caller is sitting. There are no separate internal and external hostnames to maintain, and every downstream OIDC client keeps a single redirect URI.

This is a straightforward pattern but it has a maintenance cost: the internal zone and the Cloudflare zone can drift. I documented the intended state alongside the implementation so that future changes to one zone prompt a check against the other.

Three layers of rate limiting

The IdP is the single point through which every authentication decision flows. Compromise it and you have compromised everything. That asymmetry is the reason it gets more defensive attention than any individual app.

The first layer sits at the Cloudflare edge: WAF rules and a rate limit that applies before any traffic reaches the origin. This handles volumetric brute-force and the most obvious credential stuffing patterns.

The second layer is a per-client rate limit on the Traefik origin. Traffic that passes Cloudflare still hits a limit before it reaches the Authentik container. The purpose of this layer is defense-in-depth: if someone manages to route around Cloudflare (direct-to-origin probing, for example), they still hit a throttle. The two limits have different thresholds because they serve different threat models.

The third layer is Authentik's own reputation lockout. After a configurable number of failed attempts from the same source, Authentik locks that client out at the application level. This layer persists across restarts and does not depend on a network appliance being in the path.

Conditional MFA

MFA fatigue from two-factor prompts on the LAN is real. When I am sitting at home and every app reload triggers an authenticator push, I start bypassing controls to reduce friction. That outcome is worse than not having MFA at all.

The solution was conditional MFA evaluated at the Authentik policy layer. A policy checks the source IP range of the authentication request. LAN-originating requests skip the second factor. Requests from any other origin always require it. This check happens inside Authentik, not in application code, which means it applies uniformly to every downstream app and cannot be bypassed by hitting a different app's endpoint.

The policy is simple, but the important detail is where it runs: in the IdP, not in each application. Putting auth logic in app code creates n copies of the same check, each of which can drift or be omitted. Centralizing it means the rule is enforced regardless of which OIDC client triggers the authentication.

What the documentation got wrong

When I went to verify the deployment topology against the existing Outline runbook, the documentation described an Authentik configuration that no longer matched what was actually running. A previous migration had moved services around, but the docs had not been updated. The "source of truth" was wrong about which services existed and where they were deployed.

I updated the documentation as part of the hardening work, not as an afterthought. The operational state of an identity provider is the kind of thing that needs to be correct in the runbook, because the runbook is what you reach for when things are broken and you cannot trust your memory. Stale topology docs are a liability during incidents.

The blast-radius argument

One way to calibrate how much security investment an individual service deserves is to ask what happens when it is compromised or unavailable. For most apps, the answer is local: that app stops working, or data in that app is at risk.

For the identity provider, the answer is: everything stops working and every session across every app becomes suspect. A compromised IdP means you cannot trust any authenticated state in the system. That blast radius is why the IdP gets three rate-limiting layers, conditional MFA, and a documentation pass as part of a single change, when most other services would get one.

Making it public does not change the blast radius, but it does change the exposure. Before the change, an attacker had to be on the LAN to even reach the login page. After the change, anyone can send traffic to it. The defense-in-depth was the prerequisite for accepting that exposure, not a follow-on improvement.

The public IdP has been running without incident since this work. Every off-network OIDC flow that uses it now works correctly, and the internal path is unchanged.