diff --git a/public/diagrams/authentik-sso.svg b/public/diagrams/authentik-sso.svg new file mode 100644 index 0000000..9d2cd92 --- /dev/null +++ b/public/diagrams/authentik-sso.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/diagrams/edge-ai.svg b/public/diagrams/edge-ai.svg new file mode 100644 index 0000000..11b4bdb --- /dev/null +++ b/public/diagrams/edge-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/diagrams/homelab.svg b/public/diagrams/homelab.svg new file mode 100644 index 0000000..c987bef --- /dev/null +++ b/public/diagrams/homelab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/diagrams/iac-fleet.svg b/public/diagrams/iac-fleet.svg new file mode 100644 index 0000000..90eedac --- /dev/null +++ b/public/diagrams/iac-fleet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/diagrams/outbound-exposure.svg b/public/diagrams/outbound-exposure.svg new file mode 100644 index 0000000..5226dca --- /dev/null +++ b/public/diagrams/outbound-exposure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Diagram.astro b/src/components/Diagram.astro index 400fd8b..94e6d77 100644 --- a/src/components/Diagram.astro +++ b/src/components/Diagram.astro @@ -1,175 +1,48 @@ --- -// Hand-authored, theme-aware architecture diagrams rendered as inline SVG at build. -// No client JS, no headless browser, no inline diff --git a/src/content/blog/authentik-sso-2fa.md b/src/content/blog/authentik-sso-2fa.md new file mode 100644 index 0000000..2635af0 --- /dev/null +++ b/src/content/blog/authentik-sso-2fa.md @@ -0,0 +1,58 @@ +--- +title: "SSO is a perimeter decision, not a login box" +date: 2026-06-23 +summary: "Putting single sign-on in front of a fleet of self-hosted apps isn't about a nicer login screen — it's about where your trust boundary lives, and how it fails." +tags: ["authentik", "oidc", "sso", "security", "kubernetes"] +draft: false +--- + +Most write-ups treat single sign-on as a feature you bolt on: stand up an identity provider, point the +apps at it, enjoy one login. That framing misses the actual decision. SSO *moves your trust boundary* — +every app now trusts one issuer, and every account is one credential away from all of them. Get it right +and you've hardened the whole estate at once. Get it wrong and you've built a single, central thing to lose. + +Here's how I think about it after wiring an OIDC provider in front of a self-hosted fleet. + +![Authentik SSO flow — the browser authenticates against the identity provider over the front channel; each app validates tokens over an internal back channel](/diagrams/authentik-sso.svg) + +## Enforce the second factor at the flow, not per app + +The leverage is putting 2FA on the *authentication flow itself*, not on each application. Every app that +federates to the provider inherits it for free — you can't forget to enable 2FA on app number nine, because +app number nine never sees a password. Enrolment (TOTP / WebAuthn) is mandatory at the identity layer, and +the apps just receive an already-verified identity. + +That's the whole point of centralising: you set the policy once, at the boundary, and everything downstream +gets it. + +## Keep a key under the mat + +The flip side of "one issuer for everything" is that when the issuer is down — or you fat-finger the auth +flow — you can lock yourself out of *everything*, including the tools you'd use to fix it. So every critical +app keeps a **local break-glass admin** that bypasses SSO, and the recovery codes for 2FA enrolment live +offline. + +SSO is the front door. Break-glass is the fire exit. You don't ship a building with only one. + +## The gotchas that actually cost time + +Two traps ate real hours, and they're the same lesson wearing two hats: + +- **A baked-in config file silently overrode environment variables.** I set the OIDC scopes via env, login + kept failing with *"missing fields: email"*, and the cause was a config file winning over the env. The + provider needs `openid email profile` explicitly — set where the app actually reads it, not where you + assume it does. +- **In-cluster back-channels can't use the public hostname.** The browser hits the public login URL fine, + but the app's *server-side* token exchange runs inside the cluster, where split-horizon DNS doesn't + resolve the public name. The fix: point the back channel at the internal service address while the + browser-facing URL stays public. + +Both say the same thing: SSO has two channels — the one the user sees and the one the server uses — and they +don't live on the same network. + +## The principle + +Centralising identity is a force multiplier, but it concentrates risk *by design*. So design the +concentration: enforce the strong factor at the boundary, plan the failure mode before you need it, and +remember that OIDC authenticates a *user* — it doesn't, on its own, make a sensitive service safe to expose. +The login box is the easy part. The perimeter is the decision. diff --git a/src/content/blog/outbound-only-exposure.md b/src/content/blog/outbound-only-exposure.md new file mode 100644 index 0000000..6d12d7b --- /dev/null +++ b/src/content/blog/outbound-only-exposure.md @@ -0,0 +1,53 @@ +--- +title: "The most secure inbound port is the one you never open" +date: 2026-06-23 +summary: "Exposing self-hosted services to the internet with zero open ports, zero port-forwarding, and the origin's IP never leaving the building." +tags: ["cloudflare", "networking", "security", "zero-trust", "kubernetes"] +draft: false +--- + +The default way to put a home service on the internet is to forward a port: poke a hole in the router, map +it to the box, and hope the thing behind it never has a bad day. Every open port is a standing invitation +that's online whether or not anyone's knocking. + +There's a better shape: don't open anything. Let the origin dial *out*. + +![Outbound-only exposure — public traffic hits a Cloudflare edge with WAF and geo rules; the origin holds a single outbound tunnel; nothing is port-forwarded](/diagrams/outbound-exposure.svg) + +## The tunnel dials out + +A lightweight connector runs next to the service and opens a persistent **outbound** connection to the edge +network. Public traffic arrives at the edge, and the edge hands it back down that already-open tunnel. The +router has no inbound rule. The origin's public IP is never advertised. Port-scan the home connection and +there's nothing listening — because there isn't. + +You've inverted the trust direction: instead of the internet reaching *in*, the box reaches *out*. + +## Same name, two answers + +The wrinkle is DNS. I run **split-horizon**: the internal domain resolves to in-cluster ingress for anyone +on the LAN, and the public domain resolves through the edge for the outside world. Same services, two names, +two answers depending on where you're standing — and the internal estate is never reachable through the +public path. + +This bites in a non-obvious way too: a pod *inside* the cluster can't resolve the public hostname (it gets +the internal view), so any server-side callout has to target the internal service address directly. +Browser-facing stays public; machine-to-machine stays internal. + +## The edge is the perimeter + +Because every request now funnels through one edge before it ever touches home, that edge *is* the firewall. +So the controls live there, not on the origin: + +- geo-fence to the regions you actually serve, +- rate-limit the authentication endpoints, +- put access control in front of anything administrative. + +The origin's job shrinks to one thing: hold the tunnel open and serve. Everything hostile is filtered a +continent away. + +## The principle + +Exposure isn't binary, and it isn't a synonym for port-forwarding. Push the perimeter out to an edge you +don't host, make the origin speak only outbound, and the attack surface at home collapses to zero open +ports. The safest port is the one that was never listening. diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 63f9856..2fc85c3 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -85,4 +85,16 @@ const { Content } = await render(entry); .post__body { margin-top: var(--space-7); } + /* Diagram images (Markdown `![](/diagrams/*.svg)`) sit on a constant light card so the + fixed-colour SVGs stay legible in dark mode — mirrors the Diagram.astro component. */ + .post__body :global(img[src^="/diagrams/"]) { + display: block; + width: 100%; + height: auto; + margin: var(--space-6) auto; + padding: var(--space-5); + background: #f6f8fb; + border: 1px solid rgba(2, 6, 23, 0.1); + border-radius: var(--radius); + }